mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-11-30 00:55:00 +00:00
7cec9ae04a
Allow pasting of rich (HTML) content. ve.ce.Surface * Use a sliced document clone for converting to DM HTML (copy) * Add full context to pasteTarget before copying * Add ve-pasteProtect class to spans to prevent them being dropped * Implement external paste by converting HTML to data and inserting with newFromDocumentInsertion * Remove clipboard key placeholder after read so they aren't picked up by rich paste. Hash no longer includes the placeholder. * Detect the corruption of important spans and fallback to clipboard data HTML if available. ve.dm.LinearData * Add clone method for copy ve.dm.ElementLinearData * Add compareUnannotated for use by context diffing. * Add sanitize method for cleaning data according to a set of rules. ve.dm.Transaction * Add range parameter for inserting a range of a document only, e.g. stripping the paste context. ve.dm.Document * Implement sliced document clone creation so that DM HTML is generated correctly in onCopy ve.dm.DocumentSlice * Replaces LinearDataSlice. Now has two ranges for balanced data and data with a full context. ve.init.Target.js * Define default, loose, paste rules (just remove aliens). ve.init.mw.ViewPageTarget * Define strict MW paste rules: + no links, spans, underlines + no images, divs, aliens + strip extra HTML attribues ve.init.sa.Target, ve.init.mw.ViewPageTarget, ve.ui.Surface * Pass through and store paste rules. Bug: 41193 Bug: 48170 Bug: 50128 Bug: 53828 Change-Id: I38d63e31ee3e3ee11707e3fffed5174e1d633b42
487 lines
15 KiB
JavaScript
487 lines
15 KiB
JavaScript
/*!
|
|
* VisualEditor DataModel Document tests.
|
|
*
|
|
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
|
|
* @license The MIT License (MIT); see LICENSE.txt
|
|
*/
|
|
|
|
QUnit.module( 've.dm.Document' );
|
|
|
|
/* Tests */
|
|
|
|
QUnit.test( 'constructor', 12, function ( assert ) {
|
|
var data, htmlDoc,
|
|
doc = ve.dm.example.createExampleDocument();
|
|
assert.equalNodeTree( doc.getDocumentNode(), ve.dm.example.tree, 'node tree matches example data' );
|
|
assert.throws(
|
|
function () {
|
|
doc = new ve.dm.Document( [
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': 'paragraph' }
|
|
] );
|
|
},
|
|
Error,
|
|
'unbalanced input causes exception'
|
|
);
|
|
|
|
doc = new ve.dm.Document( [ 'a', 'b', 'c', 'd' ] );
|
|
assert.equalNodeTree(
|
|
doc.getDocumentNode(),
|
|
new ve.dm.DocumentNode( [ new ve.dm.TextNode( 4 ) ] ),
|
|
'plain text input is handled correctly'
|
|
);
|
|
assert.deepEqualWithDomElements( doc.getMetadata(), new Array( 5 ),
|
|
'sparse metadata array is created'
|
|
);
|
|
assert.equal( doc.getHtmlDocument().body.innerHTML, '', 'Empty HTML document is created' );
|
|
|
|
htmlDoc = ve.createDocumentFromHtml( 'abcd' );
|
|
doc = new ve.dm.Document( [ 'a', 'b', 'c', 'd' ], htmlDoc );
|
|
assert.equal( doc.getHtmlDocument(), htmlDoc, 'Provided HTML document is used' );
|
|
doc = new ve.dm.Document( htmlDoc, ve.createDocumentFromHtml( 'efgh' ) );
|
|
assert.equal( doc.getHtmlDocument(), htmlDoc, 'Second parameter ignored if first parameter is a document' );
|
|
|
|
data = new ve.dm.ElementLinearData(
|
|
new ve.dm.IndexValueStore(),
|
|
[ { 'type': 'paragraph' }, { 'type': '/paragraph' } ]
|
|
);
|
|
doc = new ve.dm.Document( data );
|
|
assert.equalNodeTree(
|
|
doc.getDocumentNode(),
|
|
new ve.dm.DocumentNode( [ new ve.dm.ParagraphNode( [], { 'type': 'paragraph' } ) ] ),
|
|
'empty paragraph no longer has a text node'
|
|
);
|
|
assert.equal( doc.data, data, 'ElementLinearData is stored by reference' );
|
|
|
|
doc = ve.dm.example.createExampleDocument( 'withMeta' );
|
|
assert.deepEqualWithDomElements( doc.getData(), ve.dm.example.withMetaPlainData,
|
|
'metadata is stripped out of the linear model'
|
|
);
|
|
assert.deepEqualWithDomElements( doc.getMetadata(), ve.dm.example.withMetaMetaData,
|
|
'metadata is put in the meta-linmod'
|
|
);
|
|
assert.equalNodeTree(
|
|
doc.getDocumentNode(),
|
|
new ve.dm.DocumentNode( [
|
|
new ve.dm.ParagraphNode( [ new ve.dm.TextNode( 9 ) ], ve.dm.example.withMetaPlainData[0] ),
|
|
new ve.dm.InternalListNode( [], ve.dm.example.withMetaPlainData[11] )
|
|
] ),
|
|
'node tree does not contain metadata'
|
|
);
|
|
} );
|
|
|
|
QUnit.test( 'getData', 1, function ( assert ) {
|
|
var doc = ve.dm.example.createExampleDocument(),
|
|
expectedData = ve.dm.example.preprocessAnnotations( ve.copy( ve.dm.example.data ) );
|
|
assert.deepEqualWithDomElements( doc.getData(), expectedData.getData() );
|
|
} );
|
|
|
|
QUnit.test( 'getFullData', 1, function ( assert ) {
|
|
var doc = ve.dm.example.createExampleDocument( 'withMeta' );
|
|
assert.deepEqualWithDomElements( doc.getFullData(), ve.dm.example.withMeta );
|
|
} );
|
|
|
|
QUnit.test( 'cloneFromRange', function ( assert ) {
|
|
var i, doc2, doc = ve.dm.example.createExampleDocument( 'internalData' ),
|
|
cases = [
|
|
{
|
|
'msg': 'first internal item',
|
|
'doc': 'internalData',
|
|
'range': new ve.Range( 7, 12 ),
|
|
'expectedData': doc.data.slice( 7, 12 ).concat( doc.data.slice( 5, 21 ) )
|
|
},
|
|
{
|
|
'msg': 'second internal item',
|
|
'doc': 'internalData',
|
|
'range': doc.getInternalList().getItemNode( 1 ).getRange(),
|
|
'expectedData': doc.data.slice( 14, 19 ).concat( doc.data.slice( 5, 21 ) )
|
|
},
|
|
{
|
|
'msg': 'paragraph at the start',
|
|
'doc': 'internalData',
|
|
'range': new ve.Range( 0, 5 ),
|
|
'expectedData': doc.data.slice( 0, 21 )
|
|
},
|
|
{
|
|
'msg': 'paragraph at the end',
|
|
'doc': 'internalData',
|
|
'range': new ve.Range( 21, 27 ),
|
|
'expectedData': doc.data.slice( 21, 27 ).concat( doc.data.slice( 5, 21 ) )
|
|
}
|
|
];
|
|
QUnit.expect( 4*cases.length );
|
|
for ( i = 0; i < cases.length; i++ ) {
|
|
doc = ve.dm.example.createExampleDocument( cases[i].doc );
|
|
doc2 = doc.cloneFromRange( cases[i].range );
|
|
assert.deepEqual( doc2.data.data, cases[i].expectedData,
|
|
cases[i].msg + ': sliced data' );
|
|
assert.notStrictEqual( doc2.data[0], cases[i].expectedData[0],
|
|
cases[i].msg + ': data is cloned, not the same' );
|
|
assert.deepEqual( doc2.store, doc.store,
|
|
cases[i].msg + ': store is copied' );
|
|
assert.notStrictEqual( doc2.store, doc.store,
|
|
cases[i].msg + ': store is a clone, not the same' );
|
|
}
|
|
} );
|
|
|
|
QUnit.test( 'getNodeFromOffset', function ( assert ) {
|
|
var i, j, node,
|
|
doc = ve.dm.example.createExampleDocument(),
|
|
root = doc.getDocumentNode().getRoot(),
|
|
expected = [
|
|
[], // 0 - document
|
|
[0], // 1 - heading
|
|
[0], // 2 - heading
|
|
[0], // 3 - heading
|
|
[0], // 4 - heading
|
|
[], // 5 - document
|
|
[1], // 6 - table
|
|
[1, 0], // 7 - tableSection
|
|
[1, 0, 0], // 7 - tableRow
|
|
[1, 0, 0, 0], // 8 - tableCell
|
|
[1, 0, 0, 0, 0], // 9 - paragraph
|
|
[1, 0, 0, 0, 0], // 10 - paragraph
|
|
[1, 0, 0, 0], // 11 - tableCell
|
|
[1, 0, 0, 0, 1], // 12 - list
|
|
[1, 0, 0, 0, 1, 0], // 13 - listItem
|
|
[1, 0, 0, 0, 1, 0, 0], // 14 - paragraph
|
|
[1, 0, 0, 0, 1, 0, 0], // 15 - paragraph
|
|
[1, 0, 0, 0, 1, 0], // 16 - listItem
|
|
[1, 0, 0, 0, 1, 0, 1], // 17 - list
|
|
[1, 0, 0, 0, 1, 0, 1, 0], // 18 - listItem
|
|
[1, 0, 0, 0, 1, 0, 1, 0, 0], // 19 - paragraph
|
|
[1, 0, 0, 0, 1, 0, 1, 0, 0], // 20 - paragraph
|
|
[1, 0, 0, 0, 1, 0, 1, 0], // 21 - listItem
|
|
[1, 0, 0, 0, 1, 0, 1], // 22 - list
|
|
[1, 0, 0, 0, 1, 0], // 23 - listItem
|
|
[1, 0, 0, 0, 1], // 24 - list
|
|
[1, 0, 0, 0], // 25 - tableCell
|
|
[1, 0, 0, 0, 2], // 26 - list
|
|
[1, 0, 0, 0, 2, 0], // 27 - listItem
|
|
[1, 0, 0, 0, 2, 0, 0], // 28 - paragraph
|
|
[1, 0, 0, 0, 2, 0, 0], // 29 - paragraph
|
|
[1, 0, 0, 0, 2, 0], // 30 - listItem
|
|
[1, 0, 0, 0, 2], // 31 - list
|
|
[1, 0, 0, 0], // 32 - tableCell
|
|
[1, 0, 0], // 33 - tableRow
|
|
[1, 0], // 33 - tableSection
|
|
[1], // 34 - table
|
|
[], // 35- document
|
|
[2], // 36 - preformatted
|
|
[2], // 37 - preformatted
|
|
[2], // 38 - preformatted
|
|
[2], // 39 - preformatted
|
|
[2], // 40 - preformatted
|
|
[], // 41 - document
|
|
[3], // 42 - definitionList
|
|
[3, 0], // 43 - definitionListItem
|
|
[3, 0, 0], // 44 - paragraph
|
|
[3, 0, 0], // 45 - paragraph
|
|
[3, 0], // 46 - definitionListItem
|
|
[3], // 47 - definitionList
|
|
[3, 1], // 48 - definitionListItem
|
|
[3, 1, 0], // 49 - paragraph
|
|
[3, 1, 0], // 50 - paragraph
|
|
[3, 1], // 51 - definitionListItem
|
|
[3], // 52 - definitionList
|
|
[], // 53 - document
|
|
[4], // 54 - paragraph
|
|
[4], // 55 - paragraph
|
|
[], // 56 - document
|
|
[5], // 57 - paragraph
|
|
[5], // 58 - paragraph
|
|
[] // 59 - document
|
|
];
|
|
QUnit.expect( expected.length );
|
|
for ( i = 0; i < expected.length; i++ ) {
|
|
node = root;
|
|
for ( j = 0; j < expected[i].length; j++ ) {
|
|
node = node.children[expected[i][j]];
|
|
}
|
|
assert.ok( node === doc.getNodeFromOffset( i ), 'reference at offset ' + i );
|
|
}
|
|
} );
|
|
|
|
QUnit.test( 'getDataFromNode', 3, function ( assert ) {
|
|
var doc = ve.dm.example.createExampleDocument(),
|
|
expectedData = ve.dm.example.preprocessAnnotations( ve.copy( ve.dm.example.data ) );
|
|
assert.deepEqual(
|
|
doc.getDataFromNode( doc.getDocumentNode().getChildren()[0] ),
|
|
expectedData.slice( 1, 4 ),
|
|
'branch with leaf children'
|
|
);
|
|
assert.deepEqual(
|
|
doc.getDataFromNode( doc.getDocumentNode().getChildren()[1] ),
|
|
expectedData.slice( 6, 36 ),
|
|
'branch with branch children'
|
|
);
|
|
assert.deepEqual(
|
|
doc.getDataFromNode( doc.getDocumentNode().getChildren()[2].getChildren()[1] ),
|
|
[],
|
|
'leaf without children'
|
|
);
|
|
} );
|
|
|
|
QUnit.test( 'getOuterLength', 1, function ( assert ) {
|
|
var doc = ve.dm.example.createExampleDocument();
|
|
assert.strictEqual(
|
|
doc.getDocumentNode().getOuterLength(),
|
|
ve.dm.example.data.length,
|
|
'document does not have elements around it'
|
|
);
|
|
} );
|
|
|
|
QUnit.test( 'rebuildNodes', 2, function ( assert ) {
|
|
var tree,
|
|
doc = ve.dm.example.createExampleDocument(),
|
|
documentNode = doc.getDocumentNode();
|
|
// Rebuild table without changes
|
|
doc.rebuildNodes( documentNode, 1, 1, 5, 32 );
|
|
assert.equalNodeTree(
|
|
documentNode,
|
|
ve.dm.example.tree,
|
|
'rebuild without changes'
|
|
);
|
|
|
|
// XXX: Create a new document node tree from the old one
|
|
tree = new ve.dm.DocumentNode( ve.dm.example.tree.getChildren() );
|
|
// Replace table with paragraph
|
|
doc.data.batchSplice( 5, 32, [ { 'type': 'paragraph' }, 'a', 'b', 'c', { 'type': '/paragraph' } ] );
|
|
tree.splice( 1, 1, new ve.dm.ParagraphNode(
|
|
[new ve.dm.TextNode( 3 )], doc.data.getData( 5 )
|
|
) );
|
|
// Rebuild with changes
|
|
doc.rebuildNodes( documentNode, 1, 1, 5, 5 );
|
|
assert.equalNodeTree(
|
|
documentNode,
|
|
tree,
|
|
'replace table with paragraph'
|
|
);
|
|
} );
|
|
|
|
QUnit.test( 'selectNodes', function ( assert ) {
|
|
var i, doc, expectedSelection,
|
|
mainDoc = ve.dm.example.createExampleDocument(),
|
|
cases = ve.dm.example.selectNodesCases;
|
|
|
|
function resolveNode( item ) {
|
|
var newItem = ve.extendObject( {}, item );
|
|
newItem.node = ve.dm.example.lookupNode.apply(
|
|
ve.dm.example, [ doc.getDocumentNode() ].concat( item.node )
|
|
);
|
|
return newItem;
|
|
}
|
|
|
|
QUnit.expect( cases.length );
|
|
for ( i = 0; i < cases.length; i++ ) {
|
|
doc = cases[i].doc ? ve.dm.example.createExampleDocument( cases[i].doc ) : mainDoc;
|
|
expectedSelection = cases[i].expected.map( resolveNode );
|
|
assert.equalNodeSelection(
|
|
doc.selectNodes( cases[i].range, cases[i].mode ), expectedSelection, cases[i].msg
|
|
);
|
|
}
|
|
} );
|
|
|
|
QUnit.test( 'cloneSliceFromRange', function ( assert ) {
|
|
var i, expectedData, slice, range,
|
|
doc = ve.dm.example.createExampleDocument(),
|
|
cases = [
|
|
{
|
|
'msg': 'empty range',
|
|
'range': new ve.Range( 2, 2 ),
|
|
'expected': []
|
|
},
|
|
{
|
|
'msg': 'range with one character',
|
|
'range': new ve.Range( 2, 3 ),
|
|
'expected': [
|
|
['b', [ ve.dm.example.bold ]]
|
|
]
|
|
},
|
|
{
|
|
'msg': 'range with two characters',
|
|
'range': new ve.Range( 2, 4 ),
|
|
'expected': [
|
|
['b', [ ve.dm.example.bold ]],
|
|
['c', [ ve.dm.example.italic ]]
|
|
]
|
|
},
|
|
{
|
|
'msg': 'range with two characters and a header closing',
|
|
'range': new ve.Range( 2, 5 ),
|
|
'expected': [
|
|
{ 'type': 'heading', 'attributes': { 'level': 1 } },
|
|
['b', [ ve.dm.example.bold ]],
|
|
['c', [ ve.dm.example.italic ]],
|
|
{ 'type': '/heading' }
|
|
],
|
|
'originalRange': new ve.Range( 1, 4 )
|
|
},
|
|
{
|
|
'msg': 'range with one character, a header closing and a table opening',
|
|
'range': new ve.Range( 3, 6 ),
|
|
'expected': [
|
|
{ 'type': 'heading', 'attributes': { 'level': 1 } },
|
|
['c', [ ve.dm.example.italic ]],
|
|
{ 'type': '/heading' },
|
|
{ 'type': 'table' },
|
|
{ 'type': '/table' }
|
|
],
|
|
'originalRange': new ve.Range( 1, 4 )
|
|
},
|
|
{
|
|
'msg': 'range from a paragraph into a list',
|
|
'range': new ve.Range( 15, 21 ),
|
|
'expected': [
|
|
{ 'type': 'paragraph' },
|
|
'e',
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'paragraph' },
|
|
'f',
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' }
|
|
],
|
|
'originalRange': new ve.Range( 1, 7 )
|
|
},
|
|
{
|
|
'msg': 'range from a paragraph inside a nested list into the next list',
|
|
'range': new ve.Range( 20, 27 ),
|
|
'expected': [
|
|
{ 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'paragraph' },
|
|
'f',
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' },
|
|
{ 'type': 'list', 'attributes': { 'style': 'number' } },
|
|
{ 'type': '/list' }
|
|
],
|
|
'originalRange': new ve.Range( 5, 12 )
|
|
},
|
|
{
|
|
'msg': 'range from a paragraph inside a nested list out of both lists',
|
|
'range': new ve.Range( 20, 26 ),
|
|
'expected': [
|
|
{ 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'paragraph' },
|
|
'f',
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' }
|
|
],
|
|
'originalRange': new ve.Range( 5, 11 )
|
|
},
|
|
{
|
|
'msg': 'range from a paragraph inside a nested list out of the outer listItem',
|
|
'range': new ve.Range( 20, 25 ),
|
|
'expected': [
|
|
{ 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'paragraph' },
|
|
'f',
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' }
|
|
],
|
|
'originalRange': new ve.Range( 5, 10 ),
|
|
'balancedRange': new ve.Range( 1, 10 )
|
|
},
|
|
{
|
|
'msg': 'table cell',
|
|
'range': new ve.Range( 8, 34 ),
|
|
'expected': [
|
|
{ 'type': 'table' },
|
|
{ 'type': 'tableSection', 'attributes': { 'style': 'body' } },
|
|
{ 'type': 'tableRow' },
|
|
{ 'type': 'tableCell', 'attributes': { 'style': 'data' } },
|
|
{ 'type': 'paragraph' },
|
|
'd',
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'paragraph' },
|
|
'e',
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'paragraph' },
|
|
'f',
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' },
|
|
{ 'type': 'list', 'attributes': { 'style': 'number' } },
|
|
{ 'type': 'listItem' },
|
|
{ 'type': 'paragraph' },
|
|
'g',
|
|
{ 'type': '/paragraph' },
|
|
{ 'type': '/listItem' },
|
|
{ 'type': '/list' },
|
|
{ 'type': '/tableCell' },
|
|
{ 'type': '/tableRow' },
|
|
{ 'type': '/tableSection' },
|
|
{ 'type': '/table' }
|
|
],
|
|
'originalRange': new ve.Range( 3, 29 ),
|
|
'balancedRange': new ve.Range( 3, 29 )
|
|
}
|
|
];
|
|
QUnit.expect( 3 * cases.length );
|
|
for ( i = 0; i < cases.length; i++ ) {
|
|
expectedData = ve.dm.example.preprocessAnnotations( cases[i].expected.slice(), doc.getStore() ).getData();
|
|
range = new ve.Range( 0, cases[i].expected.length );
|
|
expectedData = expectedData.concat( [
|
|
{ 'type': 'internalList' },
|
|
{ 'type': '/internalList' },
|
|
] );
|
|
slice = doc.cloneSliceFromRange( cases[i].range );
|
|
assert.deepEqual(
|
|
slice.getData(),
|
|
expectedData,
|
|
cases[i].msg + ': data'
|
|
);
|
|
assert.deepEqual(
|
|
slice.originalRange,
|
|
cases[i].originalRange || range,
|
|
cases[i].msg + ': original range'
|
|
);
|
|
assert.deepEqual(
|
|
slice.balancedRange,
|
|
cases[i].balancedRange || range,
|
|
cases[i].msg + ': balanced range'
|
|
);
|
|
}
|
|
} );
|
|
|
|
QUnit.test( 'protection against double application of transactions', 1, function ( assert ) {
|
|
var tx = new ve.dm.Transaction(), testDocument = ve.dm.example.createExampleDocument();
|
|
tx.pushRetain( 1 );
|
|
tx.pushReplace( testDocument, 1, 0, ['H', 'e', 'l', 'l', 'o' ] );
|
|
testDocument.commit( tx );
|
|
assert.throws(
|
|
function () {
|
|
testDocument.commit( tx );
|
|
},
|
|
Error,
|
|
'exception thrown when trying to commit an already-committed transaction'
|
|
);
|
|
} );
|