fix(contenteditable): Fix nested list behavior:

Summary:
- Fixes #862
- Makes it behave like hackpad -- adding nested lists will indent the
  current list item
- Fix lint errors

Test Plan: - Integration tests

Reviewers: evan, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2402
This commit is contained in:
Juan Tejada 2015-12-30 14:53:46 -05:00
parent b452be933a
commit a8e771c033
3 changed files with 161 additions and 98 deletions

View file

@ -1,14 +1,14 @@
import N1Launcher from './helpers/n1-launcher'
import ContenteditableTestHarness from './helpers/contenteditable-test-harness.es6'
import N1Launcher from './helpers/n1-launcher';
import ContenteditableTestHarness from './helpers/contenteditable-test-harness.es6';
describe('Contenteditable Integration Spec', function() {
fdescribe('Contenteditable Integration Spec', function() {
beforeAll((done)=>{
this.app = new N1Launcher(["--dev"]);
this.app = new N1Launcher(['--dev']);
this.app.popoutComposerWindowReady().finally(done);
});
beforeEach((done) => {
this.ce = new ContenteditableTestHarness(this.app.client)
this.ce = new ContenteditableTestHarness(this.app.client);
this.ce.init().finally(done);
});
@ -16,100 +16,117 @@ describe('Contenteditable Integration Spec', function() {
if (this.app && this.app.isRunning()) {
this.app.stop().then(done);
} else {
done()
done();
}
});
describe('Manipulating Lists', () => {
it("Creates ordered lists", (done)=> {
it('Creates ordered lists', (done)=> {
this.ce.test({
keys: ["1", ".", "Space"],
expectedHTML: "<ol><li></li></ol>",
keys: ['1', '.', 'Space'],
expectedHTML: '<ol><li></li></ol>',
expectedSelectionResolver: (dom) => {
return {node: dom.querySelectorAll('li')[0]} }
}).then(done).catch(done.fail)
return {
node: dom.querySelectorAll('li')[0],
};
},
}).then(done).catch(done.fail);
});
it('Undoes ordered list creation with backspace', (done) => {
this.ce.test({
keys: ["1", ".", "Space", "Back space"],
expectedHTML: "1.&nbsp;<br>",
keys: ['1', '.', 'Space', 'Back space'],
expectedHTML: '1.&nbsp;<br>',
expectedSelectionResolver: (dom) => {
return {node: dom.childNodes[0], offset: 3} }
}).then(done).catch(done.fail)
return {
node: dom.childNodes[0], offset: 3,
};
},
}).then(done).catch(done.fail);
});
it("Creates unordered lists with star", (done) => {
it('Creates unordered lists with star', (done) => {
this.ce.test({
keys: ['*', 'Space'],
expectedHTML: "<ul><li></li></ul>",
expectedHTML: '<ul><li></li></ul>',
expectedSelectionResolver: (dom) => {
return {node: dom.querySelectorAll("li")[0] } }
}).then(done).catch(done.fail)
return {
node: dom.querySelectorAll('li')[0],
};
},
}).then(done).catch(done.fail);
});
it("Undoes unordered list creation with backspace", (done) => {
it('Undoes unordered list creation with backspace', (done) => {
this.ce.test({
keys: ['*', 'Space', 'Back space'],
expectedHTML: "*&nbsp;<br>",
expectedHTML: '*&nbsp;<br>',
expectedSelectionResolver: (dom) => {
return {node: dom.childNodes[0], offset: 2} }
}).then(done).catch(done.fail)
return {
node: dom.childNodes[0], offset: 2,
};
},
}).then(done).catch(done.fail);
});
it("Creates unordered lists with dash", (done) => {
it('Creates unordered lists with dash', (done) => {
this.ce.test({
keys: ['-', 'Space'],
expectedHTML: "<ul><li></li></ul>",
expectedHTML: '<ul><li></li></ul>',
expectedSelectionResolver: (dom) => {
return {node: dom.querySelectorAll("li")[0] } }
}).then(done).catch(done.fail)
return {
node: dom.querySelectorAll('li')[0],
};
},
}).then(done).catch(done.fail);
});
it("Undoes unordered list creation with backspace", (done) => {
it('Undoes unordered list creation with backspace', (done) => {
this.ce.test({
keys: ['-', 'Space', 'Back space'],
expectedHTML: "-&nbsp;<br>",
expectedHTML: '-&nbsp;<br>',
expectedSelectionResolver: (dom) => {
return {node: dom.childNodes[0], offset: 2} }
}).then(done).catch(done.fail)
return {
node: dom.childNodes[0], offset: 2,
};
},
}).then(done).catch(done.fail);
});
});
describe('When creating two items in a list', () => {
beforeEach(() => {
this.twoItemUl = ['-', 'Space', 'a', 'Return', 'b']
this.twoItemOl = ['1', ".", 'Space', 'a', 'Return', 'b']
this.twoItemUl = ['-', 'Space', 'a', 'Return', 'b'];
this.twoItemOl = ['1', '.', 'Space', 'a', 'Return', 'b'];
});
it("creates two ordered items with enter at end", (done) => {
it('creates two ordered items with enter at end', (done) => {
this.ce.test({
keys: this.twoItemUl,
expectedHTML: "<ul><li>a</li><li>b</li></ul>",
expectedHTML: '<ul><li>a</li><li>b</li></ul>',
expectedSelectionResolver: (dom) => {
return {
node: dom.querySelectorAll("li")[1].childNodes[0],
offset: 1
}
}
}).then(done).catch(done.fail)
node: dom.querySelectorAll('li')[1].childNodes[0],
offset: 1,
};
},
}).then(done).catch(done.fail);
});
it("creates two bullet items with enter at end", (done) => {
it('creates two bullet items with enter at end', (done) => {
this.ce.test({
keys: this.twoItemOl,
expectedHTML: "<ol><li>a</li><li>b</li></ol>",
expectedHTML: '<ol><li>a</li><li>b</li></ol>',
expectedSelectionResolver: (dom) => {
return {
node: dom.querySelectorAll("li")[1].childNodes[0],
offset: 1
}
}
}).then(done).catch(done.fail)
node: dom.querySelectorAll('li')[1].childNodes[0],
offset: 1,
};
},
}).then(done).catch(done.fail);
});
it("outdents the first item when backspacing from the start", (done) => {
it('outdents the first item when backspacing from the start', (done) => {
this.ce.test({
keys: this.twoItemOl.concat(['Up arrow', 'Left arrow', 'Back space']),
expectedHTML: '<span style="line-height: 1.4;">a</span><br><ol><li>b</li></ol>',
@ -119,24 +136,23 @@ describe('Contenteditable Integration Spec', function() {
const {DOMUtils} = require('nylas-exports');
return {
node: DOMUtils.findFirstTextNode(dom),
offset: 0
}
}
}).then(done).catch(done.fail)
offset: 0,
};
},
}).then(done).catch(done.fail);
});
it("outdents the last item when backspacing from the start", (done) => {
it('outdents the last item when backspacing from the start', (done) => {
this.ce.test({
keys: this.twoItemOl.concat(['Left arrow', 'Back space']),
expectedHTML: '<ol><li>a</li></ol><span style="line-height: 1.4;">b</span><br>',
expectedSelectionResolver: (dom) => {
const {DOMUtils} = require('nylas-exports');
return {
node: dom.querySelector('span').childNodes[0],
offset: 0
}
}
}).then(done).catch(done.fail)
offset: 0,
};
},
}).then(done).catch(done.fail);
});
// xit "backspace from the start of the 1st item outdents", ->
@ -164,37 +180,67 @@ describe('Contenteditable Integration Spec', function() {
// @ce.keys @twoItemKeys.concat ['backspace']
});
describe('when creating a list within a list', ()=> {
beforeEach(() => {
this.list = ['-', 'Space'];
});
it('indents list once', (done)=> {
this.ce.test({
keys: this.list.concat(this.list).concat(['a']),
expectedHTML: '<ul><ul><li>a</li></ul></ul>',
expectedSelectionResolver: (dom) => {
return {
node: dom.querySelectorAll('li')[0].childNodes[0],
};
},
}).then(done).catch(done.fail);
});
it('indents list twice', (done)=> {
this.ce.test({
keys: this.list.concat(this.list).concat(this.list).concat(['a']),
expectedHTML: '<ul><ul><ul><li>a</li></ul></ul></ul>',
expectedSelectionResolver: (dom) => {
return {
node: dom.querySelectorAll('li')[0].childNodes[0],
};
},
}).then(done).catch(done.fail);
});
});
describe('When auto-concatenating lists', () => {
beforeEach(() => {
this.threeItemUl = ['-', 'Space', 'a', 'Return', 'b', 'Return', 'c']
this.threeItemOl = ['1', ".", 'Space', 'a', 'Return', 'b', 'Return', 'c']
this.deleteMiddle = ['Up arrow', 'Back space', 'Back space', 'Back space']
this.threeItemUl = ['-', 'Space', 'a', 'Return', 'b', 'Return', 'c'];
this.threeItemOl = ['1', '.', 'Space', 'a', 'Return', 'b', 'Return', 'c'];
this.deleteMiddle = ['Up arrow', 'Back space', 'Back space', 'Back space'];
});
it("concatenates adjacent unordered lists", (done) => {
it('concatenates adjacent unordered lists', (done) => {
this.ce.test({
keys: this.threeItemUl.concat(this.deleteMiddle),
expectedHTML: "<ul><li>a</li><li>c</li></ul>",
expectedHTML: '<ul><li>a</li><li>c</li></ul>',
expectedSelectionResolver: (dom) => {
return {
node: dom.querySelectorAll("li")[0].childNodes[0],
offset: 1
}
}
}).then(done).catch(done.fail)
node: dom.querySelectorAll('li')[0].childNodes[0],
offset: 1,
};
},
}).then(done).catch(done.fail);
});
it("concatenates adjacent ordered lists", (done) => {
it('concatenates adjacent ordered lists', (done) => {
this.ce.test({
keys: this.threeItemOl.concat(this.deleteMiddle),
expectedHTML: "<ol><li>a</li><li>c</li></ol>",
expectedHTML: '<ol><li>a</li><li>c</li></ol>',
expectedSelectionResolver: (dom) => {
return {
node: dom.querySelectorAll("li")[0].childNodes[0],
offset: 1
}
}
}).then(done).catch(done.fail)
node: dom.querySelectorAll('li')[0].childNodes[0],
offset: 1,
};
},
}).then(done).catch(done.fail);
});
});
@ -247,43 +293,41 @@ describe('Contenteditable Integration Spec', function() {
// @ce.keys ['-', ' ', 'a', 'tab', 'shift-tab']
describe('Ensuring popout composer window works', () => {
it("has main window visible", (done)=> {
it('has main window visible', (done)=> {
this.app.client.isWindowVisible()
.then((result)=>{ expect(result).toBe(true) })
.then(done).catch(done.fail)
.then((result)=> expect(result).toBe(true) )
.then(done).catch(done.fail);
});
it("has main window focused", (done)=> {
it('has main window focused', (done)=> {
this.app.client.isWindowFocused()
.then((result)=>{ expect(result).toBe(true) })
.then(done).catch(done.fail)
.then((result)=> expect(result).toBe(true) )
.then(done).catch(done.fail);
});
it("isn't minimized", (done)=> {
it('is not minimized', (done)=> {
this.app.client.isWindowMinimized()
.then((result)=>{ expect(result).toBe(false) })
.then(done).catch(done.fail)
.then((result)=> expect(result).toBe(false) )
.then(done).catch(done.fail);
});
it("doesn't have the dev tools open", (done)=> {
it('does not have the dev tools open', (done)=> {
this.app.client.isWindowDevToolsOpened()
.then((result)=>{ expect(result).toBe(false) })
.then(done).catch(done.fail)
.then((result)=> expect(result).toBe(false) )
.then(done).catch(done.fail);
});
it("has width", (done)=> {
it('has width', (done)=> {
this.app.client.getWindowWidth()
.then((result)=>{ expect(result).toBeGreaterThan(0) })
.then(done).catch(done.fail)
.then((result)=> expect(result).toBeGreaterThan(0) )
.then(done).catch(done.fail);
});
it("has height", (done)=> {
it('has height', (done)=> {
this.app.client.getWindowHeight()
.then((result)=>{ expect(result).toBeGreaterThan(0) })
.then(done).catch(done.fail)
.then((result)=> expect(result).toBeGreaterThan(0) )
.then(done).catch(done.fail);
});
});
});

View file

@ -44,11 +44,11 @@ class ListManager extends ContenteditableExtension
if @numberRegex().test(text)
@originalInput = text[0...3]
editor.insertOrderedList()
@insertList(editor, ordered: true)
@removeListStarter(@numberRegex(), editor.currentSelection())
else if @bulletRegex().test(text)
@originalInput = text[0...2]
editor.insertUnorderedList()
@insertList(editor, ordered: false)
@removeListStarter(@bulletRegex(), editor.currentSelection())
else
return
@ -90,6 +90,15 @@ class ListManager extends ContenteditableExtension
@originalInput = null
@insertList: (editor, {ordered}) ->
node = editor.currentSelection().anchorNode
if @isInsideListItem(node)
editor.indent()
else
editor.insertOrderedList() if ordered is true
editor.insertUnorderedList() if not ordered
@outdentListItem: (editor) ->
if @originalInput
editor.outdent()
@ -97,6 +106,9 @@ class ListManager extends ContenteditableExtension
else
editor.outdent()
@isInsideListItem: (node) =>
DOMUtils.isDescendantOf(node, (parent) -> parent.tagName is 'LI')
# If users ended up with two <ul> lists adjacent to each other, we
# collapse them into one. We leave adjacent <ol> lists intact in case
# the user wanted to restart the numbering sequence

View file

@ -600,4 +600,11 @@ DOMUtils =
node.parentNode.replaceChild(fragment, node)
return fragment
isDescendantOf: (node, matcher = -> false) ->
parent = node?.parentElement
while parent
return true if matcher(parent)
parent = parent.parentElement
false
module.exports = DOMUtils