From 7775c06d48eb6f310a8ba03d476fd9fcd36b3bc8 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Aleksander=20=C5=81ukasz?= Date: Wed, 30 Jul 2014 15:04:26 +0200 Subject: [PATCH] editor: wip partial --- .../modules/documentCanvas/canvas/canvas.js | 15 ++ .../modules/documentCanvas/canvas/keyEvent.js | 40 +++ .../modules/documentCanvas/canvas/keyboard.js | 135 +++++++++-- .../documentCanvas/canvas/selection.js | 38 +++ src/editor/plugins/core/core.js | 71 +++++- src/editor/plugins/core/core.test.js | 228 +++++++++++++++++- 6 files changed, 502 insertions(+), 25 deletions(-) create mode 100644 src/editor/modules/documentCanvas/canvas/keyEvent.js diff --git a/src/editor/modules/documentCanvas/canvas/canvas.js b/src/editor/modules/documentCanvas/canvas/canvas.js index 6f648ec..9d33856 100644 --- a/src/editor/modules/documentCanvas/canvas/canvas.js +++ b/src/editor/modules/documentCanvas/canvas/canvas.js @@ -146,6 +146,14 @@ $.extend(Canvas.prototype, Backbone.Events, { this.rootWrapper.append(this.rootElement.dom); }, + + triggerKeyEvent: function(keyEvent, selection) { + selection = selection || this.getSelection(); + if(selection && (selection.type === 'caret' || selection.type === 'textSelection') && selection.toDocumentFragment().isValid()) { + keyboard.handleKeyEvent(keyEvent, selection); + } + }, + setupEventHandling: function() { var canvas = this; @@ -350,6 +358,13 @@ $.extend(Canvas.prototype, Backbone.Events, { } }, + setSelection: function(selection) { + this.select(this, selection.toDocumentFragment()); + }, + + createSelection: function(params) { + return selection.fromParams(this, params); + }, setCurrentElement: function(element, params) { if(!element) { logger.debug('Invalid element passed to setCurrentElement: ' + element); diff --git a/src/editor/modules/documentCanvas/canvas/keyEvent.js b/src/editor/modules/documentCanvas/canvas/keyEvent.js new file mode 100644 index 0000000..a5ce48b --- /dev/null +++ b/src/editor/modules/documentCanvas/canvas/keyEvent.js @@ -0,0 +1,40 @@ +define(function(require) { + +'use strict'; + +var _ = require('libs/underscore'), + keyboard = require('modules/documentCanvas/canvas/keyboard'); + +var KEYS = keyboard.KEYS; + +var KeyEvent = function(params) { + this.key = params.key; + this.ctrlKey = params.ctrlKey; + this._nativeEvent = params._nativeEvent; +}; + +_.extend(KeyEvent.prototype, KEYS, { + forKey: function(k) { + return k === this.key; + }, + preventDefault: function() { + if(this._nativeEvent) { + this._nativeEvent.preventDefault(); + } + } +}); + +return { + fromParams: function(params) { + return new KeyEvent(params); + }, + fromNativeEvent: function(e) { + return this.fromParams({ + key: e.which, + ctrlKey: e.ctrlKey, + _nativeEvent: e + }); + } +}; + +}); \ No newline at end of file diff --git a/src/editor/modules/documentCanvas/canvas/keyboard.js b/src/editor/modules/documentCanvas/canvas/keyboard.js index 9f58066..b2ec6ba 100644 --- a/src/editor/modules/documentCanvas/canvas/keyboard.js +++ b/src/editor/modules/documentCanvas/canvas/keyboard.js @@ -359,32 +359,12 @@ handlers.push({keys: [KEYS.BACKSPACE, KEYS.DELETE], event.preventDefault(); node.setText(''); } - else if(element.isEmpty()) { - event.preventDefault(); - var parent = element.parent(), - grandParent = parent ? parent.parent() : null; - if(!grandParent && parent.children().length === 1) { - return; - } - if(parent.children().length === 1 && parent.children()[0].sameNode(element)) { - if(grandParent && grandParent.children().length === 1) { - goto = grandParent.wlxmlNode.append({text: ''}); - } else { - goto = canvas.getNearestTextElement(direction, element); - } - parent.wlxmlNode.detach(); - } else { - goto = canvas.getNearestTextElement(direction, element); - element.wlxmlNode.detach(); - } - canvas.setCurrentElement(goto, {caretTo: caretTo}); - } else if(cursorAtOperationEdge) { if(direction === 'below') { element = canvas.getNearestTextElement(direction, element); } - if(element) { - goto = element.wlxmlNode.mergeContentUp(); + if(element && element.wlxmlNode.getIndex() === 0) { + goto = element.wlxmlNode.parent().moveUp(); if(goto) { canvas.setCurrentElement(goto.node, {caretTo: goto.offset}); } @@ -399,8 +379,117 @@ handlers.push({keys: [KEYS.BACKSPACE, KEYS.DELETE], } }); +var handleKeyEvent = function(e, s) { + keyEventHandlers.some(function(handler) { + if(handler.applies(e, s)) { + handler.run(e, s); + return true; + } + }); +}; +// todo: whileRemoveWholetext +var keyEventHandlers = [ + { + applies: function(e, s) { + return s.type === 'caret' && ( + (s.isAtBeginning() && e.key === KEYS.BACKSPACE) || + (s.isAtEnd() && e.key === KEYS.DELETE) + ); + }, + run: function(e,s) { + var direction, caretTo, cursorAtOperationEdge, goto, element; + + if(e.key === KEYS.BACKSPACE) { + direction = 'above'; + caretTo = 'end'; + cursorAtOperationEdge = s.isAtBeginning(); + element = s.element; + } + else { + direction = 'below'; + caretTo = 'start'; + cursorAtOperationEdge = s.isAtEnd(); + if(cursorAtOperationEdge) { + element = cursorAtOperationEdge && s.canvas.getNearestTextElement(direction, s.element); + } + } + + if(!cursorAtOperationEdge || !element) { + return; + } + + e.preventDefault(); + + s.canvas.wlxmlDocument.transaction(function() { + if(element.wlxmlNode.getIndex() === 0) { + goto = element.wlxmlNode.parent().moveUp(); + if(goto) { + s.canvas.setCurrentElement(goto.node, {caretTo: goto.offset}); + } + } + }, { + metadata: { + description: gettext('Remove text') + } + }); + } + }, + + { + applies: function(e, s) { + return s.type === 'textSelection' && (e.key === KEYS.BACKSPACE || e.key === KEYS.DELETE); + }, + run: function(e, s) { + var direction = 'above', + caretTo = 'end', + goto; + + + if(e.key === KEYS.DELETE) { + direction = 'below'; + caretTo = 'start'; + } + + e.preventDefault(); + if(direction === 'above') { + if(s.startsAtBeginning()) { + goto = s.canvas.getNearestTextElement('above', s.startElement); + caretTo = 'end'; + } else { + goto = s.startElement; + caretTo = s.startOffset; + } + } else { + if(s.endsAtEnd()) { + goto = s.canvas.getNearestTextElement('below', s.startElement); + caretTo = 'start'; + } else { + goto = s.endElement; + caretTo = 0; + } + } + + s.canvas.wlxmlDocument.deleteText({ + from: { + node: s.startElement.wlxmlNode, + offset: s.startOffset + }, + to: { + node: s.endElement.wlxmlNode, + offset: s.endOffset + } + }); + if(goto) { + s.canvas.setCurrentElement(goto, {caretTo: caretTo}); + } + } + } +]; + return { - handleKey: handleKey + handleKey: handleKey, + handleKeyEvent: handleKeyEvent, + KEYS: KEYS }; }); \ No newline at end of file diff --git a/src/editor/modules/documentCanvas/canvas/selection.js b/src/editor/modules/documentCanvas/canvas/selection.js index 790f56d..fbae482 100644 --- a/src/editor/modules/documentCanvas/canvas/selection.js +++ b/src/editor/modules/documentCanvas/canvas/selection.js @@ -17,11 +17,43 @@ $.extend(CaretSelection.prototype, { toDocumentFragment: function() { var doc = this.canvas.wlxmlDocument; return doc.createFragment(doc.CaretFragment, {node: this.element.wlxmlNode, offset: this.offset}); + }, + isAtEdge: function() { + return this.isAtBeginning() || this.isAtEnd(); + }, + isAtBeginning: function() { + return this.offset === 0; + }, + isAtEnd: function() { + return this.offset === this.element.getText().length; } }); var TextSelection = function(canvas, params) { + var anchorFirst; + Selection.call(this, canvas, params); + + if(this.anchorElement.sameNode(this.focusElement)) { + anchorFirst = this.anchorOffset <= this.focusOffset; + } else { + /*jshint bitwise: false*/ + /* globals Node */ + anchorFirst = this.anchorElement.dom[0].compareDocumentPosition(this.focusElement.dom[0]) & Node.DOCUMENT_POSITION_FOLLOWING; + } + + if(anchorFirst) { + this.startElement = this.anchorElement; + this.startOffset = this.anchorOffset; + this.endElement = this.focusElement; + this.endOffset = this.focusOffset; + + } else { + this.startElement = this.focusElement; + this.startOffset = this.focusOffset; + this.endElement = this.anchorElement; + this.endOffset = this.anchorOffset; + } }; TextSelection.prototype = Object.create(Selection.prototype); $.extend(TextSelection.prototype, { @@ -49,6 +81,12 @@ $.extend(TextSelection.prototype, { node2: siblingParents.node2 }); } + }, + startsAtBeginning: function() { + return this.startOffset === 0; + }, + endsAtEnd: function() { + return this.endOffset === this.endElement.getText().length; } }); diff --git a/src/editor/plugins/core/core.js b/src/editor/plugins/core/core.js index 509d743..cde08d6 100644 --- a/src/editor/plugins/core/core.js +++ b/src/editor/plugins/core/core.js @@ -8,7 +8,7 @@ var _ = require('libs/underscore'), footnote = require('plugins/core/footnote'), switchTo = require('plugins/core/switch'), lists = require('plugins/core/lists'), - plugin = {name: 'core', actions: [], canvas: {}, documentExtension: {textNode: {}}}, + plugin = {name: 'core', actions: [], canvas: {}, documentExtension: {textNode: {}, elementNode: {}}}, Dialog = require('views/dialog/dialog'), canvasElements = require('plugins/core/canvasElements'), metadataEditor = require('plugins/core/metadataEditor/metadataEditor'); @@ -82,6 +82,75 @@ plugin.documentExtension.textNode.transformations = { } }; +plugin.documentExtension.elementNode.transformations = { + moveUp: function() { + var toMerge = this, + prev = toMerge.prev(); + + var merge = function(from, to) { + from.contents().forEach(function(node) { + to.append(node); + }); + from.detach(); + }; + + var strategies = [ + { + applies: function() { + return toMerge.is('p'); + }, + run: function() { + if(prev && prev.is('p') || prev.is({tagName: 'header'})) { + merge(toMerge, prev); + } + if(prev && prev.is('list')) { + var items = prev.contents().filter(function(n) { return n.is('item');}); + merge(toMerge, items[items.length-1]); + //return {node: toMerge, offset:0}; + } + } + }, + { + applies: function() { + return toMerge.is({tagName: 'header'}); + }, + run: function() { + if(prev && prev.is('p') || prev.is({tagName: 'header'})) { + merge(toMerge, prev); + } + } + }, + { + applies: function() { + return toMerge.is('item'); + }, + run: function() { + var list; + if(prev && prev.is('item')) { + merge(toMerge, prev); + } else if(!prev && (list = toMerge.parent()) && list.is('list')) { + list.before(toMerge); + toMerge.setClass('p'); + if(!list.contents().length) { + list.detach(); + } + return {node: toMerge.contents()[0], offset:0}; + } + } + } + ]; + + var toret; + strategies.some(function(strategy) { + if(strategy.applies()) { + toret = strategy.run(); + return true; + } + }); + return toret; + } +}; + var undoRedoAction = function(dir) { return { name: dir, diff --git a/src/editor/plugins/core/core.test.js b/src/editor/plugins/core/core.test.js index ed61328..99f49f4 100644 --- a/src/editor/plugins/core/core.test.js +++ b/src/editor/plugins/core/core.test.js @@ -3,18 +3,29 @@ define(function(require) { 'use strict'; /* globals describe, it */ -var chai = require('libs/chai'), +var _ = require('libs/underscore'), + chai = require('libs/chai'), sinon = require('libs/sinon'), wlxml = require('wlxml/wlxml'), + canvas = require('modules/documentCanvas/canvas/canvas'), + keyboard = require('modules/documentCanvas/canvas/keyboard'), + keyEvent = require('modules/documentCanvas/canvas/keyEvent'), corePlugin = require('./core.js'), expect = chai.expect; +var K = keyboard.KEYS; + var getDocumentFromXML = function(xml, options) { var doc = wlxml.WLXMLDocumentFromXML(xml, options || {}); doc.registerExtension(corePlugin.documentExtension); return doc; }; + +var getCanvasFromXML = function(xml, elements) { + return canvas.fromXMLDocument(getDocumentFromXML(xml), elements); +}; + var getTextNodes = function(text, doc) { /* globals Node */ var toret = []; @@ -49,6 +60,15 @@ var getTextNode = function(text, doc) { return nodes[0]; }; +var getTextElement = function(text, c) { + var node = getTextNode(text, c.wlxmlDocument), + element = node && node.getData('canvasElement'); + if(!(element && element.getText() === text)) { + throw new Error(); + } + return element; +}; + describe('Document extensions', function() { describe('break content', function() { @@ -133,5 +153,211 @@ describe('Document extensions', function() { }); }); +describe.only('Keyboard interactions', function() { + + var Keyboard = function(canvas) { + this.canvas = canvas; + }; + + _.extend(Keyboard.prototype, { + press: function(key) { + this.canvas.triggerKeyEvent(keyEvent.fromParams({key:key}), this.selection); + this.selection = this.canvas.getSelection(); + return this; + }, + withCaret: function(where) { + var offset = where.indexOf('|'), + text = where.split('|').join(''), + el = getTextElement(text, this.canvas), + selection = this.canvas.createSelection({type: 'caret', element: el, offset: offset}); + if(offset === -1) { + throw new Error('Invalid caret'); + } + this.selection = selection; + return this; + }, + withSelection: function(start, end) { + var startOffset = start.indexOf('|'), + endOffset = end.indexOf('|'), + startText= start.split('|').join(''), + endText = end.split('|').join(''), + startElement = getTextElement(startText, this.canvas), + endElement = getTextElement(endText, this.canvas), + selection = this.canvas.createSelection({ + type: 'textSelection', + anchorElement: startElement, + anchorOffset: startOffset, + focusElement: endElement, + focusOffset: endOffset + }); + if(startOffset === -1 || endOffset === -1) { + throw new Error('Invalid text selection'); + } + this.selection = selection; + return this; + } + }); + + describe('deleting text with selection', function() { + [K.BACKSPACE, K.DELETE].forEach(function(key) { + it('deletes text withing a single text element ' + key, function() { + var c = getCanvasFromXML('
Alice
'), + k = new Keyboard(c); + + k.withSelection('A|lice', 'Alic|e').press(key); + expect(c.wlxmlDocument.root.contents()[0].contents()[0].getText()).to.equal('Ae'); + }); + it('deletes text across two paragraphs ' + key, function() { + var c = getCanvasFromXML('
Alice
cat
'), + k = new Keyboard(c); + + k.withSelection('A|lice', 'c|at').press(key); + var rootContents = c.wlxmlDocument.root.contents(); + + expect(rootContents.length).to.equal(2); + expect(rootContents[0].contents()[0].getText()).to.equal('A'); + expect(rootContents[1].contents()[0].getText()).to.equal('at'); + }); + + it('keeps an empty paragraph after deleting its whole text ' + key, function() { + var c = getCanvasFromXML('
Alice
'), + k = new Keyboard(c); + + k.withSelection('|Alice', 'Alice|').press(key); + var rootContents = c.wlxmlDocument.root.contents(); + + expect(rootContents.length).to.equal(1); + expect(rootContents[0].contents()[0].getText()).to.equal(''); + }); + }); + + }); + + // describe('deleting with a caret', function() { + // it('keeps an empty paragraph after deleteing last letter with backspace', function() { + // var c = getCanvasFromXML('
A
'), + // k = new Keyboard(c); + + // k.withCaret('A|').press(K.BACKSPACE); + // var rootContents = c.wlxmlDocument.root.contents(); + + // expect(rootContents.length).to.equal(1); + // expect(rootContents[0].contents()[0].getText()).to.equal(''); + // }); + // // it('removes a paragraph on yet another delete' + key, function() { + + // // }); + // }); + + + // + empty when bck/ins + l===1 + + describe('backspace at the beginning', function() { + it('merges two adjacent paragraphs', function() { + var c = getCanvasFromXML('
A
B
'), + k = new Keyboard(c); + + k.withCaret('|B').press(K.BACKSPACE); + + var rootContents = c.wlxmlDocument.root.contents(); + expect(rootContents.length).to.equal(1); + expect(rootContents[0].getClass()).to.equal('p'); + expect(rootContents[0].contents()[0].getText()).to.equal('AB'); + }); + it('merges a paragraph with a header', function() { + var c = getCanvasFromXML('
A
B
'), + k = new Keyboard(c); + + k.withCaret('|B').press(K.BACKSPACE); + + var rootContents = c.wlxmlDocument.root.contents(); + expect(rootContents.length).to.equal(1); + expect(rootContents[0].getTagName()).to.equal('header'); + expect(rootContents[0].contents()[0].getText()).to.equal('AB'); + }); + it('merges two adjacent headers', function() { + var c = getCanvasFromXML('
A
B
'), + k = new Keyboard(c); + + k.withCaret('|B').press(K.BACKSPACE); + var rootContents = c.wlxmlDocument.root.contents(); + expect(rootContents.length).to.equal(1); + expect(rootContents[0].getTagName()).to.equal('header'); + expect(rootContents[0].contents()[0].getText()).to.equal('AB'); + }); + it('merges a header with a paragraph', function() { + var c = getCanvasFromXML('
A
B
'), + k = new Keyboard(c); + + k.withCaret('|B').press(K.BACKSPACE); + + var rootContents = c.wlxmlDocument.root.contents(); + expect(rootContents.length).to.equal(1); + expect(rootContents[0].is('p')).to.equal(true); + expect(rootContents[0].contents()[0].getText()).to.equal('AB'); + }); + it('merges a paragraph into a last list item', function() { + var c = getCanvasFromXML('
item
paragraph
'), + list = c.wlxmlDocument.root.contents()[0], + k = new Keyboard(c); + + k.withCaret('|paragraph').press(K.BACKSPACE); + + var rootContents = c.wlxmlDocument.root.contents(); + expect(rootContents.length).to.equal(1); + expect(rootContents[0].sameNode(list)).to.equal(true); + + var items = list.contents(); + expect(items.length).to.equal(1); + expect(items[0].contents()[0].getText()).to.equal('itemparagraph'); + }); + it('merges a list item with a list item', function() { + var c = getCanvasFromXML('
item1
item2
'), + list = c.wlxmlDocument.root.contents()[0], + k = new Keyboard(c); + + k.withCaret('|item2').press(K.BACKSPACE); + + var rootContents = c.wlxmlDocument.root.contents(); + expect(rootContents.length).to.equal(1); + + expect(rootContents[0].sameNode(list)).to.equal(true); + + var items = list.contents(); + + expect(items.length).to.equal(1); + expect(items[0].contents()[0].getText()).to.equal('item1item2'); + }); + it('creates a new paragraph preceding the list from a first list item', function() { + var c = getCanvasFromXML('
item1
item2
'), + list = c.wlxmlDocument.root.contents()[0], + k = new Keyboard(c); + + k.withCaret('|item1').press(K.BACKSPACE); + + var rootContents = c.wlxmlDocument.root.contents(); + expect(rootContents.length).to.equal(2); + + expect(rootContents[0].getClass()).to.equal('p'); + expect(rootContents[0].contents()[0].getText()).to.equal('item1'); + + expect(rootContents[1].sameNode(list)).to.equal(true); + }); + it('removes list after moving up its only item', function() { + var c = getCanvasFromXML('
item
'), + k = new Keyboard(c); + + k.withCaret('|item').press(K.BACKSPACE); + var rootContents = c.wlxmlDocument.root.contents(); + expect(rootContents.length).to.equal(1); + + expect(rootContents[0].getClass()).to.equal('p'); + expect(rootContents[0].contents()[0].getText()).to.equal('item'); + }); + }); + + +}); + }); \ No newline at end of file -- 2.20.1