/** * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ 'use strict'; import ViewDocumentFragment from '/ckeditor5/engine/view/documentfragment.js'; import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js'; import ViewElement from '/ckeditor5/engine/view/element.js'; import Selection from '/ckeditor5/engine/view/selection.js'; import Range from '/ckeditor5/engine/view/range.js'; import Position from '/ckeditor5/engine/view/position.js'; import AttributeElement from '/ckeditor5/engine/view/attributeelement.js'; import ContainerElement from '/ckeditor5/engine/view/containerelement.js'; import ViewText from '/ckeditor5/engine/view/text.js'; const ELEMENT_RANGE_START_TOKEN = '['; const ELEMENT_RANGE_END_TOKEN = ']'; const TEXT_RANGE_START_TOKEN = '{'; const TEXT_RANGE_END_TOKEN = '}'; /** * Writes the contents of the {@link engine.view.Document Document} to an HTML-like string. * * @param {engine.view.Document} document * @param {Object} [options] * @param {Boolean} [options.withoutSelection=false] Whether to write the selection. When set to `true` selection will * be not included in returned string. * @param {Boolean} [options.rootName='main'] Name of the root from which data should be stringified. If not provided * default `main` name will be used. * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed (`` * instead of `

` and `` instead of ``). * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed * (``, ``). * @returns {String} The stringified data. */ export function getData( document, options = {} ) { const withoutSelection = !!options.withoutSelection; const rootName = options.rootName || 'main'; const root = document.getRoot( rootName ); const stringifyOptions = { showType: options.showType, showPriority: options.showPriority, ignoreRoot: true }; return withoutSelection ? getData._stringify( root, null, stringifyOptions ) : getData._stringify( root, document.selection, stringifyOptions ); } // Set stringify as getData private method - needed for testing/spying. getData._stringify = stringify; /** * Sets the contents of the {@link engine.view.Document Document} provided as HTML-like string. * * @param {engine.view.Document} document * @param {String} data HTML-like string to write into Document. * @param {Object} options * @param {String} [rootName] Root name where parsed data will be stored. If not provided, default `main` name will be * used. */ export function setData( document, data, options = {} ) { const rootName = options.rootName || 'main'; const root = document.getRoot( rootName ); const result = setData._parse( data, { rootElement: root } ); if ( result.view && result.selection ) { document.selection.setTo( result.selection ); } } // Set parse as setData private method - needed for testing/spying. setData._parse = parse; /** * Converts view elements to HTML-like string representation. * Root element can be provided as {@link engine.view.Text Text}: * * const text = new Text( 'foobar' ); * stringify( text ); // 'foobar' * * or as {@link engine.view.Element Element}: * * const element = new Element( 'p', null, new Text( 'foobar' ) ); * stringify( element ); // '

foobar

' * * or as {@link engine.view.DocumentFragment DocumentFragment}: * * const text = new Text( 'foobar' ); * const b = new Element( 'b', { name: 'test' }, text ); * const p = new Element( 'p', { style: 'color:red;' } ); * const fragment = new DocumentFragment( [ p, b ] ); * * stringify( fragment ); // '

foobar' * * Additionally {@link engine.view.Selection Selection} instance can be provided, then ranges from that selection * will be included in output data. * If range position is placed inside element node, it will be represented with `[` and `]`: * * const text = new Text( 'foobar' ); * const b = new Element( 'b', null, text ); * const p = new Element( 'p', null, b ); * const selection = new Selection(); * selection.addRange( Range.createFromParentsAndOffsets( p, 0, p, 1 ) ); * * stringify( p, selection ); // '

[foobar]

' * * If range is placed inside text node, it will be represented with `{` and `}`: * * const text = new Text( 'foobar' ); * const b = new Element( 'b', null, text ); * const p = new Element( 'p', null, b ); * const selection = new Selection(); * selection.addRange( Range.createFromParentsAndOffsets( text, 1, text, 5 ) ); * * stringify( p, selection ); // '

f{ooba}r

' * * Multiple ranges are supported: * * const text = new Text( 'foobar' ); * const selection = new Selection(); * selection.addRange( Range.createFromParentsAndOffsets( text, 0, text, 1 ) ); * selection.addRange( Range.createFromParentsAndOffsets( text, 3, text, 5 ) ); * * stringify( text, selection ); // '{f}oo{ba}r' * * Instead of {@link engine.view.Selection Selection} instance {@link engine.view.Range Range} or * {@link engine.view.Position Position} instance can be provided. If Range instance is provided - it will be * converted to selection containing this range. If Position instance is provided - it will be converted to selection * containing one range collapsed at this position. * * const text = new Text( 'foobar' ); * const range = Range.createFromParentsAndOffsets( text, 0, text, 1 ); * const position = new Position( text, 3 ); * * stringify( text, range ); // '{f}oobar' * stringify( text, position ); // 'foo{}bar' * * Additional options object can be provided. * If `options.showType` is set to `true`, element's types will be * presented for {@link engine.view.AttributeElement AttributeElements} and {@link engine.view.ContainerElement * ContainerElements}: * * const attribute = new AttributeElement( 'b' ); * const container = new ContainerElement( 'p' ); * getData( attribute, null, { showType: true } ); // '' * getData( container, null, { showType: true } ); // '' * * If `options.showPriority` is set to `true`, priority will be displayed for all * {@link engine.view.AttributeElement AttributeElements}. * * const attribute = new AttributeElement( 'b' ); * attribute.priority = 20; * getData( attribute, null, { showPriority: true } ); // * * @param {engine.view.Text|engine.view.Element|engine.view.DocumentFragment} node Node to stringify. * @param {engine.view.Selection|engine.view.Position|engine.view.Range} [selectionOrPositionOrRange = null ] * Selection instance which ranges will be included in returned string data. If Range instance is provided - it will be * converted to selection containing this range. If Position instance is provided - it will be converted to selection * containing one range collapsed at this position. * @param {Object} [options] Object with additional options. * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed (`` * instead of `

` and `` instead of ``). * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed * (``, ``). * @param {Boolean} [options.ignoreRoot=false] When set to `true` root's element opening and closing will not be printed. * Mainly used by `getData` function to ignore {@link engine.view.Document Document's} root element. * @returns {String} HTML-like string representing the view. */ export function stringify( node, selectionOrPositionOrRange = null, options = {} ) { let selection; if ( selectionOrPositionOrRange instanceof Position ) { selection = new Selection(); selection.addRange( new Range( selectionOrPositionOrRange, selectionOrPositionOrRange ) ); } else if ( selectionOrPositionOrRange instanceof Range ) { selection = new Selection(); selection.addRange( selectionOrPositionOrRange ); } else { selection = selectionOrPositionOrRange; } const viewStringify = new ViewStringify( node, selection, options ); return viewStringify.stringify(); } /** * Parses HTML-like string and returns view tree nodes. * Simple string will be converted to {@link engine.view.Text Text} node: * * parse( 'foobar' ); // Returns instance of Text. * * {@link engine.view.Element Elements} will be parsed with attributes an children: * * parse( 'foobar' ); // Returns instance of Element with `baz` attribute and text child node. * * Multiple nodes provided on root level will be converted to {@link engine.view.DocumentFragment DocumentFragment}: * * parse( 'foobar' ); // Returns DocumentFragment with two child elements. * * Method can parse multiple {@link engine.view.Range ranges} provided in string data and return * {@link engine.view.Selection Selection} instance containing these ranges. Ranges placed inside * {@link engine.view.Text Text} nodes should be marked using `{` and `}` brackets: * * const { text, selection } = parse( 'f{ooba}r' ); * * Ranges placed outside text nodes should be marked using `[` and `]` brackets: * * const { root, selection } = parse( '

[foobar]

' ); * * Sometimes there is a need for defining order of ranges inside created selection. This can be achieved by providing * ranges order array as additional parameter: * * const { root, selection } = parse( '{fo}ob{ar}{ba}z', { order: [ 2, 3, 1 ] } ); * * In above example first range (`{fo}`) will be added to selection as second one, second range (`{ar}`) will be added * as third and third range (`{ba}`) will be added as first one. * * If selection's last range should be added as backward one (so the {@link engine.view.Selection#anchor selection * anchor} is represented by `end` position and {@link engine.view.Selection#focus selection focus} is * represented by `start` position) use `lastRangeBackward` flag: * * const { root, selection } = parse( `{foo}bar{baz}`, { lastRangeBackward: true } ); * * @param {String} data HTML-like string to be parsed. * @param {Object} options * @param {Array.} [options.order] Array with order of parsed ranges added to returned * {@link engine.view.Selection Selection} instance. Each element should represent desired position of each range in * selection instance. For example: `[2, 3, 1]` means that first range will be placed as second, second as third and third as first. * @param {Boolean} [options.lastRangeBackward=false] If set to true last range will be added as backward to the returned * {@link engine.view.Selection Selection} instance. * @param {engine.view.Element|engine.view.DocumentFragment} [options.rootElement=null] Default root to use when parsing elements. * When set to `null` root element will be created automatically. If set to * {@link engine.view.Element Element} or {@link engine.view.DocumentFragment DocumentFragment} - this node * will be used as root for all parsed nodes. * @returns {engine.view.Text|engine.view.Element|engine.view.DocumentFragment|Object} Returns parsed view node * or object with two fields `view` and `selection` when selection ranges were included in data to parse. */ export function parse( data, options = {} ) { options.order = options.order || []; const viewParser = new ViewParser(); const rangeParser = new RangeParser(); const view = viewParser.parse( data, options.rootElement ); const ranges = rangeParser.parse( view, options.order ); // When ranges are present - return object containing view, and selection. if ( ranges.length ) { const selection = new Selection(); selection.setRanges( ranges, !!options.lastRangeBackward ); return { view: view, selection: selection }; } return view; } /** * Private helper class used for converting ranges represented as text inside view {@link engine.view.Text Text nodes}. * * @private */ class RangeParser { /** * Parses the view, and returns ranges represented inside {@link engine.view.Text Text nodes}. * Method will remove all occurrences of `{`, `}`, `[` and `]` from found text nodes. If text node is empty after * the process - it will be removed too. * * @param {engine.view.Node} node Starting node. * @param {Array.} order Ranges order. Each element should represent desired position of the range after * sorting. For example: `[2, 3, 1]` means that first range will be placed as second, second as third and third as first. * @returns {Array.} Array with ranges found. */ parse( node, order ) { this._positions = []; // Remove all range brackets from view nodes and save their positions. this._getPositions( node ); // Create ranges using gathered positions. let ranges = this._createRanges(); // Sort ranges if needed. if ( order.length ) { if ( order.length != ranges.length ) { throw new Error( `Parse error - there are ${ ranges.length} ranges found, but ranges order array contains ${ order.length } elements.` ); } ranges = this._sortRanges( ranges, order ); } return ranges; } /** * Gathers positions of brackets inside view tree starting from provided node. Method will remove all occurrences of * `{`, `}`, `[` and `]` from found text nodes. If text node is empty after the process - it will be removed * too. * * @private * @param {engine.view.Node} node Staring node. */ _getPositions( node ) { if ( node instanceof ViewDocumentFragment || node instanceof ViewElement ) { // Copy elements into the array, when nodes will be removed from parent node this array will still have all the // items needed for iteration. const children = [ ...node.getChildren() ]; for ( let child of children ) { this._getPositions( child ); } } if ( node instanceof ViewText ) { const regexp = new RegExp( `[ ${ TEXT_RANGE_START_TOKEN }${ TEXT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_START_TOKEN } ]`, 'g' ); let text = node.data; let match; let offset = 0; const brackets = []; // Remove brackets from text and store info about offset inside text node. while ( ( match = regexp.exec( text ) ) ) { const index = match.index; const bracket = match[ 0 ]; brackets.push( { bracket: bracket, textOffset: index - offset } ); offset++; } text = text.replace( regexp, '' ); node.data = text; const index = node.getIndex(); const parent = node.parent; // Remove empty text nodes. if ( !text ) { node.remove(); } for ( let item of brackets ) { // Non-empty text node. if ( text ) { if ( item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) { // Store information about text range delimiter. this._positions.push( { bracket: item.bracket, position: new Position( node, item.textOffset ) } ); } else { // Check if element range delimiter is not placed inside text node. if ( item.textOffset !== 0 && item.textOffset !== text.length ) { throw new Error( `Parse error - range delimiter '${ item.bracket }' is placed inside text node.` ); } // If bracket is placed at the end of the text node - it should be positioned after it. const offset = ( item.textOffset === 0 ? index : index + 1 ); // Store information about element range delimiter. this._positions.push( { bracket: item.bracket, position: new Position( parent, offset ) } ); } } else { if ( item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) { throw new Error( `Parse error - text range delimiter '${ item.bracket }' is placed inside empty text node. ` ); } // Store information about element range delimiter. this._positions.push( { bracket: item.bracket, position: new Position( parent, index ) } ); } } } } /** * Sort ranges in given order. Ranges order should be an array, each element should represent desired position * of the range after sorting. * For example: `[2, 3, 1]` means that first range will be placed as second, second as third and third as first. * * @private * @param {Array.} ranges Ranges to sort. * @param {Array.} rangesOrder Array with new ranges order. * @returns {Array} Sorted ranges array. */ _sortRanges( ranges, rangesOrder ) { const sortedRanges = []; let index = 0; for ( let newPosition of rangesOrder ) { if ( ranges[ newPosition - 1 ] === undefined ) { throw new Error( 'Parse error - provided ranges order is invalid.' ); } sortedRanges[ newPosition - 1 ] = ranges[ index ]; index++; } return sortedRanges; } /** * Uses all found bracket positions to create ranges from them. * * @private * @returns {Array.` * instead of `

` and `` instead of ``. * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed. * @param {Boolean} [options.ignoreRoot=false] When set to `true` root's element opening and closing tag will not * be outputted. */ constructor( root, selection = null, options = {} ) { this.root = root; this.selection = selection; this.ranges = []; if ( this.selection ) { this.ranges = [ ...selection.getRanges() ]; } this.showType = !!options.showType; this.showPriority = !!options.showPriority; this.ignoreRoot = !!options.ignoreRoot; } /** * Converts view to string. * * @returns {String} String representation of the view elements. */ stringify() { let result = ''; this._walkView( this.root, ( chunk ) => { result += chunk; } ); return result; } /** * Executes simple walker that iterates over all elements in the view tree starting from root element. * Calls `callback` with parsed chunks of string data. * * @private * @param {engine.view.DocumentFragment|engine.view.Element|engine.view.Text} root * @param {Function} callback */ _walkView( root, callback ) { const isElement = root instanceof ViewElement; const ignore = this.ignoreRoot && this.root === root; if ( isElement || root instanceof ViewDocumentFragment ) { if ( isElement && !ignore ) { callback( this._stringifyElementOpen( root ) ); } let offset = 0; callback( this._stringifyElementRanges( root, offset ) ); for ( let child of root.getChildren() ) { this._walkView( child, callback ); offset++; callback( this._stringifyElementRanges( root, offset ) ); } if ( isElement && !ignore ) { callback( this._stringifyElementClose( root ) ); } } if ( root instanceof ViewText ) { callback( this._stringifyTextRanges( root ) ); } } /** * Checks if given {@link engine.view.Element Element} has {@link engine.view.Range#start range start} or * {@link engine.view.Range#start range end} placed at given offset and returns its string representation. * * @private * @param {engine.view.Element} element * @param {Number} offset */ _stringifyElementRanges( element, offset ) { let start = ''; let end = ''; let collapsed = ''; for ( let range of this.ranges ) { if ( range.start.parent == element && range.start.offset === offset ) { if ( range.isCollapsed ) { collapsed += ELEMENT_RANGE_START_TOKEN + ELEMENT_RANGE_END_TOKEN; } else { start += ELEMENT_RANGE_START_TOKEN; } } if ( range.end.parent === element && range.end.offset === offset && !range.isCollapsed ) { end += ELEMENT_RANGE_END_TOKEN; } } return end + collapsed + start; } /** * Checks if given {@link engine.view.Element Text node} has {@link engine.view.Range#start range start} or * {@link engine.view.Range#start range end} placed somewhere inside. Returns string representation of text * with range delimiters placed inside. * * @private * @param {engine.view.Text} node */ _stringifyTextRanges( node ) { const length = node.data.length; let result = node.data.split( '' ); // Add one more element for ranges ending after last character in text. result[ length ] = ''; // Represent each letter as object with information about opening/closing ranges at each offset. result = result.map( ( letter ) => { return { letter: letter, start: '', end: '', collapsed: '' }; } ); for ( let range of this.ranges ) { const start = range.start; const end = range.end; if ( start.parent == node && start.offset >= 0 && start.offset <= length ) { if ( range.isCollapsed ) { result[ end.offset ].collapsed += TEXT_RANGE_START_TOKEN + TEXT_RANGE_END_TOKEN; } else { result[ start.offset ].start += TEXT_RANGE_START_TOKEN; } } if ( end.parent == node && end.offset >= 0 && end.offset <= length && !range.isCollapsed ) { result[ end.offset ].end += TEXT_RANGE_END_TOKEN; } } return result.map( item => item.end + item.collapsed + item.start + item.letter ).join( '' ); } /** * Converts passed {@link engine.view.Element Element} to opening tag. * Depending on current configuration opening tag can be simple (``), contain type prefix (`` or * ``), contain priority information ( `` ). Element's attributes also * will be included (``). * * @private * @param {engine.view.Element} element * @returns {String} */ _stringifyElementOpen( element ) { const priority = this._stringifyElementPriority( element ); const type = this._stringifyElementType( element ); const name = [ type, element.name, priority ].filter( i=> i !== '' ).join( ':' ); const attributes = this._stringifyElementAttributes( element ); const parts = [ name, attributes ]; return `<${ parts.filter( i => i !== '' ).join( ' ' ) }>`; } /** * Converts passed {@link engine.view.Element Element} to closing tag. * Depending on current configuration opening tag can be simple (``) or contain type prefix (`` or * ``). * * @private * @param {engine.view.Element} element * @returns {String} */ _stringifyElementClose( element ) { const priority = this._stringifyElementPriority( element ); const type = this._stringifyElementType( element ); const name = [ type, element.name, priority ].filter( i=> i !== '' ).join( ':' ); return ``; } /** * Converts passed {@link engine.view.Element Element's} type to its string representation * Returns 'attribute' for {@link engine.view.AttributeElement AttributeElements} and * 'container' for {@link engine.view.ContainerElement ContainerElements}. Returns empty string when current * configuration is preventing showing elements' types. * * @private * @param {engine.view.Element} element * @returns {String} */ _stringifyElementType( element ) { if ( this.showType ) { if ( element instanceof AttributeElement ) { return 'attribute'; } if ( element instanceof ContainerElement ) { return 'container'; } } return ''; } /** * Converts passed {@link engine.view.Element Element} to its priority representation. * Priority string representation will be returned when passed element is an instance of * {@link engine.view.AttributeElement AttributeElement} and current configuration allow to show priority. * Otherwise returns empty string. * * @private * @param {engine.view.Element} element * @returns {String} */ _stringifyElementPriority( element ) { if ( this.showPriority && element instanceof AttributeElement ) { return element.priority; } return ''; } /** * Converts passed {@link engine.view.Element Element} attributes to their string representation. * If element has no attributes - empty string is returned. * * @private * @param {engine.view.Element} element * @returns {String} */ _stringifyElementAttributes( element ) { const attributes = []; const keys = [ ...element.getAttributeKeys() ].sort(); for ( let attribute of keys ) { attributes.push( `${ attribute }="${ element.getAttribute( attribute ) }"` ); } return attributes.join( ' ' ); } }