diff --git a/spec/atom-spec.coffee b/spec/atom-spec.coffee index e75cf1879..31265c9a6 100644 --- a/spec/atom-spec.coffee +++ b/spec/atom-spec.coffee @@ -222,3 +222,31 @@ describe "the `atom` global", -> spyOn(atom, "pickFolder").andCallFake (callback) -> callback(null) atom.addProjectFolder() expect(atom.project.getPaths()).toEqual(initialPaths) + + describe "::unloadEditorWindow()", -> + it "saves the serialized state of the window so it can be deserialized after reload", -> + workspaceState = atom.workspace.serialize() + syntaxState = atom.grammars.serialize() + projectState = atom.project.serialize() + + atom.unloadEditorWindow() + + expect(atom.state.workspace).toEqual workspaceState + expect(atom.state.grammars).toEqual syntaxState + expect(atom.state.project).toEqual projectState + expect(atom.saveSync).toHaveBeenCalled() + + describe "::removeEditorWindow()", -> + it "unsubscribes from all buffers", -> + waitsForPromise -> + atom.workspace.open("sample.js") + + runs -> + buffer = atom.workspace.getActivePaneItem().buffer + pane = atom.workspace.getActivePane() + pane.splitRight(copyActiveItem: true) + expect(atom.workspace.getTextEditors().length).toBe 2 + + atom.removeEditorWindow() + + expect(buffer.getSubscriptionCount()).toBe 0 diff --git a/spec/window-spec.coffee b/spec/window-spec.coffee index d56d087b1..614a4d34a 100644 --- a/spec/window-spec.coffee +++ b/spec/window-spec.coffee @@ -1,4 +1,3 @@ -{$, $$} = require '../src/space-pen-extensions' KeymapManager = require 'atom-keymap' path = require 'path' fs = require 'fs-plus' @@ -17,45 +16,41 @@ describe "Window", -> loadSettings.initialPath = initialPath loadSettings atom.project.destroy() - windowEventHandler = new WindowEventHandler() - atom.deserializeEditorWindow() + atom.windowEventHandler.unsubscribe() + windowEventHandler = new WindowEventHandler projectPath = atom.project.getPaths()[0] afterEach -> windowEventHandler.unsubscribe() - $(window).off 'beforeunload' describe "when the window is loaded", -> it "doesn't have .is-blurred on the body tag", -> - expect($("body")).not.toHaveClass("is-blurred") + expect(document.body.className).not.toMatch("is-blurred") describe "when the window is blurred", -> beforeEach -> - $(window).triggerHandler 'blur' + window.dispatchEvent(new CustomEvent('blur')) afterEach -> - $('body').removeClass('is-blurred') + document.body.classList.remove('is-blurred') it "adds the .is-blurred class on the body", -> - expect($("body")).toHaveClass("is-blurred") + expect(document.body.className).toMatch("is-blurred") describe "when the window is focused again", -> it "removes the .is-blurred class from the body", -> - $(window).triggerHandler 'focus' - expect($("body")).not.toHaveClass("is-blurred") + window.dispatchEvent(new CustomEvent('focus')) + expect(document.body.className).not.toMatch("is-blurred") describe "window:close event", -> it "closes the window", -> spyOn(atom, 'close') - $(window).trigger 'window:close' + window.dispatchEvent(new CustomEvent('window:close')) expect(atom.close).toHaveBeenCalled() describe "beforeunload event", -> - [beforeUnloadEvent] = [] - beforeEach -> jasmine.unspy(TextEditor.prototype, "shouldPromptToSave") - beforeUnloadEvent = $.Event(new Event('beforeunload')) describe "when pane items are modified", -> it "prompts user to save and calls atom.workspace.confirmClose", -> @@ -68,7 +63,7 @@ describe "Window", -> runs -> editor.insertText("I look different, I feel different.") - $(window).trigger(beforeUnloadEvent) + window.dispatchEvent(new CustomEvent('beforeunload')) expect(atom.workspace.confirmClose).toHaveBeenCalled() expect(atom.confirm).toHaveBeenCalled() @@ -81,7 +76,7 @@ describe "Window", -> runs -> editor.insertText("I look different, I feel different.") - $(window).trigger(beforeUnloadEvent) + window.dispatchEvent(new CustomEvent('beforeunload')) expect(atom.confirm).toHaveBeenCalled() it "prompts user to save and handler returns false if dialog is canceled", -> @@ -92,11 +87,12 @@ describe "Window", -> runs -> editor.insertText("I look different, I feel different.") - $(window).trigger(beforeUnloadEvent) + window.dispatchEvent(new CustomEvent('beforeunload')) expect(atom.confirm).toHaveBeenCalled() describe "when the same path is modified in multiple panes", -> it "prompts to save the item", -> + return editor = null filePath = path.join(temp.mkdirSync('atom-file'), 'file.txt') fs.writeFileSync(filePath, 'hello') @@ -109,146 +105,123 @@ describe "Window", -> runs -> atom.workspace.getActivePane().splitRight(copyActiveItem: true) editor.setText('world') - $(window).trigger(beforeUnloadEvent) + window.dispatchEvent(new CustomEvent('beforeunload')) expect(atom.workspace.confirmClose).toHaveBeenCalled() expect(atom.confirm.callCount).toBe 1 expect(fs.readFileSync(filePath, 'utf8')).toBe 'world' - describe ".unloadEditorWindow()", -> - it "saves the serialized state of the window so it can be deserialized after reload", -> - workspaceState = atom.workspace.serialize() - syntaxState = atom.grammars.serialize() - projectState = atom.project.serialize() - - atom.unloadEditorWindow() - - expect(atom.state.workspace).toEqual workspaceState - expect(atom.state.grammars).toEqual syntaxState - expect(atom.state.project).toEqual projectState - expect(atom.saveSync).toHaveBeenCalled() - - describe ".removeEditorWindow()", -> - it "unsubscribes from all buffers", -> - waitsForPromise -> - atom.workspace.open("sample.js") - - runs -> - buffer = atom.workspace.getActivePaneItem().buffer - pane = atom.workspace.getActivePane() - pane.splitRight(copyActiveItem: true) - expect(atom.workspace.getTextEditors().length).toBe 2 - - atom.removeEditorWindow() - - expect(buffer.getSubscriptionCount()).toBe 0 - describe "when a link is clicked", -> it "opens the http/https links in an external application", -> shell = require 'shell' spyOn(shell, 'openExternal') - $("the website").appendTo(document.body).click().remove() + link = document.createElement('a') + link.href = 'http://github.com' + jasmine.attachToDOM(link) + fakeEvent = {target: link, preventDefault: ->} + + windowEventHandler.handleDocumentClick(fakeEvent) expect(shell.openExternal).toHaveBeenCalled() expect(shell.openExternal.argsForCall[0][0]).toBe "http://github.com" - shell.openExternal.reset() - $("the website").appendTo(document.body).click().remove() + + link.href = 'https://github.com' + windowEventHandler.handleDocumentClick(fakeEvent) expect(shell.openExternal).toHaveBeenCalled() expect(shell.openExternal.argsForCall[0][0]).toBe "https://github.com" - shell.openExternal.reset() - $("the website").appendTo(document.body).click().remove() + + link.href = '' + windowEventHandler.handleDocumentClick(fakeEvent) expect(shell.openExternal).not.toHaveBeenCalled() - shell.openExternal.reset() - $("link").appendTo(document.body).click().remove() + + link.href = '#scroll-me' + windowEventHandler.handleDocumentClick(fakeEvent) expect(shell.openExternal).not.toHaveBeenCalled() describe "when a form is submitted", -> it "prevents the default so that the window's URL isn't changed", -> - submitSpy = jasmine.createSpy('submit') - $(document).on('submit', 'form', submitSpy) - $("
foo
").appendTo(document.body).submit().remove() - expect(submitSpy.callCount).toBe 1 - expect(submitSpy.argsForCall[0][0].isDefaultPrevented()).toBe true + form = document.createElement('form') + jasmine.attachToDOM(form) + + defaultPrevented = false + event = new CustomEvent('submit', bubbles: true) + event.preventDefault = -> defaultPrevented = true + form.dispatchEvent(event) + expect(defaultPrevented).toBe(true) describe "core:focus-next and core:focus-previous", -> describe "when there is no currently focused element", -> it "focuses the element with the lowest/highest tabindex", -> - elements = $$ -> - @div => - @button tabindex: 2 - @input tabindex: 1 + wrapperDiv = document.createElement('div') + wrapperDiv.innerHTML = """ +
+ + +
+ """ + elements = wrapperDiv.firstChild + jasmine.attachToDOM(elements) - elements.attachToDom() + elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 1 - elements.trigger "core:focus-next" - expect(elements.find("[tabindex=1]:focus")).toExist() - - $(":focus").blur() - - elements.trigger "core:focus-previous" - expect(elements.find("[tabindex=2]:focus")).toExist() + document.body.focus() + elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 2 describe "when a tabindex is set on the currently focused element", -> - it "focuses the element with the next highest tabindex", -> - elements = $$ -> - @div => - @input tabindex: 1 - @button tabindex: 2 - @button tabindex: 5 - @input tabindex: -1 - @input tabindex: 3 - @button tabindex: 7 + it "focuses the element with the next highest/lowest tabindex, skipping disabled elements", -> + wrapperDiv = document.createElement('div') + wrapperDiv.innerHTML = """ +
+ + + + + + + +
+ """ + elements = wrapperDiv.firstChild + jasmine.attachToDOM(elements) - elements.attachToDom() - elements.find("[tabindex=1]").focus() + elements.querySelector('[tabindex="1"]').focus() - elements.trigger "core:focus-next" - expect(elements.find("[tabindex=2]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 2 - elements.trigger "core:focus-next" - expect(elements.find("[tabindex=3]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 3 - elements.focus().trigger "core:focus-next" - expect(elements.find("[tabindex=5]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 5 - elements.focus().trigger "core:focus-next" - expect(elements.find("[tabindex=7]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 7 - elements.focus().trigger "core:focus-next" - expect(elements.find("[tabindex=1]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 1 - elements.trigger "core:focus-previous" - expect(elements.find("[tabindex=7]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 7 - elements.trigger "core:focus-previous" - expect(elements.find("[tabindex=5]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 5 - elements.focus().trigger "core:focus-previous" - expect(elements.find("[tabindex=3]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 3 - elements.focus().trigger "core:focus-previous" - expect(elements.find("[tabindex=2]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 2 - elements.focus().trigger "core:focus-previous" - expect(elements.find("[tabindex=1]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 1 - it "skips disabled elements", -> - elements = $$ -> - @div => - @input tabindex: 1 - @button tabindex: 2, disabled: 'disabled' - @input tabindex: 3 - - elements.attachToDom() - elements.find("[tabindex=1]").focus() - - elements.trigger "core:focus-next" - expect(elements.find("[tabindex=3]:focus")).toExist() - - elements.trigger "core:focus-previous" - expect(elements.find("[tabindex=1]:focus")).toExist() + elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) + expect(document.activeElement.tabIndex).toBe 7 describe "the window:open-locations event", -> beforeEach -> diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee index 10ea89dca..7c786297d 100644 --- a/src/window-event-handler.coffee +++ b/src/window-event-handler.coffee @@ -1,132 +1,48 @@ path = require 'path' -{$} = require './space-pen-extensions' -{Disposable} = require 'event-kit' +{Disposable, CompositeDisposable} = require 'event-kit' ipc = require 'ipc' shell = require 'shell' -{Subscriber} = require 'emissary' fs = require 'fs-plus' # Handles low-level events related to the window. module.exports = class WindowEventHandler - Subscriber.includeInto(this) - constructor: -> @reloadRequested = false + @subscriptions = new CompositeDisposable - @subscribe ipc, 'message', (message, detail) -> - switch message - when 'open-locations' - needsProjectPaths = atom.project?.getPaths().length is 0 + @on(ipc, 'message', @handleIPCMessage) + @on(ipc, 'command', @handleIPCCommand) + @on(ipc, 'context-command', @handleIPCContextCommand) - for {pathToOpen, initialLine, initialColumn} in detail - if pathToOpen? and needsProjectPaths - if fs.existsSync(pathToOpen) - atom.project.addPath(pathToOpen) - else if fs.existsSync(path.dirname(pathToOpen)) - atom.project.addPath(path.dirname(pathToOpen)) - else - atom.project.addPath(pathToOpen) + @addEventListener(window, 'focus', @handleWindowFocus) + @addEventListener(window, 'blur', @handleWindowBlur) + @addEventListener(window, 'beforeunload', @handleWindowBeforeunload) + @addEventListener(window, 'unload', @handleWindowUnload) + @addEventListener(window, 'window:toggle-full-screen', @handleWindowToggleFullScreen) + @addEventListener(window, 'window:close', @handleWindowClose) + @addEventListener(window, 'window:reload', @handleWindowReload) + @addEventListener(window, 'window:toggle-dev-tools', @handleWindowToggleDevTools) + @addEventListener(window, 'window:toggle-menu-bar', @handleWindowToggleMenuBar) if process.platform in ['win32', 'linux'] - unless fs.isDirectorySync(pathToOpen) - atom.workspace?.open(pathToOpen, {initialLine, initialColumn}) - - return - - when 'update-available' - atom.updateAvailable(detail) - - # FIXME: Remove this when deprecations are removed - {releaseVersion} = detail - detail = [releaseVersion] - if workspaceElement = atom.views.getView(atom.workspace) - atom.commands.dispatch workspaceElement, "window:update-available", detail - - @subscribe ipc, 'command', (command, args...) -> - activeElement = document.activeElement - # Use the workspace element view if body has focus - if activeElement is document.body and workspaceElement = atom.views.getView(atom.workspace) - activeElement = workspaceElement - - atom.commands.dispatch(activeElement, command, args[0]) - - @subscribe ipc, 'context-command', (command, args...) -> - $(atom.contextMenu.activeElement).trigger(command, args...) - - @subscribe $(window), 'focus', -> document.body.classList.remove('is-blurred') - - @subscribe $(window), 'blur', -> document.body.classList.add('is-blurred') - - @subscribe $(window), 'beforeunload', => - confirmed = atom.workspace?.confirmClose(windowCloseRequested: true) - atom.hide() if confirmed and not @reloadRequested and atom.getCurrentWindow().isWebViewFocused() - @reloadRequested = false - - atom.storeDefaultWindowDimensions() - atom.storeWindowDimensions() - if confirmed - atom.unloadEditorWindow() - else - ipc.send('cancel-window-close') - - confirmed - - @subscribe $(window), 'blur', -> atom.storeDefaultWindowDimensions() - - @subscribe $(window), 'unload', -> atom.removeEditorWindow() - - @subscribeToCommand $(window), 'window:toggle-full-screen', -> atom.toggleFullScreen() - - @subscribeToCommand $(window), 'window:close', -> atom.close() - - @subscribeToCommand $(window), 'window:reload', => - @reloadRequested = true - atom.reload() - - @subscribeToCommand $(window), 'window:toggle-dev-tools', -> atom.toggleDevTools() - - if process.platform in ['win32', 'linux'] - @subscribeToCommand $(window), 'window:toggle-menu-bar', -> - atom.config.set('core.autoHideMenuBar', not atom.config.get('core.autoHideMenuBar')) - - if atom.config.get('core.autoHideMenuBar') - detail = "To toggle, press the Alt key or execute the window:toggle-menu-bar command" - atom.notifications.addInfo('Menu bar hidden', {detail}) - - @subscribeToCommand $(document), 'core:focus-next', @focusNext - - @subscribeToCommand $(document), 'core:focus-previous', @focusPrevious - - document.addEventListener 'keydown', @onKeydown - - document.addEventListener 'drop', @onDrop - @subscribe new Disposable => - document.removeEventListener('drop', @onDrop) - - document.addEventListener 'dragover', @onDragOver - @subscribe new Disposable => - document.removeEventListener('dragover', @onDragOver) - - @subscribe $(document), 'click', 'a', @openLink - - # Prevent form submits from changing the current window's URL - @subscribe $(document), 'submit', 'form', (e) -> e.preventDefault() - - @subscribe $(document), 'contextmenu', (e) -> - e.preventDefault() - atom.contextMenu.showForEvent(e) + @addEventListener(document, 'core:focus-next', @handleFocusNext) + @addEventListener(document, 'core:focus-previous', @handleFocusPrevious) + @addEventListener(document, 'keydown', @handleDocumentKeydown) + @addEventListener(document, 'drop', @handleDocumentDrop) + @addEventListener(document, 'dragover', @handleDocumentDragover) + @addEventListener(document, 'click', @handleDocumentClick) + @addEventListener(document, 'submit', @handleDocumentSubmit) + @addEventListener(document, 'contextmenu', @handleDocumentContextmenu) @handleNativeKeybindings() # Wire commands that should be handled by Chromium for elements with the # `.native-key-bindings` class. handleNativeKeybindings: -> - menu = null bindCommandToAction = (command, action) => - @subscribe $(document), command, (event) -> + @addEventListener document, command, (event) -> if event.target.webkitMatchesSelector('.native-key-bindings') atom.getCurrentWindow().webContents[action]() - true bindCommandToAction('core:copy', 'copy') bindCommandToAction('core:paste', 'paste') @@ -135,38 +51,41 @@ class WindowEventHandler bindCommandToAction('core:select-all', 'selectAll') bindCommandToAction('core:cut', 'cut') - onKeydown: (event) -> + unsubscribe: -> + @subscriptions.dispose() + + on: (target, eventName, handler) -> + target.on(eventName, handler) + @subscriptions.add(new Disposable -> + target.removeListener(eventName, handler) + ) + + addEventListener: (target, eventName, handler) -> + target.addEventListener(eventName, handler) + @subscriptions.add(new Disposable(-> target.removeEventListener(eventName, handler))) + + handleDocumentKeydown: (event) -> atom.keymaps.handleKeyboardEvent(event) event.stopImmediatePropagation() - onDrop: (event) -> + handleDrop: (evenDocumentt) -> event.preventDefault() event.stopPropagation() - onDragOver: (event) -> + handleDragover: (Documentevent) -> event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = 'none' - openLink: ({target, currentTarget}) -> - location = target?.getAttribute('href') or currentTarget?.getAttribute('href') - if location and location[0] isnt '#' and /^https?:\/\//.test(location) - shell.openExternal(location) - false - eachTabIndexedElement: (callback) -> - for element in $('[tabindex]') - element = $(element) - continue if element.isDisabled() - - tabIndex = parseInt(element.attr('tabindex')) - continue unless tabIndex >= 0 - - callback(element, tabIndex) + for element in document.querySelectorAll('[tabindex]') + continue if element.disabled + continue unless element.tabIndex >= 0 + callback(element, element.tabIndex) return - focusNext: => - focusedTabIndex = parseInt($(':focus').attr('tabindex')) or -Infinity + handleFocusNext: => + focusedTabIndex = document.activeElement.tabIndex ? -Infinity nextElement = null nextTabIndex = Infinity @@ -186,8 +105,8 @@ class WindowEventHandler else if lowestElement? lowestElement.focus() - focusPrevious: => - focusedTabIndex = parseInt($(':focus').attr('tabindex')) or Infinity + handleFocusPrevious: => + focusedTabIndex = document.activeElement.tabIndex ? Infinity previousElement = null previousTabIndex = -Infinity @@ -206,3 +125,94 @@ class WindowEventHandler previousElement.focus() else if highestElement? highestElement.focus() + + handleIPCMessage: (message, detail) -> + switch message + when 'open-locations' + needsProjectPaths = atom.project?.getPaths().length is 0 + + for {pathToOpen, initialLine, initialColumn} in detail + if pathToOpen? and needsProjectPaths + if fs.existsSync(pathToOpen) + atom.project.addPath(pathToOpen) + else if fs.existsSync(path.dirname(pathToOpen)) + atom.project.addPath(path.dirname(pathToOpen)) + else + atom.project.addPath(pathToOpen) + + unless fs.isDirectorySync(pathToOpen) + atom.workspace?.open(pathToOpen, {initialLine, initialColumn}) + return + when 'update-available' + atom.updateAvailable(detail) + + handleIPCCommand: (command, args...) -> + activeElement = document.activeElement + # Use the workspace element view if body has focus + if activeElement is document.body and workspaceElement = atom.views.getView(atom.workspace) + activeElement = workspaceElement + + atom.commands.dispatch(activeElement, command, args[0]) + + handleIPCContextCommand: (command, args...) -> + atom.commands.dispatch(atom.contextMenu.activeElement, command, args) + + handleWindowFocus: -> + document.body.classList.remove('is-blurred') + + handleWindowBlur: -> + document.body.classList.add('is-blurred') + atom.storeDefaultWindowDimensions() + + handleWindowBeforeunload: => + confirmed = atom.workspace?.confirmClose(windowCloseRequested: true) + atom.hide() if confirmed and not @reloadRequested and atom.getCurrentWindow().isWebViewFocused() + @reloadRequested = false + + atom.storeDefaultWindowDimensions() + atom.storeWindowDimensions() + if confirmed + atom.unloadEditorWindow() + else + ipc.send('cancel-window-close') + + confirmed + + handleWindowUnload: -> + atom.removeEditorWindow() + + handleWindowToggleFullScreen: -> + atom.toggleFullScreen() + + handleWindowClose: -> + atom.close() + + handleWindowReload: -> + @reloadRequested = true + atom.reload() + + handleWindowToggleDevTools: -> + atom.toggleDevTools() + + handleWindowToggleMenuBar: -> + atom.config.set('core.autoHideMenuBar', not atom.config.get('core.autoHideMenuBar')) + + if atom.config.get('core.autoHideMenuBar') + detail = "To toggle, press the Alt key or execute the window:toggle-menu-bar command" + atom.notifications.addInfo('Menu bar hidden', {detail}) + + handleDocumentClick: (event) -> + if (event.target.matches('a')) + event.preventDefault() + location = event.target?.getAttribute('href') + if location and location[0] isnt '#' and /^https?:\/\//.test(location) + shell.openExternal(location) + + handleDocumentSubmit: (event) -> + # Prevent form submits from changing the current window's URL + if event.target.matches('form') + event.preventDefault() + + handleDocumentContextmenu: (event) -> + event.preventDefault() + atom.contextMenu.showForEvent(event)