diff --git a/spec/app/editor-spec.coffee b/spec/app/editor-spec.coffee index a9f020799..eed7563b5 100644 --- a/spec/app/editor-spec.coffee +++ b/spec/app/editor-spec.coffee @@ -2588,3 +2588,79 @@ describe "Editor", -> expect(buffer.lineForRow(14)).toBe '' expect(buffer.lineForRow(15)).toBeUndefined() expect(editor.getCursorBufferPosition()).toEqual [14, 0] + + describe ".moveEditSessionToIndex(fromIndex, toIndex)", -> + describe "when the edit session moves to a later index", -> + it "updates the edit session order", -> + jsPath = editor.getPath() + rootView.open("sample.txt") + txtPath = editor.getPath() + expect(editor.editSessions[0].getPath()).toBe jsPath + expect(editor.editSessions[1].getPath()).toBe txtPath + editor.moveEditSessionToIndex(0, 1) + expect(editor.editSessions[0].getPath()).toBe txtPath + expect(editor.editSessions[1].getPath()).toBe jsPath + + it "fires an editor:edit-session-order-changed event", -> + eventHandler = jasmine.createSpy("eventHandler") + rootView.open("sample.txt") + editor.on "editor:edit-session-order-changed", eventHandler + editor.moveEditSessionToIndex(0, 1) + expect(eventHandler).toHaveBeenCalled() + + it "sets the moved session as the editor's active session", -> + jsPath = editor.getPath() + rootView.open("sample.txt") + txtPath = editor.getPath() + expect(editor.activeEditSession.getPath()).toBe txtPath + editor.moveEditSessionToIndex(0, 1) + expect(editor.activeEditSession.getPath()).toBe jsPath + + describe "when the edit session moves to an earlier index", -> + it "updates the edit session order", -> + jsPath = editor.getPath() + rootView.open("sample.txt") + txtPath = editor.getPath() + expect(editor.editSessions[0].getPath()).toBe jsPath + expect(editor.editSessions[1].getPath()).toBe txtPath + editor.moveEditSessionToIndex(1, 0) + expect(editor.editSessions[0].getPath()).toBe txtPath + expect(editor.editSessions[1].getPath()).toBe jsPath + + it "fires an editor:edit-session-order-changed event", -> + eventHandler = jasmine.createSpy("eventHandler") + rootView.open("sample.txt") + editor.on "editor:edit-session-order-changed", eventHandler + editor.moveEditSessionToIndex(1, 0) + expect(eventHandler).toHaveBeenCalled() + + it "sets the moved session as the editor's active session", -> + jsPath = editor.getPath() + rootView.open("sample.txt") + txtPath = editor.getPath() + expect(editor.activeEditSession.getPath()).toBe txtPath + editor.moveEditSessionToIndex(1, 0) + expect(editor.activeEditSession.getPath()).toBe txtPath + + describe ".moveEditSessionToEditor(fromIndex, toEditor, toIndex)", -> + it "closes the edit session in the source editor", -> + jsPath = editor.getPath() + rootView.open("sample.txt") + txtPath = editor.getPath() + rightEditor = editor.splitRight() + expect(editor.editSessions[0].getPath()).toBe jsPath + expect(editor.editSessions[1].getPath()).toBe txtPath + editor.moveEditSessionToEditor(0, rightEditor, 1) + expect(editor.editSessions[0].getPath()).toBe txtPath + expect(editor.editSessions[1]).toBeUndefined() + + it "opens the edit session in the destination editor at the target index", -> + jsPath = editor.getPath() + rootView.open("sample.txt") + txtPath = editor.getPath() + rightEditor = editor.splitRight() + expect(rightEditor.editSessions[0].getPath()).toBe txtPath + expect(rightEditor.editSessions[1]).toBeUndefined() + editor.moveEditSessionToEditor(0, rightEditor, 0) + expect(rightEditor.editSessions[0].getPath()).toBe jsPath + expect(rightEditor.editSessions[1].getPath()).toBe txtPath diff --git a/spec/app/keymap-spec.coffee b/spec/app/keymap-spec.coffee index 01ef31019..22c56c337 100644 --- a/spec/app/keymap-spec.coffee +++ b/spec/app/keymap-spec.coffee @@ -1,5 +1,6 @@ Keymap = require 'keymap' $ = require 'jquery' +RootView = require 'root-view' describe "Keymap", -> fragment = null @@ -23,8 +24,8 @@ describe "Keymap", -> keymap.bindKeys '.command-mode', 'x': 'deleteChar' keymap.bindKeys '.insert-mode', 'x': 'insertChar' - deleteCharHandler = jasmine.createSpy 'deleteCharHandler' - insertCharHandler = jasmine.createSpy 'insertCharHandler' + deleteCharHandler = jasmine.createSpy('deleteCharHandler') + insertCharHandler = jasmine.createSpy('insertCharHandler') fragment.on 'deleteChar', deleteCharHandler fragment.on 'insertChar', insertCharHandler @@ -149,6 +150,19 @@ describe "Keymap", -> keymap.handleKeyEvent(keydownEvent('y', target: target)) expect(bazHandler).toHaveBeenCalled() + describe "when the event's target is the document body", -> + it "triggers the mapped event on the rootView", -> + rootView = new RootView + keymap.bindKeys 'body', 'x': 'foo' + fooHandler = jasmine.createSpy("fooHandler") + rootView.on 'foo', fooHandler + + result = keymap.handleKeyEvent(keydownEvent('x', target: document.body)) + expect(result).toBe(false) + expect(fooHandler).toHaveBeenCalled() + expect(deleteCharHandler).not.toHaveBeenCalled() + expect(insertCharHandler).not.toHaveBeenCalled() + describe "when at least one binding partially matches the event's keystroke", -> [quitHandler, closeOtherWindowsHandler] = [] diff --git a/spec/app/root-view-spec.coffee b/spec/app/root-view-spec.coffee index cc3516b9d..3e4945118 100644 --- a/spec/app/root-view-spec.coffee +++ b/spec/app/root-view-spec.coffee @@ -190,11 +190,11 @@ describe "RootView", -> expect(rootView.find('#two')).not.toMatchSelector(':focus') describe "when there are no visible focusable elements", -> - it "retains focus itself", -> + it "surrenders focus to the body", -> rootView.remove() rootView = new RootView(require.resolve 'fixtures') rootView.attachToDom() - expect(rootView).toMatchSelector(':focus') + expect(document.activeElement).toBe $('body')[0] describe "panes", -> [pane1, newPaneContent] = [] diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index fb7c36cc2..5a5d39136 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -102,9 +102,8 @@ window.keyIdentifierForKey = (key) -> "U+00" + charCode.toString(16) window.keydownEvent = (key, properties={}) -> - event = $.Event "keydown", _.extend({originalEvent: { keyIdentifier: keyIdentifierForKey(key) }}, properties) - # event.keystroke = (new Keymap).keystrokeStringForEvent(event) - event + properties = $.extend({originalEvent: { keyIdentifier: keyIdentifierForKey(key) }}, properties) + $.Event("keydown", properties) window.mouseEvent = (type, properties) -> if properties.point diff --git a/src/app/editor.coffee b/src/app/editor.coffee index 36634b647..80ed42943 100644 --- a/src/app/editor.coffee +++ b/src/app/editor.coffee @@ -549,6 +549,20 @@ class Editor extends View "Cancel" ) + moveEditSessionToIndex: (fromIndex, toIndex) -> + return if fromIndex is toIndex + editSession = @editSessions.splice(fromIndex, 1) + @editSessions.splice(toIndex, 0, editSession[0]) + @trigger 'editor:edit-session-order-changed', [editSession, fromIndex, toIndex] + @setActiveEditSessionIndex(toIndex) + + moveEditSessionToEditor: (fromIndex, toEditor, toIndex) -> + fromEditSession = @editSessions[fromIndex] + toEditSession = fromEditSession.copy() + @destroyEditSessionIndex(fromIndex) + toEditor.edit(toEditSession) + toEditor.moveEditSessionToIndex(toEditor.getActiveEditSessionIndex(), toIndex) + activateEditSessionForPath: (path) -> for editSession, index in @editSessions if editSession.buffer.getPath() == path diff --git a/src/app/keymap.coffee b/src/app/keymap.coffee index 97cf4303b..dd21eac85 100644 --- a/src/app/keymap.coffee +++ b/src/app/keymap.coffee @@ -73,6 +73,7 @@ class Keymap return true unless bindingSetsForFirstKeystroke? currentNode = $(event.target) + currentNode = rootView if currentNode is $('body')[0] while currentNode.length candidateBindingSets = @bindingSetsForNode(currentNode, bindingSetsForFirstKeystroke) for bindingSet in candidateBindingSets @@ -99,6 +100,7 @@ class Keymap b.specificity - a.specificity triggerCommandEvent: (keyEvent, commandName) -> + keyEvent.target = rootView[0] if keyEvent.target == document.body and window.rootView commandEvent = $.Event(commandName) commandEvent.keyEvent = keyEvent aborted = false diff --git a/src/app/root-view.coffee b/src/app/root-view.coffee index c5dee555a..c7533f46e 100644 --- a/src/app/root-view.coffee +++ b/src/app/root-view.coffee @@ -18,7 +18,7 @@ class RootView extends View disabledPackages: [] @content: -> - @div id: 'root-view', tabindex: 0, => + @div id: 'root-view', => @div id: 'horizontal', outlet: 'horizontal', => @div id: 'vertical', outlet: 'vertical', => @div id: 'panes', outlet: 'panes' @@ -261,3 +261,11 @@ class RootView extends View eachBuffer: (callback) -> @project.eachBuffer(callback) + + indexOfPane: (pane) -> + index = -1 + for p, idx in @panes.find('.pane') + if pane.is(p) + index = idx + break + index diff --git a/src/app/sortable-list.coffee b/src/app/sortable-list.coffee new file mode 100644 index 000000000..a1c634d1e --- /dev/null +++ b/src/app/sortable-list.coffee @@ -0,0 +1,53 @@ +{View} = require 'space-pen' +$ = require 'jquery' + +module.exports = +class SortableList extends View + @viewClass: -> 'sortable-list' + + initialize: -> + @on 'dragstart', '.sortable', @onDragStart + @on 'dragend', '.sortable', @onDragEnd + @on 'dragover', '.sortable', @onDragOver + @on 'dragenter', '.sortable', @onDragEnter + @on 'dragleave', '.sortable', @onDragLeave + @on 'drop', '.sortable', @onDrop + + onDragStart: (event) => + return false if !@shouldAllowDrag(event) + + el = @getSortableElement(event) + el.addClass 'is-dragging' + event.originalEvent.dataTransfer.setData 'sortable-index', el.index() + + onDragEnd: (event) => + @getSortableElement(event).removeClass 'is-dragging' + + onDragEnter: (event) => + event.preventDefault() + + onDragOver: (event) => + event.preventDefault() + @getSortableElement(event).addClass 'is-drop-target' + + onDragLeave: (event) => + @getSortableElement(event).removeClass 'is-drop-target' + + onDrop: (event) => + return false if !@shouldAllowDrop(event) + event.stopPropagation() + @find('.is-drop-target').removeClass 'is-drop-target' + + shouldAllowDrag: (event) -> + true + + shouldAllowDrop: (event) -> + true + + getDroppedElement: (event) -> + idx = event.originalEvent.dataTransfer.getData 'sortable-index' + @find ".sortable:eq(#{idx})" + + getSortableElement: (event) -> + el = $(event.target) + if !el.hasClass('sortable') then el.closest('.sortable') else el \ No newline at end of file diff --git a/src/packages/tabs/lib/tab-view.coffee b/src/packages/tabs/lib/tab-view.coffee new file mode 100644 index 000000000..34e76496f --- /dev/null +++ b/src/packages/tabs/lib/tab-view.coffee @@ -0,0 +1,97 @@ +$ = require 'jquery' +SortableList = require 'sortable-list' +Tab = require './tab' + +module.exports = +class TabView extends SortableList + @activate: -> + rootView.eachEditor (editor) => + @prependToEditorPane(editor) if editor.attached + + @prependToEditorPane: (editor) -> + if pane = editor.pane() + pane.prepend(new TabView(editor)) + + @content: -> + @ul class: "tabs #{@viewClass()}" + + initialize: (@editor) -> + super + + @addTabForEditSession(editSession) for editSession in @editor.editSessions + + @setActiveTab(@editor.getActiveEditSessionIndex()) + @editor.on 'editor:active-edit-session-changed', (e, editSession, index) => @setActiveTab(index) + @editor.on 'editor:edit-session-added', (e, editSession) => @addTabForEditSession(editSession) + @editor.on 'editor:edit-session-removed', (e, editSession, index) => @removeTabAtIndex(index) + @editor.on 'editor:edit-session-order-changed', (e, editSession, fromIndex, toIndex) => + fromTab = @find(".tab:eq(#{fromIndex})") + toTab = @find(".tab:eq(#{toIndex})") + fromTab.detach() + if fromIndex < toIndex + fromTab.insertAfter(toTab) + else + fromTab.insertBefore(toTab) + + @on 'click', '.tab', (e) => + @editor.setActiveEditSessionIndex($(e.target).closest('.tab').index()) + @editor.focus() + + @on 'click', '.tab .close-icon', (e) => + index = $(e.target).closest('.tab').index() + @editor.destroyEditSessionIndex(index) + false + + addTabForEditSession: (editSession) -> + @append(new Tab(editSession, @editor)) + + setActiveTab: (index) -> + @find(".tab.active").removeClass('active') + @find(".tab:eq(#{index})").addClass('active') + + removeTabAtIndex: (index) -> + @find(".tab:eq(#{index})").remove() + + containsEditSession: (editor, editSession) -> + for session in editor.editSessions + return true if editSession.getPath() is session.getPath() + + shouldAllowDrag: (event) -> + panes = rootView.find('.pane') + !(panes.length == 1 && panes.find('.sortable').length == 1) + + onDragStart: (event) => + super + + pane = $(event.target).closest('.pane') + paneIndex = rootView.indexOfPane(pane) + event.originalEvent.dataTransfer.setData 'from-pane-index', paneIndex + + onDrop: (event) => + super + + droppedNearTab = @getSortableElement(event) + transfer = event.originalEvent.dataTransfer + previousDraggedTabIndex = transfer.getData 'sortable-index' + + fromPaneIndex = ~~transfer.getData 'from-pane-index' + toPaneIndex = rootView.indexOfPane($(event.target).closest('.pane')) + fromPane = $(rootView.find('.pane')[fromPaneIndex]) + fromEditor = fromPane.find('.editor').view() + draggedTab = fromPane.find(".#{TabView.viewClass()} .sortable:eq(#{previousDraggedTabIndex})") + return if draggedTab.is(droppedNearTab) + + if fromPaneIndex == toPaneIndex + droppedNearTab = @getSortableElement(event) + fromIndex = draggedTab.index() + toIndex = droppedNearTab.index() + toIndex++ if fromIndex > toIndex + fromEditor.moveEditSessionToIndex(fromIndex, toIndex) + fromEditor.focus() + else + toPane = $(rootView.find('.pane')[toPaneIndex]) + toEditor = toPane.find('.editor').view() + + unless @containsEditSession(toEditor, fromEditor.editSessions[draggedTab.index()]) + fromEditor.moveEditSessionToEditor(draggedTab.index(), toEditor, droppedNearTab.index() + 1) + toEditor.focus() diff --git a/src/packages/tabs/lib/tab.coffee b/src/packages/tabs/lib/tab.coffee index cdb225442..bcc055f1d 100644 --- a/src/packages/tabs/lib/tab.coffee +++ b/src/packages/tabs/lib/tab.coffee @@ -4,7 +4,7 @@ fs = require 'fs' module.exports = class Tab extends View @content: (editSession) -> - @li class: 'tab', => + @li class: 'tab sortable', => @span class: 'file-name', outlet: 'fileName' @span class: 'close-icon' diff --git a/src/packages/tabs/lib/tabs-view.coffee b/src/packages/tabs/lib/tabs-view.coffee deleted file mode 100644 index 85cd3887d..000000000 --- a/src/packages/tabs/lib/tabs-view.coffee +++ /dev/null @@ -1,44 +0,0 @@ -$ = require 'jquery' -{View} = require 'space-pen' -Tab = require './tab' - -module.exports = -class Tabs extends View - @activate: -> - rootView.eachEditor (editor) => - @prependToEditorPane(rootView, editor) if editor.attached - - @prependToEditorPane: (rootView, editor) -> - if pane = editor.pane() - pane.prepend(new Tabs(editor)) - - @content: -> - @ul class: 'tabs' - - initialize: (@editor) -> - for editSession, index in @editor.editSessions - @addTabForEditSession(editSession) - - @setActiveTab(@editor.getActiveEditSessionIndex()) - @editor.on 'editor:active-edit-session-changed', (e, editSession, index) => @setActiveTab(index) - @editor.on 'editor:edit-session-added', (e, editSession) => @addTabForEditSession(editSession) - @editor.on 'editor:edit-session-removed', (e, editSession, index) => @removeTabAtIndex(index) - - @on 'click', '.tab', (e) => - @editor.setActiveEditSessionIndex($(e.target).closest('.tab').index()) - @editor.focus() - - @on 'click', '.tab .close-icon', (e) => - index = $(e.target).closest('.tab').index() - @editor.destroyEditSessionIndex(index) - false - - addTabForEditSession: (editSession) -> - @append(new Tab(editSession, @editor)) - - setActiveTab: (index) -> - @find(".tab.active").removeClass('active') - @find(".tab:eq(#{index})").addClass('active') - - removeTabAtIndex: (index) -> - @find(".tab:eq(#{index})").remove() diff --git a/src/packages/tabs/package.cson b/src/packages/tabs/package.cson index 6cc9d8565..0e40dfd74 100644 --- a/src/packages/tabs/package.cson +++ b/src/packages/tabs/package.cson @@ -1 +1 @@ -'main': 'lib/tabs-view' +'main': 'lib/tab-view' diff --git a/src/packages/tabs/spec/tabs-spec.coffee b/src/packages/tabs/spec/tabs-spec.coffee index a9bc5b358..addbdda81 100644 --- a/src/packages/tabs/spec/tabs-spec.coffee +++ b/src/packages/tabs/spec/tabs-spec.coffee @@ -3,11 +3,11 @@ _ = require 'underscore' RootView = require 'root-view' fs = require 'fs' -describe "Tabs", -> +describe "TabView", -> [editor, buffer, tabs] = [] beforeEach -> - rootView = new RootView(require.resolve('fixtures/sample.js')) + new RootView(require.resolve('fixtures/sample.js')) rootView.open('sample.txt') rootView.simulateDomAttachment() atom.loadPackage("tabs") @@ -144,3 +144,68 @@ describe "Tabs", -> expect(tabs.find('.tab:last .file-name').text()).toBe 'sample.js - tmp' editor.destroyActiveEditSession() expect(tabs.find('.tab:eq(0) .file-name').text()).toBe 'sample.js' + + describe "when an editor:edit-session-order-changed event is triggered", -> + it "updates the order of the tabs to match the new edit session order", -> + expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.js" + expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.txt" + + editor.moveEditSessionToIndex(0, 1) + expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.txt" + expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.js" + + editor.moveEditSessionToIndex(1, 0) + expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.js" + expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.txt" + + describe "dragging and dropping tabs", -> + describe "when a tab is dragged from and dropped onto the same editor", -> + it "moves the edit session, updates the order of the tabs, and focuses the editor", -> + expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.js" + expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.txt" + + sortableElement = [tabs.find('.tab:eq(0)')] + spyOn(tabs, 'getSortableElement').andCallFake -> sortableElement[0] + event = $.Event() + event.target = tabs[0] + event.originalEvent = + dataTransfer: + data: {} + setData: (key, value) -> @data[key] = value + getData: (key) -> @data[key] + + editor.hiddenInput.focusout() + tabs.onDragStart(event) + sortableElement = [tabs.find('.tab:eq(1)')] + tabs.onDrop(event) + + expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.txt" + expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.js" + expect(editor.isFocused).toBeTruthy() + + describe "when a tab is dragged from one editor and dropped onto another editor", -> + it "moves the edit session, updates the order of the tabs, and focuses the destination editor", -> + leftTabs = tabs + rightEditor = editor.splitRight() + rightTabs = rootView.find('.tabs:last').view() + + sortableElement = [leftTabs.find('.tab:eq(0)')] + spyOn(tabs, 'getSortableElement').andCallFake -> sortableElement[0] + event = $.Event() + event.target = leftTabs + event.originalEvent = + dataTransfer: + data: {} + setData: (key, value) -> @data[key] = value + getData: (key) -> @data[key] + + rightEditor.hiddenInput.focusout() + tabs.onDragStart(event) + + event.target = rightTabs + sortableElement = [rightTabs.find('.tab:eq(0)')] + tabs.onDrop(event) + + expect(rightTabs.find('.tab:eq(0) .file-name').text()).toBe "sample.txt" + expect(rightTabs.find('.tab:eq(1) .file-name').text()).toBe "sample.js" + expect(rightEditor.isFocused).toBeTruthy() diff --git a/src/packages/tree-view/keymaps/tree-view.cson b/src/packages/tree-view/keymaps/tree-view.cson index 1e198d26d..0bdf1b863 100644 --- a/src/packages/tree-view/keymaps/tree-view.cson +++ b/src/packages/tree-view/keymaps/tree-view.cson @@ -1,4 +1,4 @@ -'#root-view': +'body': 'meta-\\': 'tree-view:toggle' 'meta-|': 'tree-view:reveal-active-file' diff --git a/static/tabs.css b/static/tabs.css index a6c9cb8b8..814af0ce3 100644 --- a/static/tabs.css +++ b/static/tabs.css @@ -1,14 +1,14 @@ .tabs { font: caption; - -webkit-user-select: none; display: -webkit-box; -webkit-box-align: center; } .tab { + -webkit-user-select: none; + -webkit-user-drag: element; cursor: default; -webkit-box-flex: 2; - position: relative; width: 175px; max-width: 175px; min-width: 40px; @@ -16,6 +16,7 @@ text-shadow: -1px -1px 0 #000; font-size: 11px; padding: 5px 10px; + position: relative; } .tab.active { @@ -75,4 +76,35 @@ .tab.file-modified:hover .close-icon:before { content: "\f081"; color: #66a6ff; +} + +/* Drag and Drop */ +.tab.is-dragging { + +} + +.tab.is-drop-target:after { + position: absolute; + top: 0; + right: -2px; + content: ""; + z-index: 999; + display: inline-block; + width: 2px; + height: 30px; + display: inline-block; + background: #0098ff; +} + +.tab.is-drop-target:before { + content: ""; + position: absolute; + width: 4px; + height: 4px; + background: #0098ff; + right: -4px; + top: 30px; + border-radius: 4px; + z-index: 9999; + border: 1px solid transparent; } \ No newline at end of file