Merge pull request #255 from github/rearrange-tabs

Drag and drop tabs
This commit is contained in:
Kevin Sawicki 2013-02-12 08:38:52 -08:00
commit 1ee1fa02ba
15 changed files with 375 additions and 59 deletions

View File

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

View File

@ -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] = []

View File

@ -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] = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

@ -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()

View File

@ -1 +1 @@
'main': 'lib/tabs-view'
'main': 'lib/tab-view'

View File

@ -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()

View File

@ -1,4 +1,4 @@
'#root-view':
'body':
'meta-\\': 'tree-view:toggle'
'meta-|': 'tree-view:reveal-active-file'

View File

@ -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 {
@ -76,3 +77,34 @@
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;
}