8
0

view.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. import Document from '/ckeditor5/engine/view/document.js';
  6. import ViewDocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
  7. import XmlDataProcessor from '/ckeditor5/engine/dataprocessor/xmldataprocessor.js';
  8. import ViewElement from '/ckeditor5/engine/view/element.js';
  9. import Selection from '/ckeditor5/engine/view/selection.js';
  10. import Range from '/ckeditor5/engine/view/range.js';
  11. import Position from '/ckeditor5/engine/view/position.js';
  12. import AttributeElement from '/ckeditor5/engine/view/attributeelement.js';
  13. import ContainerElement from '/ckeditor5/engine/view/containerelement.js';
  14. import ViewText from '/ckeditor5/engine/view/text.js';
  15. const ELEMENT_RANGE_START_TOKEN = '[';
  16. const ELEMENT_RANGE_END_TOKEN = ']';
  17. const TEXT_RANGE_START_TOKEN = '{';
  18. const TEXT_RANGE_END_TOKEN = '}';
  19. /**
  20. * Writes the contents of the {@link engine.view.Document Document} to an HTML-like string.
  21. *
  22. * @param {engine.view.Document} document
  23. * @param {Object} [options]
  24. * @param {Boolean} [options.withoutSelection=false] Whether to write the selection. When set to `true` selection will
  25. * be not included in returned string.
  26. * @param {Boolean} [options.rootName='main'] Name of the root from which data should be stringified. If not provided
  27. * default `main` name will be used.
  28. * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed (`<container:p>`
  29. * instead of `<p>` and `<attribute:b>` instead of `<b>`).
  30. * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed
  31. * (`<span view-priority="12">`, `<b view-priority="10">`).
  32. * @returns {String} The stringified data.
  33. */
  34. export function getData( document, options = {} ) {
  35. if ( !( document instanceof Document ) ) {
  36. throw new TypeError( 'Document needs to be an instance of engine.view.Document.' );
  37. }
  38. const withoutSelection = !!options.withoutSelection;
  39. const rootName = options.rootName || 'main';
  40. const root = document.getRoot( rootName );
  41. const stringifyOptions = {
  42. showType: options.showType,
  43. showPriority: options.showPriority,
  44. ignoreRoot: true
  45. };
  46. return withoutSelection ?
  47. getData._stringify( root, null, stringifyOptions ) :
  48. getData._stringify( root, document.selection, stringifyOptions );
  49. }
  50. // Set stringify as getData private method - needed for testing/spying.
  51. getData._stringify = stringify;
  52. /**
  53. * Sets the contents of the {@link engine.view.Document Document} provided as HTML-like string.
  54. *
  55. * @param {engine.view.Document} document
  56. * @param {String} data HTML-like string to write into Document.
  57. * @param {Object} options
  58. * @param {String} [options.rootName='main'] Root name where parsed data will be stored. If not provided,
  59. * default `main` name will be used.
  60. */
  61. export function setData( document, data, options = {} ) {
  62. if ( !( document instanceof Document ) ) {
  63. throw new TypeError( 'Document needs to be an instance of engine.view.Document.' );
  64. }
  65. const rootName = options.rootName || 'main';
  66. const root = document.getRoot( rootName );
  67. const result = setData._parse( data, { rootElement: root } );
  68. if ( result.view && result.selection ) {
  69. document.selection.setTo( result.selection );
  70. }
  71. }
  72. // Set parse as setData private method - needed for testing/spying.
  73. setData._parse = parse;
  74. /**
  75. * Converts view elements to HTML-like string representation.
  76. * Root element can be provided as {@link engine.view.Text Text}:
  77. *
  78. * const text = new Text( 'foobar' );
  79. * stringify( text ); // 'foobar'
  80. *
  81. * or as {@link engine.view.Element Element}:
  82. *
  83. * const element = new Element( 'p', null, new Text( 'foobar' ) );
  84. * stringify( element ); // '<p>foobar</p>'
  85. *
  86. * or as {@link engine.view.DocumentFragment DocumentFragment}:
  87. *
  88. * const text = new Text( 'foobar' );
  89. * const b = new Element( 'b', { name: 'test' }, text );
  90. * const p = new Element( 'p', { style: 'color:red;' } );
  91. * const fragment = new DocumentFragment( [ p, b ] );
  92. *
  93. * stringify( fragment ); // '<p style="color:red;"></p><b name="test">foobar</b>'
  94. *
  95. * Additionally {@link engine.view.Selection Selection} instance can be provided, then ranges from that selection
  96. * will be included in output data.
  97. * If range position is placed inside element node, it will be represented with `[` and `]`:
  98. *
  99. * const text = new Text( 'foobar' );
  100. * const b = new Element( 'b', null, text );
  101. * const p = new Element( 'p', null, b );
  102. * const selection = new Selection();
  103. * selection.addRange( Range.createFromParentsAndOffsets( p, 0, p, 1 ) );
  104. *
  105. * stringify( p, selection ); // '<p>[<b>foobar</b>]</p>'
  106. *
  107. * If range is placed inside text node, it will be represented with `{` and `}`:
  108. *
  109. * const text = new Text( 'foobar' );
  110. * const b = new Element( 'b', null, text );
  111. * const p = new Element( 'p', null, b );
  112. * const selection = new Selection();
  113. * selection.addRange( Range.createFromParentsAndOffsets( text, 1, text, 5 ) );
  114. *
  115. * stringify( p, selection ); // '<p><b>f{ooba}r</b></p>'
  116. *
  117. * ** Note: **
  118. * It is possible to unify selection markers to `[` and `]` for both (inside and outside text)
  119. * by setting `sameSelectionCharacters=true` option. It is mainly used when view stringify option is used by model utils.
  120. *
  121. * Multiple ranges are supported:
  122. *
  123. * const text = new Text( 'foobar' );
  124. * const selection = new Selection();
  125. * selection.addRange( Range.createFromParentsAndOffsets( text, 0, text, 1 ) );
  126. * selection.addRange( Range.createFromParentsAndOffsets( text, 3, text, 5 ) );
  127. *
  128. * stringify( text, selection ); // '{f}oo{ba}r'
  129. *
  130. * Instead of {@link engine.view.Selection Selection} instance {@link engine.view.Range Range} or
  131. * {@link engine.view.Position Position} instance can be provided. If Range instance is provided - it will be
  132. * converted to selection containing this range. If Position instance is provided - it will be converted to selection
  133. * containing one range collapsed at this position.
  134. *
  135. * const text = new Text( 'foobar' );
  136. * const range = Range.createFromParentsAndOffsets( text, 0, text, 1 );
  137. * const position = new Position( text, 3 );
  138. *
  139. * stringify( text, range ); // '{f}oobar'
  140. * stringify( text, position ); // 'foo{}bar'
  141. *
  142. * Additional options object can be provided.
  143. * If `options.showType` is set to `true`, element's types will be
  144. * presented for {@link engine.view.AttributeElement AttributeElements} and {@link engine.view.ContainerElement
  145. * ContainerElements}:
  146. *
  147. * const attribute = new AttributeElement( 'b' );
  148. * const container = new ContainerElement( 'p' );
  149. * getData( attribute, null, { showType: true } ); // '<attribute:b></attribute:b>'
  150. * getData( container, null, { showType: true } ); // '<container:p></container:p>'
  151. *
  152. * If `options.showPriority` is set to `true`, priority will be displayed for all
  153. * {@link engine.view.AttributeElement AttributeElements}.
  154. *
  155. * const attribute = new AttributeElement( 'b' );
  156. * attribute.priority = 20;
  157. * getData( attribute, null, { showPriority: true } ); // <b view-priority="20"></b>
  158. *
  159. * @param {engine.view.Text|engine.view.Element|engine.view.DocumentFragment} node Node to stringify.
  160. * @param {engine.view.Selection|engine.view.Position|engine.view.Range} [selectionOrPositionOrRange = null ]
  161. * Selection instance which ranges will be included in returned string data. If Range instance is provided - it will be
  162. * converted to selection containing this range. If Position instance is provided - it will be converted to selection
  163. * containing one range collapsed at this position.
  164. * @param {Object} [options] Object with additional options.
  165. * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed (`<container:p>`
  166. * instead of `<p>` and `<attribute:b>` instead of `<b>`).
  167. * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed
  168. * (`<span view-priority="12">`, `<b view-priority="10">`).
  169. * @param {Boolean} [options.ignoreRoot=false] When set to `true` root's element opening and closing will not be printed.
  170. * Mainly used by `getData` function to ignore {@link engine.view.Document Document's} root element.
  171. * @param {Boolean} [options.sameSelectionCharacters=false] When set to `true` then selection inside text will be marked as `{` and `}`
  172. * and selection outside text as `[` and `]`. When set to `false` then both will be marked as `[` and `]` only.
  173. * @returns {String} HTML-like string representing the view.
  174. */
  175. export function stringify( node, selectionOrPositionOrRange = null, options = {} ) {
  176. let selection;
  177. if ( selectionOrPositionOrRange instanceof Position ) {
  178. selection = new Selection();
  179. selection.addRange( new Range( selectionOrPositionOrRange, selectionOrPositionOrRange ) );
  180. } else if ( selectionOrPositionOrRange instanceof Range ) {
  181. selection = new Selection();
  182. selection.addRange( selectionOrPositionOrRange );
  183. } else {
  184. selection = selectionOrPositionOrRange;
  185. }
  186. const viewStringify = new ViewStringify( node, selection, options );
  187. return viewStringify.stringify();
  188. }
  189. /**
  190. * Parses HTML-like string and returns view tree nodes.
  191. * Simple string will be converted to {@link engine.view.Text Text} node:
  192. *
  193. * parse( 'foobar' ); // Returns instance of Text.
  194. *
  195. * {@link engine.view.Element Elements} will be parsed with attributes an children:
  196. *
  197. * parse( '<b name="baz">foobar</b>' ); // Returns instance of Element with `baz` attribute and text child node.
  198. *
  199. * Multiple nodes provided on root level will be converted to {@link engine.view.DocumentFragment DocumentFragment}:
  200. *
  201. * parse( '<b>foo</b><i>bar</i>' ); // Returns DocumentFragment with two child elements.
  202. *
  203. * Method can parse multiple {@link engine.view.Range ranges} provided in string data and return
  204. * {@link engine.view.Selection Selection} instance containing these ranges. Ranges placed inside
  205. * {@link engine.view.Text Text} nodes should be marked using `{` and `}` brackets:
  206. *
  207. * const { text, selection } = parse( 'f{ooba}r' );
  208. *
  209. * Ranges placed outside text nodes should be marked using `[` and `]` brackets:
  210. *
  211. * const { root, selection } = parse( '<p>[<b>foobar</b>]</p>' );
  212. *
  213. * ** Note: **
  214. * It is possible to unify selection markers to `[` and `]` for both (inside and outside text)
  215. * by setting `sameSelectionCharacters=true` option. It is mainly used when view parse option is used by model utils.
  216. *
  217. * Sometimes there is a need for defining order of ranges inside created selection. This can be achieved by providing
  218. * ranges order array as additional parameter:
  219. *
  220. * const { root, selection } = parse( '{fo}ob{ar}{ba}z', { order: [ 2, 3, 1 ] } );
  221. *
  222. * In above example first range (`{fo}`) will be added to selection as second one, second range (`{ar}`) will be added
  223. * as third and third range (`{ba}`) will be added as first one.
  224. *
  225. * If selection's last range should be added as backward one (so the {@link engine.view.Selection#anchor selection
  226. * anchor} is represented by `end` position and {@link engine.view.Selection#focus selection focus} is
  227. * represented by `start` position) use `lastRangeBackward` flag:
  228. *
  229. * const { root, selection } = parse( `{foo}bar{baz}`, { lastRangeBackward: true } );
  230. *
  231. * Other examples and edge cases:
  232. *
  233. * // Returns empty DocumentFragment.
  234. * parse( '' );
  235. *
  236. * // Returns empty DocumentFragment and collapsed selection.
  237. * const { root, selection } = parse( '[]' );
  238. *
  239. * // Returns Element and selection that is placed inside of DocumentFragment containing that element.
  240. * const { root, selection } = parse( '[<a></a>]' );
  241. *
  242. * @param {String} data HTML-like string to be parsed.
  243. * @param {Object} options
  244. * @param {Array.<Number>} [options.order] Array with order of parsed ranges added to returned
  245. * {@link engine.view.Selection Selection} instance. Each element should represent desired position of each range in
  246. * selection instance. For example: `[2, 3, 1]` means that first range will be placed as second, second as third and third as first.
  247. * @param {Boolean} [options.lastRangeBackward=false] If set to true last range will be added as backward to the returned
  248. * {@link engine.view.Selection Selection} instance.
  249. * @param {engine.view.Element|engine.view.DocumentFragment} [options.rootElement=null] Default root to use when parsing elements.
  250. * When set to `null` root element will be created automatically. If set to
  251. * {@link engine.view.Element Element} or {@link engine.view.DocumentFragment DocumentFragment} - this node
  252. * will be used as root for all parsed nodes.
  253. * @param {Boolean} [options.sameSelectionCharacters=false] When set to `false` then selection inside text should be marked using
  254. * `{` and `}` and selection outside text using `[` and `]`. When set to `true` then both should be marked with `[` and `]` only.
  255. * @returns {engine.view.Text|engine.view.Element|engine.view.DocumentFragment|Object} Returns parsed view node
  256. * or object with two fields `view` and `selection` when selection ranges were included in data to parse.
  257. */
  258. export function parse( data, options = {} ) {
  259. options.order = options.order || [];
  260. const rangeParser = new RangeParser( {
  261. sameSelectionCharacters: options.sameSelectionCharacters
  262. } );
  263. const processor = new XmlDataProcessor( {
  264. namespaces: [ 'attribute', 'container' ]
  265. } );
  266. // Convert data to view.
  267. let view = processor.toView( data );
  268. // At this point we have a view tree with Elements that could have names like `attribute:b:1`. In the next step
  269. // we need to parse Element's names and convert them to AttributeElements and ContainerElements.
  270. view = _convertViewElements( view );
  271. // If custom root is provided - move all nodes there.
  272. if ( options.rootElement ) {
  273. const root = options.rootElement;
  274. const nodes = view.removeChildren( 0, view.childCount );
  275. root.removeChildren( 0, root.childCount );
  276. root.appendChildren( nodes );
  277. view = root;
  278. }
  279. // Parse ranges included in view text nodes.
  280. const ranges = rangeParser.parse( view, options.order );
  281. // If only one element is returned inside DocumentFragment - return that element.
  282. if ( view instanceof ViewDocumentFragment && view.childCount === 1 ) {
  283. view = view.getChild( 0 );
  284. }
  285. // When ranges are present - return object containing view, and selection.
  286. if ( ranges.length ) {
  287. const selection = new Selection();
  288. selection.setRanges( ranges, !!options.lastRangeBackward );
  289. return {
  290. view: view,
  291. selection: selection
  292. };
  293. }
  294. // If single element is returned without selection - remove it from parent and return detached element.
  295. if ( view.parent ) {
  296. view.remove();
  297. }
  298. return view;
  299. }
  300. /**
  301. * Private helper class used for converting ranges represented as text inside view {@link engine.view.Text Text nodes}.
  302. *
  303. * @private
  304. */
  305. class RangeParser {
  306. /**
  307. * Create RangeParser instance.
  308. *
  309. * @param {Object} options RangeParser configuration.
  310. * @param {Boolean} [options.sameSelectionCharacters=false] When set to `true` it means that selection inside text is marked as
  311. * `{` and `}` and selection outside text as `[` and `]`. When set to `false` then both are marked as `[` and `]`.
  312. */
  313. constructor( options ) {
  314. this.sameSelectionCharacters = !!options.sameSelectionCharacters;
  315. }
  316. /**
  317. * Parses the view, and returns ranges represented inside {@link engine.view.Text Text nodes}.
  318. * Method will remove all occurrences of `{`, `}`, `[` and `]` from found text nodes. If text node is empty after
  319. * the process - it will be removed too.
  320. *
  321. * @param {engine.view.Node} node Starting node.
  322. * @param {Array.<Number>} order Ranges order. Each element should represent desired position of the range after
  323. * sorting. For example: `[2, 3, 1]` means that first range will be placed as second, second as third and third as first.
  324. * @returns {Array.<engine.view.Range>} Array with ranges found.
  325. */
  326. parse( node, order ) {
  327. this._positions = [];
  328. // Remove all range brackets from view nodes and save their positions.
  329. this._getPositions( node );
  330. // Create ranges using gathered positions.
  331. let ranges = this._createRanges();
  332. // Sort ranges if needed.
  333. if ( order.length ) {
  334. if ( order.length != ranges.length ) {
  335. throw new Error(
  336. `Parse error - there are ${ ranges.length } ranges found, but ranges order array contains ${ order.length } elements.`
  337. );
  338. }
  339. ranges = this._sortRanges( ranges, order );
  340. }
  341. return ranges;
  342. }
  343. /**
  344. * Gathers positions of brackets inside view tree starting from provided node. Method will remove all occurrences of
  345. * `{`, `}`, `[` and `]` from found text nodes. If text node is empty after the process - it will be removed
  346. * too.
  347. *
  348. * @private
  349. * @param {engine.view.Node} node Staring node.
  350. */
  351. _getPositions( node ) {
  352. if ( node instanceof ViewDocumentFragment || node instanceof ViewElement ) {
  353. // Copy elements into the array, when nodes will be removed from parent node this array will still have all the
  354. // items needed for iteration.
  355. const children = [ ...node.getChildren() ];
  356. for ( let child of children ) {
  357. this._getPositions( child );
  358. }
  359. }
  360. if ( node instanceof ViewText ) {
  361. const regexp = new RegExp(
  362. `[${ TEXT_RANGE_START_TOKEN }${ TEXT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_START_TOKEN }]`,
  363. 'g'
  364. );
  365. let text = node.data;
  366. let match;
  367. let offset = 0;
  368. const brackets = [];
  369. // Remove brackets from text and store info about offset inside text node.
  370. while ( ( match = regexp.exec( text ) ) ) {
  371. const index = match.index;
  372. const bracket = match[ 0 ];
  373. brackets.push( {
  374. bracket: bracket,
  375. textOffset: index - offset,
  376. outer: index === 0 || index == node._data.length - 1
  377. } );
  378. offset++;
  379. }
  380. text = text.replace( regexp, '' );
  381. node.data = text;
  382. const index = node.index;
  383. const parent = node.parent;
  384. // Remove empty text nodes.
  385. if ( !text ) {
  386. node.remove();
  387. }
  388. for ( let item of brackets ) {
  389. // Non-empty text node.
  390. if ( text ) {
  391. if (
  392. ( this.sameSelectionCharacters && !item.outer ) ||
  393. ( !this.sameSelectionCharacters && ( item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) )
  394. ) {
  395. // Store information about text range delimiter.
  396. this._positions.push( {
  397. bracket: item.bracket,
  398. position: new Position( node, item.textOffset )
  399. } );
  400. } else {
  401. // Check if element range delimiter is not placed inside text node.
  402. if ( !this.sameSelectionCharacters && item.textOffset !== 0 && item.textOffset !== text.length ) {
  403. throw new Error( `Parse error - range delimiter '${ item.bracket }' is placed inside text node.` );
  404. }
  405. // If bracket is placed at the end of the text node - it should be positioned after it.
  406. const offset = ( item.textOffset === 0 ? index : index + 1 );
  407. // Store information about element range delimiter.
  408. this._positions.push( {
  409. bracket: item.bracket,
  410. position: new Position( parent, offset )
  411. } );
  412. }
  413. } else {
  414. if ( !this.sameSelectionCharacters && item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) {
  415. throw new Error( `Parse error - text range delimiter '${ item.bracket }' is placed inside empty text node. ` );
  416. }
  417. // Store information about element range delimiter.
  418. this._positions.push( {
  419. bracket: item.bracket,
  420. position: new Position( parent, index )
  421. } );
  422. }
  423. }
  424. }
  425. }
  426. /**
  427. * Sort ranges in given order. Ranges order should be an array, each element should represent desired position
  428. * of the range after sorting.
  429. * For example: `[2, 3, 1]` means that first range will be placed as second, second as third and third as first.
  430. *
  431. * @private
  432. * @param {Array.<engine.view.Range>} ranges Ranges to sort.
  433. * @param {Array.<Number>} rangesOrder Array with new ranges order.
  434. * @returns {Array} Sorted ranges array.
  435. */
  436. _sortRanges( ranges, rangesOrder ) {
  437. const sortedRanges = [];
  438. let index = 0;
  439. for ( let newPosition of rangesOrder ) {
  440. if ( ranges[ newPosition - 1 ] === undefined ) {
  441. throw new Error( 'Parse error - provided ranges order is invalid.' );
  442. }
  443. sortedRanges[ newPosition - 1 ] = ranges[ index ];
  444. index++;
  445. }
  446. return sortedRanges;
  447. }
  448. /**
  449. * Uses all found bracket positions to create ranges from them.
  450. *
  451. * @private
  452. * @returns {Array.<engine.view.Range>}
  453. */
  454. _createRanges() {
  455. const ranges = [];
  456. let range = null;
  457. for ( let item of this._positions ) {
  458. // When end of range is found without opening.
  459. if ( !range && ( item.bracket == ELEMENT_RANGE_END_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) ) {
  460. throw new Error( `Parse error - end of range was found '${ item.bracket }' but range was not started before.` );
  461. }
  462. // When second start of range is found when one is already opened - selection does not allow intersecting
  463. // ranges.
  464. if ( range && ( item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN ) ) {
  465. throw new Error( `Parse error - start of range was found '${ item.bracket }' but one range is already started.` );
  466. }
  467. if ( item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN ) {
  468. range = new Range( item.position, item.position );
  469. } else {
  470. range.end = item.position;
  471. ranges.push( range );
  472. range = null;
  473. }
  474. }
  475. // Check if all ranges have proper ending.
  476. if ( range !== null ) {
  477. throw new Error( 'Parse error - range was started but no end delimiter was found.' );
  478. }
  479. return ranges;
  480. }
  481. }
  482. /**
  483. * Private helper class used for converting view tree to string.
  484. *
  485. * @private
  486. */
  487. class ViewStringify {
  488. /**
  489. * Creates ViewStringify instance.
  490. *
  491. * @param root
  492. * @param {engine.view.Selection} [selection=null] Selection which ranges should be also converted to string.
  493. * @param {Object} [options] Options object.
  494. * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed ( `<container:p>`
  495. * instead of `<p>` and `<attribute:b>` instead of `<b>`.
  496. * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed.
  497. * @param {Boolean} [options.ignoreRoot=false] When set to `true` root's element opening and closing tag will not
  498. * be outputted.
  499. * @param {Boolean} [options.sameSelectionCharacters=false] When set to `true` it means that selection inside text is marked as
  500. * `{` and `}` and selection outside text as `[` and `]`. When set to `false` then both are marked as `[` and `]`.
  501. */
  502. constructor( root, selection = null, options = {} ) {
  503. this.root = root;
  504. this.selection = selection;
  505. this.ranges = [];
  506. if ( this.selection ) {
  507. this.ranges = [ ...selection.getRanges() ];
  508. }
  509. this.showType = !!options.showType;
  510. this.showPriority = !!options.showPriority;
  511. this.ignoreRoot = !!options.ignoreRoot;
  512. this.sameSelectionCharacters = !!options.sameSelectionCharacters;
  513. }
  514. /**
  515. * Converts view to string.
  516. *
  517. * @returns {String} String representation of the view elements.
  518. */
  519. stringify() {
  520. let result = '';
  521. this._walkView( this.root, ( chunk ) => {
  522. result += chunk;
  523. } );
  524. return result;
  525. }
  526. /**
  527. * Executes simple walker that iterates over all elements in the view tree starting from root element.
  528. * Calls `callback` with parsed chunks of string data.
  529. *
  530. * @private
  531. * @param {engine.view.DocumentFragment|engine.view.Element|engine.view.Text} root
  532. * @param {Function} callback
  533. */
  534. _walkView( root, callback ) {
  535. const isElement = root instanceof ViewElement;
  536. const ignore = this.ignoreRoot && this.root === root;
  537. if ( isElement || root instanceof ViewDocumentFragment ) {
  538. if ( isElement && !ignore ) {
  539. callback( this._stringifyElementOpen( root ) );
  540. }
  541. let offset = 0;
  542. callback( this._stringifyElementRanges( root, offset ) );
  543. for ( let child of root.getChildren() ) {
  544. this._walkView( child, callback );
  545. offset++;
  546. callback( this._stringifyElementRanges( root, offset ) );
  547. }
  548. if ( isElement && !ignore ) {
  549. callback( this._stringifyElementClose( root ) );
  550. }
  551. }
  552. if ( root instanceof ViewText ) {
  553. callback( this._stringifyTextRanges( root ) );
  554. }
  555. }
  556. /**
  557. * Checks if given {@link engine.view.Element Element} has {@link engine.view.Range#start range start} or
  558. * {@link engine.view.Range#start range end} placed at given offset and returns its string representation.
  559. *
  560. * @private
  561. * @param {engine.view.Element} element
  562. * @param {Number} offset
  563. */
  564. _stringifyElementRanges( element, offset ) {
  565. let start = '';
  566. let end = '';
  567. let collapsed = '';
  568. for ( let range of this.ranges ) {
  569. if ( range.start.parent == element && range.start.offset === offset ) {
  570. if ( range.isCollapsed ) {
  571. collapsed += ELEMENT_RANGE_START_TOKEN + ELEMENT_RANGE_END_TOKEN;
  572. } else {
  573. start += ELEMENT_RANGE_START_TOKEN;
  574. }
  575. }
  576. if ( range.end.parent === element && range.end.offset === offset && !range.isCollapsed ) {
  577. end += ELEMENT_RANGE_END_TOKEN;
  578. }
  579. }
  580. return end + collapsed + start;
  581. }
  582. /**
  583. * Checks if given {@link engine.view.Element Text node} has {@link engine.view.Range#start range start} or
  584. * {@link engine.view.Range#start range end} placed somewhere inside. Returns string representation of text
  585. * with range delimiters placed inside.
  586. *
  587. * @private
  588. * @param {engine.view.Text} node
  589. */
  590. _stringifyTextRanges( node ) {
  591. const length = node.data.length;
  592. let result = node.data.split( '' );
  593. let rangeStartToken, rangeEndToken;
  594. if ( this.sameSelectionCharacters ) {
  595. rangeStartToken = ELEMENT_RANGE_START_TOKEN;
  596. rangeEndToken = ELEMENT_RANGE_END_TOKEN;
  597. } else {
  598. rangeStartToken = TEXT_RANGE_START_TOKEN;
  599. rangeEndToken = TEXT_RANGE_END_TOKEN;
  600. }
  601. // Add one more element for ranges ending after last character in text.
  602. result[ length ] = '';
  603. // Represent each letter as object with information about opening/closing ranges at each offset.
  604. result = result.map( ( letter ) => {
  605. return {
  606. letter: letter,
  607. start: '',
  608. end: '',
  609. collapsed: ''
  610. };
  611. } );
  612. for ( let range of this.ranges ) {
  613. const start = range.start;
  614. const end = range.end;
  615. if ( start.parent == node && start.offset >= 0 && start.offset <= length ) {
  616. if ( range.isCollapsed ) {
  617. result[ end.offset ].collapsed += rangeStartToken + rangeEndToken;
  618. } else {
  619. result[ start.offset ].start += rangeStartToken;
  620. }
  621. }
  622. if ( end.parent == node && end.offset >= 0 && end.offset <= length && !range.isCollapsed ) {
  623. result[ end.offset ].end += rangeEndToken;
  624. }
  625. }
  626. return result.map( item => item.end + item.collapsed + item.start + item.letter ).join( '' );
  627. }
  628. /**
  629. * Converts passed {@link engine.view.Element Element} to opening tag.
  630. * Depending on current configuration opening tag can be simple (`<a>`), contain type prefix (`<container:p>` or
  631. * `<attribute:a>`), contain priority information ( `<attribute:a view-priority="20">` ). Element's attributes also
  632. * will be included (`<a href="http://ckeditor.com" name="foobar">`).
  633. *
  634. * @private
  635. * @param {engine.view.Element} element
  636. * @returns {String}
  637. */
  638. _stringifyElementOpen( element ) {
  639. const priority = this._stringifyElementPriority( element );
  640. const type = this._stringifyElementType( element );
  641. const name = [ type, element.name ].filter( i=> i !== '' ).join( ':' );
  642. const attributes = this._stringifyElementAttributes( element );
  643. const parts = [ name, priority, attributes ];
  644. return `<${ parts.filter( i => i !== '' ).join( ' ' ) }>`;
  645. }
  646. /**
  647. * Converts passed {@link engine.view.Element Element} to closing tag.
  648. * Depending on current configuration closing tag can be simple (`</a>`) or contain type prefix (`</container:p>` or
  649. * `</attribute:a>`).
  650. *
  651. * @private
  652. * @param {engine.view.Element} element
  653. * @returns {String}
  654. */
  655. _stringifyElementClose( element ) {
  656. const type = this._stringifyElementType( element );
  657. const name = [ type, element.name ].filter( i=> i !== '' ).join( ':' );
  658. return `</${ name }>`;
  659. }
  660. /**
  661. * Converts passed {@link engine.view.Element Element's} type to its string representation
  662. * Returns 'attribute' for {@link engine.view.AttributeElement AttributeElements} and
  663. * 'container' for {@link engine.view.ContainerElement ContainerElements}. Returns empty string when current
  664. * configuration is preventing showing elements' types.
  665. *
  666. * @private
  667. * @param {engine.view.Element} element
  668. * @returns {String}
  669. */
  670. _stringifyElementType( element ) {
  671. if ( this.showType ) {
  672. if ( element instanceof AttributeElement ) {
  673. return 'attribute';
  674. }
  675. if ( element instanceof ContainerElement ) {
  676. return 'container';
  677. }
  678. }
  679. return '';
  680. }
  681. /**
  682. * Converts passed {@link engine.view.Element Element} to its priority representation.
  683. * Priority string representation will be returned when passed element is an instance of
  684. * {@link engine.view.AttributeElement AttributeElement} and current configuration allow to show priority.
  685. * Otherwise returns empty string.
  686. *
  687. * @private
  688. * @param {engine.view.Element} element
  689. * @returns {String}
  690. */
  691. _stringifyElementPriority( element ) {
  692. if ( this.showPriority && element instanceof AttributeElement ) {
  693. return `view-priority="${ element.priority }"`;
  694. }
  695. return '';
  696. }
  697. /**
  698. * Converts passed {@link engine.view.Element Element} attributes to their string representation.
  699. * If element has no attributes - empty string is returned.
  700. *
  701. * @private
  702. * @param {engine.view.Element} element
  703. * @returns {String}
  704. */
  705. _stringifyElementAttributes( element ) {
  706. const attributes = [];
  707. const keys = [ ...element.getAttributeKeys() ].sort();
  708. for ( let attribute of keys ) {
  709. attributes.push( `${ attribute }="${ element.getAttribute( attribute ) }"` );
  710. }
  711. return attributes.join( ' ' );
  712. }
  713. }
  714. // Converts {@link engine.view.Element Elements} to {@link engine.view.AttributeElement AttributeElements} and
  715. // {@link engine.view.ContainerElement ContainerElements}. It converts whole tree starting from the `rootNode`.
  716. // Conversion is based on element names. See `_convertElement` method for more details.
  717. //
  718. // @param {engine.view.Element|engine.view.DocumentFragment|engine.view.Text} rootNode Root node to convert.
  719. // @returns {engine.view.Element|engine.view.DocumentFragment|engine.view.Text|engine.view.AttributeElement|
  720. // engine.view.ContainerElement} Root node of converted elements.
  721. function _convertViewElements( rootNode ) {
  722. const isFragment = rootNode instanceof ViewDocumentFragment;
  723. if ( rootNode instanceof ViewElement || isFragment ) {
  724. // Convert element or leave document fragment.
  725. const convertedElement = isFragment ? new ViewDocumentFragment() : _convertElement( rootNode );
  726. // Convert all child nodes.
  727. for ( let child of rootNode.getChildren() ) {
  728. convertedElement.appendChildren( _convertViewElements( child ) );
  729. }
  730. return convertedElement;
  731. }
  732. return rootNode;
  733. }
  734. // Converts {@link engine.view.Element Element} to {@link engine.view.AttributeElement AttributeElement} or
  735. // {@link engine.view.ContainerElement ContainerElement}.
  736. // If element's name is in format `attribute:b` with `view-priority="11"` attribute it will be converted to
  737. // {@link engine.view.AttributeElement AttributeElement} with priority 11.
  738. // If element's name is in format `container:p` - it will be converted to
  739. // {@link engine.view.ContainerElement ContainerElement}.
  740. // If element's name will not contain any additional information - {@link engine.view.Element view Element} will be
  741. // returned.
  742. //
  743. // @param {engine.view.Element} viewElement View element to convert.
  744. // @returns {engine.view.Element|engine.view.AttributeElement|engine.view.ContainerElement} Tree view
  745. // element converted according to it's name.
  746. function _convertElement( viewElement ) {
  747. let newElement;
  748. const info = _convertElementNameAndPriority( viewElement );
  749. if ( info.type == 'attribute' ) {
  750. newElement = new AttributeElement( info.name );
  751. if ( info.priority !== null ) {
  752. newElement.priority = info.priority;
  753. }
  754. } else if ( info.type == 'container' ) {
  755. newElement = new ContainerElement( info.name );
  756. } else {
  757. newElement = new ViewElement( info.name );
  758. }
  759. // Move attributes.
  760. for ( let attributeKey of viewElement.getAttributeKeys() ) {
  761. newElement.setAttribute( attributeKey, viewElement.getAttribute( attributeKey ) );
  762. }
  763. return newElement;
  764. }
  765. // Converts `view-priority` attribute and {@link engine.view.Element#name Element's name} information needed for creating
  766. // {@link engine.view.AttributeElement AttributeElement} or {@link engine.view.ContainerElement ContainerElement} instance.
  767. // Name can be provided in two formats: as a simple element's name (`div`), or as a type and name (`container:div`,
  768. // `attribute:span`);
  769. //
  770. // @param {engine.view.Element} element Element which name should be converted.
  771. // @returns {Object} info Object with parsed information.
  772. // @returns {String} info.name Parsed name of the element.
  773. // @returns {String|null} info.type Parsed type of the element, can be `attribute` or `container`.
  774. // returns {Number|null} info.priority Parsed priority of the element.
  775. function _convertElementNameAndPriority( viewElement ) {
  776. const parts = viewElement.name.split( ':' );
  777. const priority = _convertPriority( viewElement.getAttribute( 'view-priority' ) );
  778. viewElement.removeAttribute( 'view-priority' );
  779. if ( parts.length == 1 ) {
  780. return {
  781. name: parts[ 0 ],
  782. type: priority !== null ? 'attribute' : null,
  783. priority: priority
  784. };
  785. }
  786. // Check if type and name: container:div.
  787. const type = _convertType( parts[ 0 ] );
  788. if ( type ) {
  789. return {
  790. name: parts[ 1 ],
  791. type: type,
  792. priority: priority
  793. };
  794. }
  795. throw new Error( `Parse error - cannot parse element's name: ${ viewElement.name }.` );
  796. }
  797. // Checks if element's type is allowed. Returns `attribute`, `container` or `null`.
  798. //
  799. // @param {String} type
  800. // @returns {String|null}
  801. function _convertType( type ) {
  802. if ( type == 'container' || type == 'attribute' ) {
  803. return type;
  804. }
  805. return null;
  806. }
  807. // Checks if given priority is allowed. Returns null if priority cannot be converted.
  808. //
  809. // @param {String} priorityString
  810. // returns {Number|Null}
  811. function _convertPriority( priorityString ) {
  812. const priority = parseInt( priorityString, 10 );
  813. if ( !isNaN( priority ) ) {
  814. return priority;
  815. }
  816. return null;
  817. }