TinyMCE: add wptextpattern plugin

This plugin can automatically format text patterns as you type. It includes two patterns: unordered (`* ` and `- `) and ordered list (`1. ` and `1) `). If the transformation in unwanted, the user can undo the change by pressing backspace, using the undo shortcut, or the undo button in the toolbar.

This is the first TinyMCE plugin that has unit tests and there's some good groundwork for adding tests to existing plugins in the future.

First run. See #31441.


git-svn-id: https://develop.svn.wordpress.org/trunk@32699 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Ella Iseulde Van Dorpe 2015-06-06 20:07:00 +00:00
parent 66f327cff2
commit 5bac5f7ccd
5 changed files with 273 additions and 18 deletions

View File

@ -368,7 +368,8 @@ final class _WP_Editors {
'wpgallery',
'wplink',
'wpdialogs',
'wpview',
'wptextpattern',
'wpview'
);
if ( ! self::$has_medialib ) {

View File

@ -0,0 +1,100 @@
( function( tinymce, setTimeout ) {
tinymce.PluginManager.add( 'wptextpattern', function( editor ) {
var $$ = editor.$,
patterns = [],
canUndo = false;
function add( regExp, callback ) {
patterns.push( {
regExp: regExp,
callback: callback
} );
}
add( /^[*-]\s/, function() {
this.execCommand( 'InsertUnorderedList' );
} );
add( /^1[.)]\s/, function() {
this.execCommand( 'InsertOrderedList' );
} );
editor.on( 'selectionchange', function() {
canUndo = false;
} );
editor.on( 'keydown', function( event ) {
if ( canUndo && event.keyCode === tinymce.util.VK.BACKSPACE ) {
editor.undoManager.undo();
event.preventDefault();
}
} );
editor.on( 'keyup', function( event ) {
var rng, node, text, parent, child;
if ( event.keyCode !== tinymce.util.VK.SPACEBAR ) {
return;
}
rng = editor.selection.getRng();
node = rng.startContainer;
text = node.nodeValue;
if ( node.nodeType !== 3 ) {
return;
}
parent = editor.dom.getParent( node, 'p' );
if ( ! parent ) {
return;
}
while ( child = parent.firstChild ) {
if ( child.nodeType !== 3 ) {
parent = child;
} else {
break;
}
}
if ( child !== node ) {
return;
}
tinymce.each( patterns, function( pattern ) {
var replace = text.replace( pattern.regExp, '' );
if ( text === replace ) {
return;
}
if ( rng.startOffset !== text.length - replace.length ) {
return;
}
editor.undoManager.add();
editor.undoManager.transact( function() {
if ( replace ) {
$$( node ).replaceWith( document.createTextNode( replace ) );
} else {
$$( node.parentNode ).empty().append( '<br>' );
}
editor.selection.setCursorLocation( parent );
pattern.callback.apply( editor );
} );
// We need to wait for native events to be triggered.
setTimeout( function() {
canUndo = true;
} );
return false;
} );
} );
} );
} )( window.tinymce, window.setTimeout );

View File

@ -131,7 +131,18 @@
// TODO: Replace this with the new event logic in 3.5
function type(chr) {
var editor = tinymce.activeEditor, keyCode, charCode, evt, startElm, rng;
var editor = tinymce.activeEditor, keyCode, charCode, evt, startElm, rng, startContainer, startOffset, textNode;
function charCodeToKeyCode(charCode) {
var lookup = {
'0': 48, '1': 49, '2': 50, '3': 51, '4': 52, '5': 53, '6': 54, '7': 55, '8': 56, '9': 57,'a': 65, 'b': 66, 'c': 67,
'd': 68, 'e': 69, 'f': 70, 'g': 71, 'h': 72, 'i': 73, 'j': 74, 'k': 75, 'l': 76, 'm': 77, 'n': 78, 'o': 79, 'p': 80, 'q': 81,
'r': 82, 's': 83, 't': 84, 'u': 85, 'v': 86, 'w': 87, 'x': 88, 'y': 89, ' ': 32, ',': 188, '-': 189, '.': 190, '/': 191, '\\': 220,
'[': 219, ']': 221, '\'': 222, ';': 186, '=': 187, ')': 41
};
return lookup[String.fromCharCode(charCode)];
}
function fakeEvent(target, type, evt) {
editor.dom.fire(target, type, evt);
@ -139,7 +150,8 @@
// Numeric keyCode
if (typeof(chr) == "number") {
charCode = keyCode = chr;
charCode = chr;
keyCode = charCodeToKeyCode(charCode);
} else if (typeof(chr) == "string") {
// String value
if (chr == '\b') {
@ -150,10 +162,18 @@
charCode = chr.charCodeAt(0);
} else {
charCode = chr.charCodeAt(0);
keyCode = charCode;
keyCode = charCodeToKeyCode(charCode);
}
} else {
evt = chr;
if (evt.charCode) {
chr = String.fromCharCode(evt.charCode);
}
if (evt.keyCode) {
keyCode = evt.keyCode;
}
}
evt = evt || {keyCode: keyCode, charCode: charCode};
@ -175,17 +195,19 @@
rng.execCommand('Delete', false, null);
} else {
rng = editor.selection.getRng();
startContainer = rng.startContainer;
if (rng.startContainer.nodeType == 1 && rng.collapsed) {
var nodes = rng.startContainer.childNodes, lastNode = nodes[nodes.length - 1];
if (startContainer.nodeType == 1 && rng.collapsed) {
var nodes = rng.startContainer.childNodes;
startContainer = nodes[nodes.length - 1];
}
// If caret is at <p>abc|</p> and after the abc text node then move it to the end of the text node
// Expand the range to include the last char <p>ab[c]</p> since IE 11 doesn't delete otherwise
if (rng.startOffset >= nodes.length - 1 && lastNode && lastNode.nodeType == 3 && lastNode.data.length > 0) {
rng.setStart(lastNode, lastNode.data.length - 1);
rng.setEnd(lastNode, lastNode.data.length);
editor.selection.setRng(rng);
}
// If caret is at <p>abc|</p> and after the abc text node then move it to the end of the text node
// Expand the range to include the last char <p>ab[c]</p> since IE 11 doesn't delete otherwise
if ( rng.collapsed && startContainer && startContainer.nodeType == 3 && startContainer.data.length > 0) {
rng.setStart(startContainer, startContainer.data.length - 1);
rng.setEnd(startContainer, startContainer.data.length);
editor.selection.setRng(rng);
}
editor.getDoc().execCommand('Delete', false, null);
@ -194,13 +216,19 @@
rng = editor.selection.getRng(true);
if (rng.startContainer.nodeType == 3 && rng.collapsed) {
rng.startContainer.insertData(rng.startOffset, chr);
rng.setStart(rng.startContainer, rng.startOffset + 1);
rng.collapse(true);
editor.selection.setRng(rng);
// `insertData` may alter the range.
startContainer = rng.startContainer;
startOffset = rng.startOffset;
rng.startContainer.insertData( rng.startOffset, chr );
rng.setStart( startContainer, startOffset + 1 );
} else {
rng.insertNode(editor.getDoc().createTextNode(chr));
textNode = editor.getDoc().createTextNode(chr);
rng.insertNode(textNode);
rng.setStart(textNode, 1);
}
rng.collapse(true);
editor.selection.setRng(rng);
}
}

View File

@ -108,5 +108,11 @@
<# } #>
</li>
</script>
<!-- TinyMCE -->
<script src="../../src/wp-includes/js/tinymce/tinymce.js"></script>
<script src="editor/js/utils.js"></script>
<script src="wp-includes/js/tinymce/plugins/wptextpattern/plugin.js"></script>
</body>
</html>

View File

@ -0,0 +1,120 @@
( function( $, QUnit, tinymce, _type, setTimeout ) {
var editor;
function type() {
var args = arguments;
setTimeout( function() {
if ( typeof args[0] === 'string' ) {
args[0] = args[0].split( '' );
}
if ( typeof args[0] === 'function' ) {
args[0]();
} else {
_type( args[0].shift() );
}
if ( ! args[0].length ) {
[].shift.call( args );
}
if ( args.length ) {
type.apply( null, args );
}
} );
}
QUnit.module( 'tinymce.plugins.wptextpattern', {
beforeEach: function( assert ) {
var done = assert.async();
$( '#qunit-fixture' ).append( '<textarea id="editor">' );
tinymce.init( {
selector: '#editor',
plugins: 'wptextpattern',
init_instance_callback: function() {
editor = arguments[0];
editor.focus();
editor.selection.setCursorLocation();
setTimeout( done );
}
} );
},
afterEach: function() {
editor.remove();
}
} );
QUnit.test( 'Unordered list.', function( assert ) {
type( '* test', function() {
assert.equal( editor.getContent(), '<ul>\n<li>test</li>\n</ul>' );
}, assert.async() );
} );
QUnit.test( 'Ordered list.', function( assert ) {
type( '1. test', function() {
assert.equal( editor.getContent(), '<ol>\n<li>test</li>\n</ol>' );
}, assert.async() );
} );
QUnit.test( 'Ordered list with content.', function( assert ) {
editor.setContent( '<p><strong>test</strong></p>' );
editor.selection.setCursorLocation();
type( '* ', function() {
assert.equal( editor.getContent(), '<ul>\n<li><strong>test</strong></li>\n</ul>' );
}, assert.async() );
} );
QUnit.test( 'Only transform inside a P tag.', function( assert ) {
editor.setContent( '<h1>test</h1>' );
editor.selection.setCursorLocation();
type( '* ', function() {
assert.equal( editor.getContent(), '<h1>* test</h1>' );
}, assert.async() );
} );
QUnit.test( 'Only transform at the start of a P tag.', function( assert ) {
editor.setContent( '<p>test <strong>test</strong></p>' );
editor.selection.setCursorLocation( editor.$( 'strong' )[0].firstChild, 0 );
type( '* ', function() {
assert.equal( editor.getContent(), '<p>test <strong>* test</strong></p>' );
}, assert.async() );
} );
QUnit.test( 'Only transform when at the cursor is at the start.', function( assert ) {
editor.setContent( '<p>* test</p>' );
editor.selection.setCursorLocation( editor.$( 'p' )[0].firstChild, 6 );
type( ' test', function() {
assert.equal( editor.getContent(), '<p>* test test</p>' );
}, assert.async() );
} );
QUnit.test( 'Backspace should undo the transformation.', function( assert ) {
editor.setContent( '<p>test</p>' );
editor.selection.setCursorLocation();
type( '* \b', function() {
assert.equal( editor.getContent(), '<p>* test</p>' );
assert.equal( editor.selection.getRng().startOffset, 2 );
}, assert.async() );
} );
QUnit.test( 'Backspace should undo the transformation only right after it happened.', function( assert ) {
editor.setContent( '<p>test</p>' );
editor.selection.setCursorLocation();
type( '* ', function() {
editor.selection.setCursorLocation( editor.$( 'li' )[0].firstChild, 4 );
// Gecko.
editor.fire( 'click' );
}, '\b', function() {
assert.equal( editor.getContent(), '<ul>\n<li>tes</li>\n</ul>' );
}, assert.async() );
} );
} )( window.jQuery, window.QUnit, window.tinymce, window.Utils.type, window.setTimeout );