diff --git a/src/CommandManager.js b/src/CommandManager.js index fa4e20f51e4..cc6e6763648 100644 --- a/src/CommandManager.js +++ b/src/CommandManager.js @@ -2,6 +2,10 @@ * Copyright 2011 Adobe Systems Incorporated. All Rights Reserved. */ +/** + * Manages global application commands that can be called from menu items, key bindings, or subparts + * of the application. + */ var CommandManager = {}; CommandManager._commands = {}; @@ -11,7 +15,9 @@ CommandManager._commands = {}; * * @param {string} id The ID of the command. * @param {function} command The function to call when the command is executed. Any arguments passed to - * execute() (after the id) are passed as arguments to the function. + * execute() (after the id) are passed as arguments to the function. If the function is asynchronous, + * it must return a jQuery Deferred that is resolved when the command completes. Otherwise, the + * CommandManager will assume it is synchronous, and return a Deferred that is already resolved. */ CommandManager.register = function(id, command) { if (CommandManager._commands[id]) { @@ -24,13 +30,21 @@ CommandManager.register = function(id, command) { * Runs a global command. Additional arguments are passed to the command. * * @param {string} id The ID of the command to run. + * @return {Deferred} a jQuery Deferred that will be resolved when the command completes. */ CommandManager.execute = function(id) { var command = CommandManager._commands[id]; if (command) { - command.apply(null, Array.prototype.slice.call(arguments, 1)); + var result = command.apply(null, Array.prototype.slice.call(arguments, 1)); + if (result === undefined) { + return (new $.Deferred()).resolve(); + } + else { + return result; + } } else { console.log("Attempted to call unregistered command: " + id); + return (new $.Deferred()).reject(); } } diff --git a/src/Commands.js b/src/Commands.js index 9c955ce484f..f3ee8ab8ffd 100644 --- a/src/Commands.js +++ b/src/Commands.js @@ -6,5 +6,7 @@ * List of constants for global command IDs. */ var Commands = { - FILE_OPEN: "file.open" + FILE_OPEN: "file.open", + FILE_SAVE: "file.save", + FILE_CLOSE: "file.close" }; diff --git a/src/FileCommandHandlers.js b/src/FileCommandHandlers.js new file mode 100644 index 00000000000..2a0c9978380 --- /dev/null +++ b/src/FileCommandHandlers.js @@ -0,0 +1,247 @@ +/* + * Copyright 2011 Adobe Systems Incorporated. All Rights Reserved. + */ + +/** + * Handlers for commands related to file handling (opening, saving, etc.) + */ +var FileCommandHandlers = (function() { + // TODO: remove this and use the real exports variable when we switch to modules. + var exports = {}; + + var _editor, _title, _currentFilePath, _currentTitlePath, + _isDirty = false, + _savedUndoPosition = 0; + + exports.init = function init(editor, title) { + _editor = editor; + _title = title; + + _editor.setOption("onChange", function() { + updateDirty(); + }); + + // Register global commands + CommandManager.register(Commands.FILE_OPEN, handleFileOpen); + CommandManager.register(Commands.FILE_SAVE, handleFileSave); + CommandManager.register(Commands.FILE_CLOSE, handleFileClose); + }; + + function updateDirty() { + // If we've undone past the undo position at the last save, and there is no redo stack, + // then we can never get back to a non-dirty state. + var historySize = _editor.historySize(); + if (historySize.undo < _savedUndoPosition && historySize.redo == 0) { + _savedUndoPosition = -1; + } + var newIsDirty = (_editor.historySize().undo != _savedUndoPosition); + if (_isDirty != newIsDirty) { + _isDirty = newIsDirty; + updateTitle(); + } + } + + function updateTitle() { + _title.text( + _currentTitlePath + ? (_currentTitlePath + (_isDirty ? " \u2022" : "")) + : "Untitled" + ); + } + + function handleFileOpen(fullPath) { + // TODO: In the future, when we implement multiple open files, we won't close the previous file when opening + // a new one. However, for now, since we only support a single open document, I'm pretending as if we're + // closing the existing file first. This is so that I can put the code that checks for an unsaved file and + // prompts the user to save it in the close command, where it belongs. When we implement multiple open files, + // we can remove this here. + var result; + if (_currentFilePath) { + result = new $.Deferred(); + CommandManager + .execute(Commands.FILE_CLOSE) + .done(function() { + doOpenWithOptionalPath(fullPath) + .done(function() { + result.resolve(); + }) + .fail(function() { + result.reject(); + }); + }) + .fail(function() { + result.reject(); + }); + } + else { + result = doOpenWithOptionalPath(fullPath); + } + result.always(function() { + _editor.focus(); + }); + return result; + } + + function doOpenWithOptionalPath(fullPath) { + if (!fullPath) { + // Prompt the user with a dialog + // TODO: we're relying on this to not be asynchronous--is that safe? + NativeFileSystem.showOpenDialog(false, false, "Open File", ProjectManager.getProjectRoot().fullPath, + ["htm", "html", "js", "css"], function(files) { + if (files.length > 0) { + return doOpen(files[0]); + } + }); + } + else { + return doOpen(fullPath); + } + } + + function doOpen(fullPath) { + var result = new $.Deferred(); + if (fullPath) { + var reader = new NativeFileSystem.FileReader(); + + // TODO: we should implement something like NativeFileSystem.resolveNativeFileSystemURL() (similar + // to what's in the standard file API) to get a FileEntry, rather than manually constructing it + var fileEntry = new NativeFileSystem.FileEntry(fullPath); + + // TODO: it's weird to have to construct a FileEntry just to get a File. + fileEntry.file(function(file) { + reader.onload = function(event) { + _currentFilePath = _currentTitlePath = fullPath; + + // In the main toolbar, show the project-relative path (if the file is inside the current project) + // or the full absolute path (if it's not in the project). + var projectRootPath = ProjectManager.getProjectRoot().fullPath; + if (projectRootPath.length > 0 && projectRootPath.charAt(projectRootPath.length - 1) != "/") { + projectRootPath += "/"; + } + if (fullPath.indexOf(projectRootPath) == 0) { + _currentTitlePath = fullPath.slice(projectRootPath.length); + if (_currentTitlePath.charAt(0) == '/') { + _currentTitlePath = _currentTitlePath.slice(1); + } + } + + // TODO: have a real controller object for the editor + _editor.setValue(event.target.result); + + // Make sure we can't undo back to the previous content. + _editor.clearHistory(); + + // This should be 0, but just to be safe... + _savedUndoPosition = _editor.historySize().undo; + updateDirty(); + + result.resolve(); + }; + + reader.onerror = function(event) { + // TODO: display meaningful error + result.reject(); + } + + reader.readAsText(file, "utf8"); + }, + function (error) { + // TODO: display meaningful error + result.reject(); + }); + } + return result; + } + + function handleFileSave() { + var result = new $.Deferred(); + if (_currentFilePath && _isDirty) { + // TODO: we should implement something like NativeFileSystem.resolveNativeFileSystemURL() (similar + // to what's in the standard file API) to get a FileEntry, rather than manually constructing it + var fileEntry = new NativeFileSystem.FileEntry(_currentFilePath); + + fileEntry.createWriter( + function(writer) { + writer.onwrite = function() { + _savedUndoPosition = _editor.historySize().undo; + updateDirty(); + result.resolve(); + } + writer.onerror = function() { + result.reject(); + } + writer.write(_editor.getValue()); + }, + function(error) { + // TODO: display meaningful error + result.reject(); + } + ); + } + else { + result.resolve(); + } + result.always(function() { + _editor.focus(); + }); + return result; + } + + function handleFileClose() { + var result = new $.Deferred(); + if (_currentFilePath && _isDirty) { + brackets.showModalDialog( + brackets.DIALOG_ID_SAVE_CLOSE + , brackets.strings.SAVE_CLOSE_TITLE + , brackets.strings.format(brackets.strings.SAVE_CLOSE_MESSAGE, _currentTitlePath) + ).done(function(id) { + if (id === brackets.DIALOG_BTN_CANCEL) { + result.reject(); + } + else { + if (id === brackets.DIALOG_BTN_OK) { + CommandManager + .execute(Commands.FILE_SAVE) + .done(function() { + doClose(); + result.resolve(); + }) + .fail(function() { + result.reject(); + }); + } + else { + // This is the "Don't Save" case--we can just go ahead and close the file. + doClose(); + result.resolve(); + } + } + }); + result.always(function() { + _editor.focus(); + }); + } + else { + doClose(); + _editor.focus(); + result.resolve(); + } + return result; + } + + function doClose() { + // TODO: When we implement multiple files being open, this will probably change to just + // dispose of the editor for the current file (and will later change again if we choose to + // limit the number of open editors). + _editor.setValue(""); + _editor.clearHistory(); + _currentFilePath = _currentTitlePath = null; + _savedUndoPosition = 0; + _isDirty = false; + updateTitle(); + _editor.focus(); + } + + return exports; +})(); + diff --git a/src/KeyBindingManager.js b/src/KeyBindingManager.js new file mode 100644 index 00000000000..fec89b639c5 --- /dev/null +++ b/src/KeyBindingManager.js @@ -0,0 +1,60 @@ +/* + * Copyright 2011 Adobe Systems Incorporated. All Rights Reserved. + */ + +/** + * Manages the mapping of keyboard inputs to commands. + */ +var KeyBindingManager = { + /** + * The currently installed keymap. + */ + _keymap: null, + + /** + * Install the specified keymap as the current keymap, overwriting the existing keymap. + * + * @param {KeyMap} keymap The keymap to install. + */ + installKeymap: function(keymap) { + this._keymap = keymap; + }, + + /** + * Process the keybinding for the current key. + * + * @param {string} A key-description string. + * @return {boolean} true if the key was processed, false otherwise + */ + handleKey: function(key) { + if (this._keymap && this._keymap.map[key]) { + CommandManager.execute(this._keymap.map[key]); + return true; + } + return false; + } +}; + +/** class Keymap + * + * A keymap specifies how keys are mapped to commands. This currently just holds the map, but in future + * it will likely be extended to include other metadata about the keymap. + * + * Keys are described by strings of the form "[modifier-modifier-...-]key", where modifier is one of + * Ctrl, Alt, or Shift. If multiple modifiers are specified, they must be specified in that order + * (i.e. "Ctrl-Alt-Shift-P" is legal, "Alt-Ctrl-Shift-P" is not). + * (TODO: the above restriction is to simplify mapping--is it too onerous?) + * -- Ctrl maps to Cmd on Mac. (This means that you can't specifically bind to the Ctrl key on Mac.) + * -- Alt maps to the Option key on Mac. + * -- Letters must be uppercase, but do not require Shift by default. To indicate that Shift must be held + * down, you must specifically include Shift. + * + * @constructor + * @param {map} map An object mapping key-description strings to command IDs. + */ +var KeyMap = function(map) { + if (map === undefined) { + throw new Error("All parameters to the KeyMap constructor must be specified"); + } + this.map = map; +}; diff --git a/src/NativeFileSystem.js b/src/NativeFileSystem.js index 801d49a1c83..55f56d2c43e 100644 --- a/src/NativeFileSystem.js +++ b/src/NativeFileSystem.js @@ -189,12 +189,13 @@ NativeFileSystem.FileEntry.prototype.createWriter = function( successCallback, e var self = this; brackets.fs.writeFile( fileEntry.fullPath, data, "utf8", function( err ) { - if ( self.onerror ) { - self.onerror ( NativeFileSystem._nativeToFileError( err ) ); - - // TODO (jasonsj): partial write, update length and position + if ( err ) { + if ( self.onerror ) { + self.onerror ( NativeFileSystem._nativeToFileError( err ) ); + } } else { + // TODO (jasonsj): partial write, update length and position // successful completion of a write self.position += data.size; } @@ -207,7 +208,7 @@ NativeFileSystem.FileEntry.prototype.createWriter = function( successCallback, e self.onwrite(); } - if ( this.onwriteend ) { + if ( self.onwriteend ) { // TODO (jasonsj): progressevent self.onwriteend(); } diff --git a/src/ProjectManager.js b/src/ProjectManager.js index f717a66797e..5d52364be8e 100644 --- a/src/ProjectManager.js +++ b/src/ProjectManager.js @@ -34,8 +34,9 @@ ProjectManager.openProject = function() { ProjectManager.loadProject( files[0] ); }, function(error) { - brackets.showErrorDialog( - brackets.strings.ERROR_LOADING_PROJECT + brackets.showModalDialog( + brackets.DIALOG_ID_ERROR + , brackets.strings.ERROR_LOADING_PROJECT , brackets.strings.format(brackets.strings.OPEN_DIALOG_ERROR, error.code) ); } @@ -89,8 +90,9 @@ ProjectManager.loadProject = function(rootPath) { ProjectManager._renderTree(ProjectManager._treeDataProvider); }, function(error) { - brackets.showErrorDialog( - brackets.strings.ERROR_LOADING_PROJECT + brackets.showModalDialog( + brackets.DIALOG_ID_ERROR + , brackets.strings.ERROR_LOADING_PROJECT , brackets.strings.format(brackets.strings.REQUEST_NATIVE_FILE_SYSTEM_ERROR, rootPath, error.code) ); } @@ -126,8 +128,9 @@ ProjectManager._treeDataProvider = function(treeNode, jsTreeCallback) { jsTreeCallback(subtreeJSON); }, function(error) { - brackets.showErrorDialog( - brackets.strings.ERROR_LOADING_PROJECT + brackets.showModalDialog( + brackets.DIALOG_ID_ERROR + , brackets.strings.ERROR_LOADING_PROJECT , brackets.strings.format(brackets.strings.READ_DIRECTORY_ENTRIES_ERROR, dirEntry.fullPath, error.code) ); } diff --git a/src/brackets.js b/src/brackets.js index 57dbf8a0432..254e73ce207 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -9,21 +9,35 @@ brackets = window.brackets || {}; brackets.inBrowser = !brackets.hasOwnProperty("fs"); +brackets.DIALOG_BTN_CANCEL = "cancel"; +brackets.DIALOG_BTN_OK = "ok"; +brackets.DIALOG_BTN_DONTSAVE = "dontsave"; + +brackets.DIALOG_ID_ERROR = "error-dialog"; +brackets.DIALOG_ID_SAVE_CLOSE = "save-close-dialog"; + /** - * General purpose modal error dialog. + * General purpose modal dialog. Assumes that the HTML for the dialog contains elements with "title" + * and "message" classes, as well as a number of elements with "dialog-button" class, each of which has + * a "data-button-id". * + * @param {string} id The ID of the dialog node in the HTML. * @param {string} title The title of the error dialog. Can contain HTML markup. * @param {string} message The message to display in the error dialog. Can contain HTML markup. + * @return {Deferred} a $.Deferred() that will be resolved with the ID of the clicked button when the dialog + * is dismissed. */ -brackets.showErrorDialog = function(title, message) { - var dlg = $("#error-dialog"); +brackets.showModalDialog = function(id, title, message, callback) { + var result = $.Deferred(); + var dlg = $("#" + id); // Set title and message - $("#error-dialog-title").html(title); - $("#error-dialog-message").html(message); + $(".dialog-title", dlg).html(title); + $(".dialog-message", dlg).html(message); - // Click handler for OK button - dlg.delegate("#error-dialog-ok", "click", function(e) { + // Click handler for buttons + dlg.on("click", ".dialog-button", function(e) { + result.resolve($(this).attr("data-button-id")); dlg.modal(true).hide(); }); @@ -33,149 +47,96 @@ brackets.showErrorDialog = function(title, message) { , show: true } ); -} + return result; +}; $(document).ready(function() { var editor = CodeMirror($('#editor').get(0)); - - // Load a default project into the tree - if (brackets.inBrowser) { - // In browser: dummy folder tree (hardcoded in ProjectManager) - ProjectManager.loadProject("DummyProject"); - } else { - // In app shell: load Brackets itself - var loadedPath = window.location.pathname; - var bracketsSrc = loadedPath.substr(0, loadedPath.lastIndexOf("/")); - ProjectManager.loadProject(bracketsSrc); - } - - // Open project button - $("#btn-open-project").click(function() { - ProjectManager.openProject(); - }); - // Implements the "Open File" menu - $("#menu-file-open").click(function() { - CommandManager.execute(Commands.FILE_OPEN); - }); + initProject(); + initMenus(); + initCommandHandlers(); + initKeyBindings(); - // Implements the 'Run Tests' menu to bring up the Jasmine unit test window - var testWindow = null; - $("#menu-runtests").click(function(){ - if (!(testWindow === null)) { - try { - testWindow.location.reload(); - } catch(e) { - testWindow = null; // the window was probably closed - } + function initProject() { + // Load a default project into the tree + if (brackets.inBrowser) { + // In browser: dummy folder tree (hardcoded in ProjectManager) + ProjectManager.loadProject("DummyProject"); + } else { + // In app shell: load Brackets itself + var loadedPath = window.location.pathname; + var bracketsSrc = loadedPath.substr(0, loadedPath.lastIndexOf("/")); + ProjectManager.loadProject(bracketsSrc); } - - if (testWindow === null) { - testWindow = window.open("../test/SpecRunner.html"); - testWindow.location.reload(); // if it was opened before, we need to reload because it will be cached - } - }); - // Ty test code hooked up to "new" menu. Test reads a file and prints its constents to the log. - // uncomment to test - /*$("#menu-file-new").click(function(){ - var fileEntry = new NativeFileSystem.FileEntry( "/Users/tvoliter/github/brackets-app/README.md" ); - var file; - fileEntry.file( function( file ){ - var reader = new NativeFileSystem.FileReader(); - reader.onerror = errorHandler; - - reader.onabort = function(e) { - alert('File read cancelled'); - }; - - reader.onloadstart = function(e) { - console.log( "loading" ); - }; - - reader.onload = function ( event ){ - console.log( event.target.result ); - }; - + // Open project button + $("#btn-open-project").click(function() { + ProjectManager.openProject(); + }); + } + + function initMenus() { + // Implements the File menu items + $("#menu-file-open").click(function() { + CommandManager.execute(Commands.FILE_OPEN); + }); + $("#menu-file-close").click(function() { + CommandManager.execute(Commands.FILE_CLOSE); + }); + $("#menu-file-save").click(function() { + CommandManager.execute(Commands.FILE_SAVE); + }); - // Read in the image file as a binary string. - reader.readAsText(file, "utf8"); - + // Implements the 'Run Tests' menu to bring up the Jasmine unit test window + var testWindow = null; + $("#menu-runtests").click(function(){ + if (!(testWindow === null)) { + try { + testWindow.location.reload(); + } catch(e) { + testWindow = null; // the window was probably closed + } + } - function errorHandler(evt) { - switch(evt.target.error.code) { - case evt.target.error.NOT_FOUND_ERR: - alert('File Not Found!'); - break; - case evt.target.error.NOT_READABLE_ERR: - alert('File is not readable'); - break; - case evt.target.error.ABORT_ERR: - break; // noop - default: - alert('An error occurred reading this file.'); - }; + if (testWindow === null) { + testWindow = window.open("../test/SpecRunner.html"); + testWindow.location.reload(); // if it was opened before, we need to reload because it will be cached } }); - });*/ - - // Utility functions - function doOpen(fullPath) { - if (fullPath) { - var reader = new NativeFileSystem.FileReader(); - - // TODO: we should implement something like NativeFileSystem.resolveNativeFileSystemURL() (similar - // to what's in the standard file API) to get a FileEntry, rather than manually constructing it - var fileEntry = new NativeFileSystem.FileEntry(fullPath); - - // TODO: it's weird to have to construct a FileEntry just to get a File. - fileEntry.file(function(file) { - reader.onload = function(event) { - // TODO: have a real controller object for the editor - editor.setValue(event.target.result); - editor.clearHistory(); - - // In the main toolbar, show the project-relative path (if the file is inside the current project) - // or the full absolute path (if it's not in the project). - var projectRootPath = ProjectManager.getProjectRoot().fullPath; - if (projectRootPath.length > 0 && projectRootPath.charAt(projectRootPath.length - 1) != "/") { - projectRootPath += "/"; - } - if (fullPath.indexOf(projectRootPath) == 0) { - fullPath = fullPath.slice(projectRootPath.length); - if (fullPath.charAt(0) == '/') { - fullPath = fullPath.slice(1); - } - } - $("#main-toolbar .title").text(fullPath); - }; - reader.onerror = function(event) { - // TODO: display meaningful error - } - - reader.readAsText(file, "utf8"); - }, - function (error) { - // TODO: display meaningful error - }); - } } - // Register global commands - CommandManager.register(Commands.FILE_OPEN, function(fullPath) { - if (!fullPath) { - // Prompt the user with a dialog - NativeFileSystem.showOpenDialog(false, false, "Open File", ProjectManager.getProjectRoot().fullPath, - ["htm", "html", "js", "css"], function(files) { - if (files.length > 0) { - doOpen(files[0]); - } - }); - } - else { - doOpen(fullPath); - } - }); - + function initCommandHandlers() { + FileCommandHandlers.init(editor, $("#main-toolbar .title")); + } + + function initKeyBindings() { + // Register keymaps and install the keyboard handler + // TODO: show keyboard equivalents in the menus + var _globalKeymap = new KeyMap( + { "Ctrl-O": Commands.FILE_OPEN + , "Ctrl-S": Commands.FILE_SAVE + , "Ctrl-W": Commands.FILE_CLOSE + } + ); + KeyBindingManager.installKeymap(_globalKeymap); + + $(document.body).keydown(function(event) { + var keyDescriptor = []; + if (event.metaKey || event.ctrlKey) { + keyDescriptor.push("Ctrl"); + } + if (event.altKey) { + keyDescriptor.push("Alt"); + } + if (event.shiftKey) { + keyDescriptor.push("Shift"); + } + keyDescriptor.push(String.fromCharCode(event.keyCode).toUpperCase()); + if (KeyBindingManager.handleKey(keyDescriptor.join("-"))) { + event.preventDefault(); + } + }); + } }); diff --git a/src/index.html b/src/index.html index 442fcd6858a..48e786a8652 100644 --- a/src/index.html +++ b/src/index.html @@ -23,6 +23,8 @@ + + @@ -41,6 +43,7 @@ @@ -85,13 +88,28 @@ + diff --git a/src/strings.js b/src/strings.js index a45d57ce935..102bf9c8117 100644 --- a/src/strings.js +++ b/src/strings.js @@ -33,6 +33,9 @@ if (!brackets.strings) // Project error strings s.ERROR_LOADING_PROJECT = "Error loading project"; s.OPEN_DIALOG_ERROR = "An error occurred when showing the open file dialog. (error {0})"; - s.REQUEST_NATIVE_FILE_SYSTEM_ERROR = "An error occurred when trying to load the directory '{0}'. (error {1})"; - s.READ_DIRECTORY_ENTRIES_ERROR = "An error occurred when reading the contents of the directory '{0}'. (error {1})"; + s.REQUEST_NATIVE_FILE_SYSTEM_ERROR = "An error occurred when trying to load the directory \"{0}\". (error {1})"; + s.READ_DIRECTORY_ENTRIES_ERROR = "An error occurred when reading the contents of the directory \"{0}\". (error {1})"; + + s.SAVE_CLOSE_TITLE = "Save Changes"; + s.SAVE_CLOSE_MESSAGE = "Do you want to save the changes you made in the document \"{0}\"?"; })(); \ No newline at end of file diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 6ec96e6b059..d835b912e4b 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -73,6 +73,10 @@ .box-shadow(0 1px 3px 0 rgba(0, 0, 0, 0.3)); } +.modal-footer .btn.left { + float: left; +} + /* Overall layout */ html, body {