浏览代码

Paste from Word plugin added with flat lists support.

Krzysztof Krztoń 7 年之前
父节点
当前提交
0ff1346bc1

+ 150 - 0
packages/ckeditor5-paste-from-office/src/filters/common.js

@@ -0,0 +1,150 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor';
+import { stringify } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
+const htmlDataProcessor = new HtmlDataProcessor();
+
+/**
+ * Extracts `body` tag contents from the provided HTML string.
+ *
+ * @param {Object} data
+ * @param {String} data.html HTML string from which `body` contents will be extracted.
+ * @returns {Object} result
+ * @returns {String|null} result.body Extracted `body` contents. If `body` tag was not present null is returned.
+ */
+export function extractBody( data ) {
+	const bodyRegexp = /<body[^>]*>([\s*|\S*]*?)<\/body>/i;
+	const bodyMatch = data.html.match( bodyRegexp );
+
+	data.body = bodyMatch && bodyMatch[ 1 ] ? bodyMatch[ 1 ] : null;
+
+	return data;
+}
+
+/**
+ * Parses provided HTML string to {@link module:engine/view/view~View} element.
+ *
+ * @param {Object} data
+ * @param {String} data.body HTML string which should be parsed.
+ * @returns {Object} result
+ * @returns {module:engine/view/view~View|null} result.view The {@link module:engine/view/view~View} class instance
+ * created based on provided HTML string. Return `null` if `data.body` parameter was empty.
+ */
+export function bodyToView( data ) {
+	data.view = data.body ? htmlDataProcessor.toView( data.body ) : null;
+
+	return data;
+}
+
+/**
+ * Extracts `style` tags content from provided HTML string and combines into one string.
+ *
+ * @param {Object} data
+ * @param {String} data.html HTML string from which `style` tags content will be extracted.
+ * @returns {Object} result
+ * @returns {String|null} result.styles Extracted `style` tags content. If there were no `style` tags, `null` is returned.
+ */
+export function extractStyles( data ) {
+	const styleRegexp = /<style[^>]*>([\s*|\S*]*?)<\/style>/gi;
+
+	let styles = '';
+
+	let styleMatch;
+	while ( ( styleMatch = styleRegexp.exec( data.html ) ) !== null ) {
+		if ( styleMatch && styleMatch[ 1 ] ) {
+			styles += styleMatch[ 1 ];
+		}
+	}
+
+	data.styles = styles.length ? styles : null;
+
+	return data;
+}
+
+/**
+ * Parses given styles string returning native `CSSStyleSheet` object.
+ *
+ * @param {Object} data
+ * @param {String} data.styles Styles to be parse.
+ * @param {Document} domDocument Document used to create helper element in which stylesheet will be injected.
+ * @returns {Object} result
+ * @returns {CSSStyleSheet|null} result.stylesheet Native `CSSStyleSheet` object containing parsed styles
+ * or `null` if `data.styles` or `domDocument` were not provided.
+ */
+export function stylesToStylesheet( data, domDocument ) {
+	data.stylesheet = null;
+
+	if ( data.styles && domDocument ) {
+		// Replace invalid CSS selectors so they can be correctly parsed:
+		//		* `@list lX:levelX` with `at_list lX_levelX`
+		//		* `@list lX` with `at_list lX`
+		const styles = data.styles
+			.replace( /@list\s+l(\d+):level(\d+)/g, 'at_list l$1_level$2' )
+			.replace( /@list /g, 'at_list ' );
+
+		data.stylesheet = parseCSS( styles, domDocument );
+	}
+
+	return data;
+}
+
+/**
+ * Stringifies provided view instance.
+ *
+ * @param {Object} data
+ * @param {module:engine/view/view~View} data.view The {@link module:engine/view/view~View} instance.
+ * @returns {Object} result
+ * @returns {String|null} result.view String representing contents of a given {@link module:engine/view/view~View} instance
+ * or `null` if `data.view` was not provided.
+ */
+export function stringifyView( data ) {
+	if ( data.view ) {
+		data.view = stringify( data.view );
+	}
+
+	return data;
+}
+
+// Parses provided CSS string creating native `CSSStyleSheet` object.
+//
+// If available this function use shadow DOM element to parse CSS. If not it fallback to ifrmae element.
+//
+// @param {String} cssString String containing CSS rules/stylsheet to be parsed.
+// @param {Document} domDocument Document used to create helper element in which stylesheet will be injected.
+// @returns {CSSStyleSheet} Native `CSSStyleSheet` object containing parsed styles.
+function parseCSS( cssString, domDocument ) {
+	const style = domDocument.createElement( 'style' );
+
+	let wrapper = null;
+
+	if ( domDocument.head && domDocument.head.attachShadow ) {
+		// Use shadow DOM if available.
+		wrapper = domDocument.createElement( 'div' );
+		const shadowRoot = wrapper.attachShadow( { mode: 'open' } );
+
+		wrapper.hidden = true;
+
+		domDocument.body.appendChild( wrapper );
+		shadowRoot.appendChild( style );
+	} else {
+		// Use iframe element.
+		wrapper = domDocument.createElement( 'iframe' );
+
+		wrapper.hidden = true;
+
+		domDocument.body.appendChild( wrapper );
+		wrapper.contentDocument.documentElement.appendChild( style );
+	}
+
+	style.textContent = cssString;
+
+	const stylesheet = style.sheet;
+
+	wrapper.remove();
+
+	return stylesheet;
+}

+ 190 - 0
packages/ckeditor5-paste-from-office/src/filters/list.js

@@ -0,0 +1,190 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Element from '@ckeditor/ckeditor5-engine/src/view/element';
+import Matcher from '@ckeditor/ckeditor5-engine/src/view/matcher';
+import Position from '@ckeditor/ckeditor5-engine/src/view/position';
+import TreeWalker from '@ckeditor/ckeditor5-engine/src/view/treewalker';
+import RawWriter from '@ckeditor/ckeditor5-engine/src/view/rawwriter';
+
+/**
+ * Transforms Word specific list-like elements to the semantic HTML lists.
+ *
+ * Lists in Word are represented by block elements with special attributes like:
+ *
+ *		<p class=MsoListParagraphCxSpFirst style='mso-list:l1 level1 lfo1'>...</p> // Paragraph based list.
+ *		<h1 style='mso-list:l0 level1 lfo1'>...</h1> // Heading 1 based list.
+ *
+ * @param {Object} data
+ * @param {module:engine/view/view~View} data.view The {@link module:engine/view/view~View} instance.
+ * @returns {Object} result
+ * @returns {module:engine/view/view~View} result.view The {@link module:engine/view/view~View} instance
+ * with list-like elements transformed into semantic lists.
+ */
+export function paragraphsToLists( data ) {
+	if ( data.view ) {
+		const firstChild = data.view.getChild( 0 );
+
+		if ( firstChild ) {
+			const listNodes = findAllListNodes( Position.createBefore( firstChild ) );
+			createLists( listNodes, data.styles );
+		}
+	}
+
+	return data;
+}
+
+// Writer used for View elements manipulation.
+const writer = new RawWriter();
+
+// Matcher for finding list-like elements.
+const listMatcher = new Matcher( {
+	name: /^p|h\d+$/,
+	styles: {
+		'mso-list': /.*/
+	}
+} );
+
+// Matcher for finding `span` elements holding lists numbering/bullets.
+const listBulletMatcher = new Matcher( {
+	name: 'span',
+	styles: {
+		'mso-list': 'Ignore'
+	}
+} );
+
+// Finds all list-like nodes starting from a given position.
+//
+// @param {module:engine/src/view/position~Position} startPosition Position from which to start looking.
+// @returns {Array.<Object>} Array of found list items. Each item is an object containing:
+//		* {module:engine/src/view/element~Element} element List-like element.
+//		* {Number} id List item id parsed from `mso-list` style (see `getListItemData()` function).
+//		* {Number} order List item creation order parsed from `mso-list` style (see `getListItemData()` function).
+//		* {Number} indent List item indentation level parsed from `mso-list` style (see `getListItemData()` function).
+function findAllListNodes( startPosition ) {
+	const treeWalker = new TreeWalker( { startPosition, ignoreElementEnd: true } );
+
+	// Find all list nodes.
+	const listNodes = [];
+	for ( const value of treeWalker ) {
+		if ( value.type === 'elementStart' && listMatcher.match( value.item ) ) {
+			const itemData = getListItemData( value.item );
+
+			listNodes.push( {
+				element: value.item,
+				id: itemData.id,
+				order: itemData.order,
+				indent: itemData.indent
+			} );
+		}
+	}
+
+	return listNodes;
+}
+
+// Transforms given list-like nodes into semantic lists. As the function operates on provided
+// {module:engine/src/view/element~Element elements}, it will modify the view structure to which list elements belongs.
+//
+// @param {Array.<Object>} listItems Array containing list items data. Usually it is the output of `findAllListNodes()` function.
+// @param {String} styles CSS styles which may contain additional data about lists format.
+function createLists( listItems, styles ) {
+	if ( listItems.length ) {
+		let currentList = null;
+		let previousListItem = null;
+
+		for ( const listItem of listItems ) {
+			const listNode = listItem.element;
+
+			if ( !previousListItem || previousListItem.id !== listItem.id ) {
+				const listStyle = findListType( listItem, styles );
+				currentList = new Element( listStyle.type );
+				writer.insertChild( listNode.parent, listNode.parent.getChildIndex( listNode ), currentList );
+			}
+
+			removeBulletElement( listNode );
+
+			writer.appendChild( currentList, listNode );
+			writer.rename( listNode, 'li' );
+
+			previousListItem = listItem;
+		}
+	}
+}
+
+// Extracts list information from Word specific list style like:
+//
+//		`mso-list:l1 level1 lfo1`
+//
+// where:
+//
+//		* `l1` is a list id (all elements with the same id belongs to the same list),
+//		* `level1` is a list item indentation level,
+//		* `lfo1` is a list insertion order in a document.
+//
+// @param {module:engine/view/element~Element} element List-like element from which data is extracted.
+// @returns {Object} result
+// @returns {Number} result.id List id.
+// @returns {Number} result.order List creation order.
+// @returns {Number} result.indent List indentation level.
+function getListItemData( element ) {
+	const data = {};
+	const listStyle = element.getStyle( 'mso-list' );
+
+	if ( listStyle ) {
+		data.id = parseInt( listStyle.match( /(^|\s+)l(\d+)/i )[ 2 ] );
+		data.order = parseInt( listStyle.match( /\s*lfo(\d+)/i )[ 1 ] );
+		data.indent = parseInt( listStyle.match( /\s*level(\d+)/i )[ 1 ] );
+	}
+
+	return data;
+}
+
+// Checks list item style based on provided CSS. List item style is extracted from CSS stylesheet. Each list with its specific
+// styling `mso-list:l1 level1 lfo1` has its dedicated properties in a stylesheet defined with selector like:
+//
+// 		@list l1:level1 { ... }
+//
+// which contains `mso-level-number-format` property which defines list numbering/bullet style. If this property
+// is not defined it means default `decimal` numbering.
+//
+// @param {Object} listItem List item for which list style will be find.
+// @param {String} styles CSS stylesheet.
+// @returns {Object} result
+// @returns {String} result.type Type of the list, could be `ul` or `ol`.
+// @returns {String} result.style List style like `decimal`, `lower-roman`, etc. It is passed directly from Word stylesheet
+// so may be not compatible with CSS `list-style-type` accepted values.
+function findListType( listItem, styles ) {
+	const listStyleRegexp = new RegExp( `@list l${ listItem.id }:level${ listItem.indent }\\s*({[^}]*)`, 'gi' );
+	const listStyleTypeRegex = /mso-level-number-format:([^;]*);/gi;
+
+	const listStyleMatch = listStyleRegexp.exec( styles );
+
+	let listStyleType = 'decimal'; // Decimal is default one.
+	if ( listStyleMatch && listStyleMatch[ 1 ] ) {
+		const listStyleTypeMatch = listStyleTypeRegex.exec( listStyleMatch[ 1 ] );
+
+		if ( listStyleTypeMatch && listStyleTypeMatch[ 1 ] ) {
+			listStyleType = listStyleTypeMatch[ 1 ];
+		}
+	}
+
+	return {
+		type: listStyleType !== 'bullet' && listStyleType !== 'image' ? 'ol' : 'ul',
+		style: listStyleType
+	};
+}
+
+// Removes span with a numbering/bullet from the given list element.
+//
+// @param {module:engine/view/element~Element} listElement
+function removeBulletElement( listElement ) {
+	const treeWalker = new TreeWalker( { startPosition: Position.createBefore( listElement.getChild( 0 ) ), ignoreElementEnd: true } );
+
+	for ( const value of treeWalker ) {
+		if ( value.type === 'elementStart' && listBulletMatcher.match( value.item ) ) {
+			writer.remove( value.item );
+		}
+	}
+}

+ 97 - 0
packages/ckeditor5-paste-from-office/src/pastefromword.js

@@ -0,0 +1,97 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module pastefromword/pastefromword
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import { extractBody, bodyToView, extractStyles } from './filters/common';
+import { paragraphsToLists } from './filters/list';
+
+/**
+ * This plugin handles content pasted from Word and transforms it if necessary
+ * to format suitable for editor {@link module:engine/model/model~Model}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class PasteFromWord extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'PasteFromWord';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const document = editor.editing.view.document;
+
+		this.listenTo( document, 'clipboardInput', ( evt, data ) => {
+			const html = data.dataTransfer.getData( 'text/html' );
+
+			if ( isWordInput( html ) ) {
+				evt.stop();
+
+				const normalizedInput = this._normalizeWordInput( html, editor );
+
+				editor.plugins.get( 'Clipboard' ).fire( 'inputTransformation', { content: normalizedInput } );
+			}
+		} );
+	}
+
+	/**
+	 * Normalizes input pasted from Word to format suitable for editor {@link module:engine/model/model~Model}.
+	 *
+	 * @private
+	 * @param {module:core/editor/editor~Editor} editor Editor instance.
+	 * @param {String} input Word input.
+	 * @returns {module:engine/view/view~View} view Normalized input as {module:engine/view/view~View} instance.
+	 */
+	_normalizeWordInput( input, editor ) {
+		const editorDocument = editor.editing.view.getDomRoot();
+		const ownerDocument = editorDocument ? editorDocument.ownerDocument : null;
+
+		return transformInput( input, ownerDocument,
+			extractBody,
+			bodyToView,
+			extractStyles,
+			paragraphsToLists
+		).view;
+	}
+}
+
+// Checks if given HTML string was produced by pasting content from Word.
+//
+// @param {String} html HTML string to test.
+// @returns {Boolean} True if given HTML string is a Word HTML.
+//
+function isWordInput( html ) {
+	return !!( html && html.match( /<meta\s*name="?generator"?\s*content="?microsoft\s*word\s*\d+"?\/?>/gi ) );
+}
+
+// Transforms given HTML string by provided transformation functions.
+//
+// @param {String} html HTML string to transform.
+// @param {Document} domDocument Editor owner document.
+// @param {Array.<Function>} transforms Functions which are used in the order of passing to transform given HTML.
+// @returns {Object} data Object containing transformed parts of an input HTML string in a different formats. The number
+// and type of formats depends on a provided transforms as each transform can create separate format or change existing one.
+// @returns {String} data.html Input HTML string.
+// @returns {*} data.* Any type of data created by transform functions. It directly depends on transform functions
+// which were provided. to this function.
+function transformInput( html, domDocument, ...transforms ) {
+	let transformedData = { html };
+
+	for ( const transform of transforms ) {
+		transformedData = transform( transformedData, domDocument );
+	}
+
+	return transformedData;
+}