view.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. 'use strict';
  6. /* jshint latedef:false */
  7. import ViewDocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
  8. import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
  9. import ViewElement from '/ckeditor5/engine/treeview/element.js';
  10. import Selection from '/ckeditor5/engine/treeview/selection.js';
  11. import Range from '/ckeditor5/engine/treeview/range.js';
  12. import Position from '/ckeditor5/engine/treeview/position.js';
  13. import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
  14. import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
  15. import ViewText from '/ckeditor5/engine/treeview/text.js';
  16. const DomDocumentFragment = window.DocumentFragment;
  17. const DomElement = window.Element;
  18. const ELEMENT_RANGE_START_TOKEN = '[';
  19. const ELEMENT_RANGE_END_TOKEN = ']';
  20. const TEXT_RANGE_START_TOKEN = '{';
  21. const TEXT_RANGE_END_TOKEN = '}';
  22. /**
  23. * Converts view elements to its string representation, an HTML-like string.
  24. * Root element can be provided as {@link engine.treeView.Element Element} or
  25. * {@link engine.treeView.DocumentFragment DocumentFragment}.
  26. *
  27. * const text = new Text( 'foobar' );
  28. * const b = new Element( 'b', { name: 'test' }, text );
  29. * const p = new Element( 'p', { style: 'color:red;' }, b );
  30. *
  31. * getData( p ); // <p style="color:red;"><b name="test">foobar</b></p>
  32. *
  33. * Additionally {@link engine.treeView.Selection Selection}
  34. * instance can be provided, then ranges from that selection will be converted too. If range position is placed inside
  35. * element node `[` and `]` will be used there.
  36. *
  37. * const text = new Text( 'foobar' );
  38. * const b = new Element( 'b', null, text );
  39. * const p = new Element( 'p', null, b );
  40. * const selection = new Selection();
  41. * selection.addRange( Range.createFromParentsAndOffsets( p, 0, p, 1 ) );
  42. *
  43. * getData( p, selection ); // <p>[<b>foobar</b>]</p>
  44. *
  45. * If range is placed inside text node `{` and `}` will be used there.
  46. *
  47. * const text = new Text( 'foobar' );
  48. * const b = new Element( 'b', null, text );
  49. * const p = new Element( 'p', null, b );
  50. * const selection = new Selection();
  51. * selection.addRange( Range.createFromParentsAndOffsets( text, 1, text, 5 ) );
  52. *
  53. * getData( p, selection ); // <p><b>f{ooba}r</b></p>
  54. *
  55. * Additional options object can be provided.
  56. * If `options.showType` property is set to `true` element types will be
  57. * presented for {@link engine.treeView.AttributeElement AttributeElements} and {@link engine.treeView.ContainerElement
  58. * ContainerElements}.
  59. *
  60. * const attribute = new AttributeElement( 'b' );
  61. * const container = new ContainerElement( 'p' );
  62. * getData( attribute, null, { showType: true } ); // <attribute:b></attribute:b>
  63. * getData( container, null, { showType: true } ); // <container:p></container:p>
  64. *
  65. * if `options.showPriority` property is set to `true`, priority will be displayed for all
  66. * {@link engine.treeView.AttributeElement AttributeElements}.
  67. *
  68. * const attribute = new AttributeElement( 'b' );
  69. * attribute.priority = 20;
  70. * getData( attribute, null, { showPriority: true } ); // <b priority=20></b>
  71. *
  72. * @param {engine.treeView.Node} node
  73. * @param {engine.treeView.Selection} [selection]
  74. * @param {Object} [options]
  75. * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed ( `<container:p>`
  76. * instead of `<p>` and `<attribute:b>` instead of `<b>`.
  77. * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed.
  78. * @returns {String}
  79. */
  80. export function stringify( node, selection, options = {} ) {
  81. const viewStringify = new ViewStringify( node, selection, options );
  82. return viewStringify.stringify();
  83. }
  84. export function parse( data, options = { } ) {
  85. options.order = options.order || [];
  86. const viewParser = new ViewParser();
  87. const rangeParser = new RangeParser();
  88. const view = viewParser.parse( data );
  89. const ranges = rangeParser.parse( view, options.order );
  90. // When ranges are present - return object containing view, and selection.
  91. if ( ranges.length ) {
  92. const selection = new Selection();
  93. selection.setRanges( ranges, !!options.lastRangeBackward );
  94. return {
  95. view: view,
  96. selection: selection
  97. };
  98. }
  99. return view;
  100. }
  101. class RangeParser {
  102. constructor() {
  103. // Todo - set in parse method.
  104. this._positions = [];
  105. }
  106. parse( node, order ) {
  107. this._getPositions( node );
  108. let ranges = this._createRanges();
  109. // Sort ranges if needed.
  110. if ( order.length ) {
  111. if ( order.length != ranges.length ) {
  112. throw new Error(
  113. `There are ${ ranges.length} ranges found, but ranges order array contains ${ order.length } elements.`
  114. );
  115. }
  116. ranges = this._sortRanges( ranges, order );
  117. }
  118. return ranges;
  119. }
  120. _sortRanges( ranges, rangesOrder ) {
  121. const sortedRanges = [];
  122. let index = 0;
  123. for ( let newPosition of rangesOrder ) {
  124. if ( ranges[ newPosition - 1 ] === undefined ) {
  125. throw new Error( 'Provided ranges order is invalid.' );
  126. }
  127. sortedRanges[ newPosition - 1] = ranges[ index ];
  128. index++;
  129. }
  130. return sortedRanges;
  131. }
  132. _getPositions( node ) {
  133. if ( node instanceof ViewDocumentFragment || node instanceof ViewElement ) {
  134. // Copy elements into the array, when items will be removed from node this array will still have all the
  135. // items needed for iteration.
  136. const children = Array.from( node.getChildren() );
  137. for ( let child of children ) {
  138. this._getPositions( child );
  139. }
  140. }
  141. if ( node instanceof ViewText ) {
  142. const regexp = new RegExp(
  143. `[ ${TEXT_RANGE_START_TOKEN}${TEXT_RANGE_END_TOKEN}\\${ELEMENT_RANGE_END_TOKEN}\\${ELEMENT_RANGE_START_TOKEN} ]`,
  144. 'g'
  145. );
  146. let text = node.data;
  147. let match;
  148. let offset = 0;
  149. const brackets = [];
  150. // Remove brackets from text and store info about offset inside text node.
  151. while ( ( match = regexp.exec( text ) ) ) {
  152. const index = match.index;
  153. const bracket = match[ 0 ];
  154. brackets.push( {
  155. bracket: bracket,
  156. textOffset: index - offset
  157. } );
  158. offset++;
  159. }
  160. text = text.replace( regexp, '' );
  161. node.data = text;
  162. const index = node.getIndex();
  163. const parent = node.parent;
  164. // Remove empty text nodes.
  165. if ( !text ) {
  166. node.remove();
  167. }
  168. for ( let item of brackets ) {
  169. // Non-empty text node.
  170. if ( text ) {
  171. if ( item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) {
  172. // Store information about text range delimiter.
  173. this._positions.push( {
  174. bracket: item.bracket,
  175. position: new Position( node, item.textOffset )
  176. } );
  177. } else {
  178. // Check if element range delimiter is not placed inside text node.
  179. if ( item.textOffset !== 0 && item.textOffset !== text.length ) {
  180. throw new Error( `Range delimiter '${ item.bracket }' is placed inside text node.` );
  181. }
  182. // If bracket is placed at the end of the text node - it should be positioned after it.
  183. const offset = ( item.textOffset === 0 ? index : index + 1 );
  184. // Store information about element range delimiter.
  185. this._positions.push( {
  186. bracket: item.bracket,
  187. position: new Position( parent, offset )
  188. } );
  189. }
  190. } else {
  191. if ( item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) {
  192. throw new Error( `Text range delimiter '${ item.bracket }' is placed inside empty text node. ` );
  193. }
  194. // Store information about element range delimiter.
  195. this._positions.push( {
  196. bracket: item.bracket,
  197. position: new Position( parent, index )
  198. } );
  199. }
  200. }
  201. }
  202. }
  203. _createRanges() {
  204. const ranges = [];
  205. let range = null;
  206. for ( let item of this._positions ) {
  207. // When end of range is found without opening.
  208. if ( !range && ( item.bracket == ELEMENT_RANGE_END_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) ) {
  209. throw new Error( `End of range was found '${ item.bracket }' but range was not started before.` );
  210. }
  211. // When second start of range is found when one is already opened - selection does not allow intersecting
  212. // ranges.
  213. if ( range && ( item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN ) ) {
  214. throw new Error( `Start of range was found '${ item.bracket }' but one range is already started.` );
  215. }
  216. if ( item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN ) {
  217. range = new Range( item.position, item.position );
  218. } else {
  219. range.end = item.position;
  220. ranges.push( range );
  221. range = null;
  222. }
  223. }
  224. // Check if all ranges have proper ending.
  225. if ( range !== null ) {
  226. throw new Error( 'Range was started but no end delimiter was found.' );
  227. }
  228. return ranges;
  229. }
  230. }
  231. class ViewParser {
  232. parse( data ) {
  233. const htmlProcessor = new HtmlDataProcessor();
  234. const domRoot = htmlProcessor.toDom( data );
  235. return this._walkDom( domRoot );
  236. }
  237. _walkDom( domNode ) {
  238. const isDomElement = domNode instanceof DomElement;
  239. if ( isDomElement || domNode instanceof DomDocumentFragment ) {
  240. const children = domNode.childNodes;
  241. const length = children.length;
  242. // If there is only one element inside DOM DocumentFragment - use it as root.
  243. if ( !isDomElement && length == 1 ) {
  244. return this._walkDom( domNode.childNodes[ 0 ] );
  245. }
  246. let viewElement;
  247. if ( isDomElement ) {
  248. viewElement = this._convertElement( domNode );
  249. } else {
  250. viewElement = new ViewDocumentFragment();
  251. }
  252. for ( let i = 0; i < children.length; i++ ) {
  253. const child = children[ i ];
  254. viewElement.appendChildren( this._walkDom( child ) );
  255. }
  256. return viewElement;
  257. }
  258. return new ViewText( domNode.textContent );
  259. }
  260. _convertElement( domElement ) {
  261. const info = this._convertElementName( domElement );
  262. let viewElement;
  263. if ( info.type == 'attribute' ) {
  264. viewElement = new AttributeElement( info.name );
  265. if ( info.priority !== null ) {
  266. viewElement.priority = info.priority;
  267. }
  268. } else if ( info.type == 'container' ) {
  269. viewElement = new ContainerElement( info.name );
  270. } else {
  271. viewElement = new ViewElement( info.name );
  272. }
  273. const attributes = domElement.attributes;
  274. const attributesCount = attributes.length;
  275. for ( let i = 0; i < attributesCount; i++ ) {
  276. const attribute = attributes[ i ];
  277. viewElement.setAttribute( attribute.name, attribute.value );
  278. }
  279. return viewElement;
  280. }
  281. _convertElementName( element ) {
  282. const parts = element.tagName.toLowerCase().split( ':' );
  283. if ( parts.length == 1 ) {
  284. return {
  285. name: parts[ 0 ],
  286. type: null,
  287. priority: null
  288. };
  289. }
  290. if ( parts.length == 2 ) {
  291. // Check if type and name: container:div.
  292. const type = this._convertType( parts[ 0 ] );
  293. if ( type ) {
  294. return {
  295. name: parts[ 1 ],
  296. type: type,
  297. priority: null
  298. };
  299. }
  300. // Check if name and priority: span:10.
  301. const priority = this._convertPriority( parts[ 1 ] );
  302. if ( priority !== null ) {
  303. return {
  304. name: parts[ 0 ],
  305. type: 'attribute',
  306. priority: priority
  307. };
  308. }
  309. throw new Error( `Cannot parse element's tag name: ${ element.tagName.toLowerCase() }.` );
  310. }
  311. // Check if name is in format type:name:priority.
  312. if ( parts.length === 3 ) {
  313. const type = this._convertType( parts[ 0 ] );
  314. const priority = this._convertPriority( parts[ 2 ] );
  315. if ( type && priority !== null ) {
  316. return {
  317. name: parts[ 1 ],
  318. type: type,
  319. priority: priority
  320. };
  321. }
  322. }
  323. throw new Error( `Cannot parse element's tag name: ${ element.tagName.toLowerCase() }.` );
  324. }
  325. _convertType( type ) {
  326. if ( type == 'container' || type == 'attribute' ) {
  327. return type;
  328. }
  329. return null;
  330. }
  331. _convertPriority( priorityString ) {
  332. const priority = parseInt( priorityString, 10 );
  333. if ( !isNaN( priority ) ) {
  334. return priority;
  335. }
  336. return null;
  337. }
  338. }
  339. /**
  340. * Private helper class used for converting view tree to string.
  341. *
  342. * @private
  343. */
  344. class ViewStringify {
  345. /**
  346. * Creates ViewStringify instance.
  347. * @param root
  348. * @param {engine.treeView.Selection} [selection=null] Selection which ranges should be also converted to string.
  349. * @param {Object} [options] Options object.
  350. * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed ( `<container:p>`
  351. * instead of `<p>` and `<attribute:b>` instead of `<b>`.
  352. * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed.
  353. */
  354. constructor( root, selection = null, options = {} ) {
  355. this.root = root;
  356. this.selection = selection;
  357. this.ranges = [];
  358. if ( this.selection ) {
  359. this.ranges = [ ...selection.getRanges() ];
  360. }
  361. this.showType = !!options.showType;
  362. this.showPriority = !!options.showPriority;
  363. }
  364. /**
  365. * Converts view to string.
  366. *
  367. * @returns {string} String representation of the view elements.
  368. */
  369. stringify() {
  370. let result = '';
  371. this._walkView( this.root, ( chunk ) => {
  372. result += chunk;
  373. } );
  374. return result;
  375. }
  376. /**
  377. * Executes simple walker that iterates over all elements in the view tree starting from root element.
  378. * Calls `callback` with parsed chunks of string data.
  379. *
  380. * @private
  381. * @param {engine.treeView.DocumentFragment|engine.treeView.Element|engine.treeView.Text} root
  382. * @param {Function} callback
  383. */
  384. _walkView( root, callback ) {
  385. const isElement = root instanceof ViewElement;
  386. if ( isElement || root instanceof ViewDocumentFragment ) {
  387. if ( isElement ) {
  388. callback( this._stringifyElementOpen( root ) );
  389. }
  390. let offset = 0;
  391. callback( this._stringifyElementRanges( root, offset ) );
  392. for ( let child of root.getChildren() ) {
  393. this._walkView( child, callback );
  394. offset++;
  395. callback( this._stringifyElementRanges( root, offset ) );
  396. }
  397. if ( isElement ) {
  398. callback( this._stringifyElementClose( root ) );
  399. }
  400. }
  401. if ( root instanceof ViewText ) {
  402. callback( this._stringifyTextRanges( root ) );
  403. }
  404. }
  405. /**
  406. * Checks if given {@link engine.treeView.Element Element} has {@link engine.treeView.Range#start range start} or
  407. * {@link engine.treeView.Range#start range end} placed at given offset and returns its string representation.
  408. *
  409. * @private
  410. * @param {engine.treeView.Element} element
  411. * @param {Number} offset
  412. */
  413. _stringifyElementRanges( element, offset ) {
  414. let start = '';
  415. let end = '';
  416. let collapsed = '';
  417. for ( let range of this.ranges ) {
  418. if ( range.start.parent == element && range.start.offset === offset ) {
  419. if ( range.isCollapsed ) {
  420. collapsed += ELEMENT_RANGE_START_TOKEN + ELEMENT_RANGE_END_TOKEN;
  421. } else {
  422. start += ELEMENT_RANGE_START_TOKEN;
  423. }
  424. }
  425. if ( range.end.parent === element && range.end.offset === offset && !range.isCollapsed ) {
  426. end += ELEMENT_RANGE_END_TOKEN;
  427. }
  428. }
  429. return end + collapsed + start;
  430. }
  431. /**
  432. * Checks if given {@link engine.treeView.Element Text node} has {@link engine.treeView.Range#start range start} or
  433. * {@link engine.treeView.Range#start range end} placed somewhere inside. Returns string representation of text
  434. * with range delimiters placed inside.
  435. *
  436. * @private
  437. * @param {engine.treeView.Text} node
  438. */
  439. _stringifyTextRanges( node ) {
  440. const length = node.data.length;
  441. let result = node.data.split( '' );
  442. // Add one more element for ranges ending after last character in text.
  443. result[ length ] = '';
  444. // Represent each letter as object with information about opening/closing ranges at each offset.
  445. result = result.map( ( letter ) => {
  446. return {
  447. letter: letter,
  448. start: '',
  449. end: '',
  450. collapsed: ''
  451. };
  452. } );
  453. for ( let range of this.ranges ) {
  454. const start = range.start;
  455. const end = range.end;
  456. if ( start.parent == node && start.offset >= 0 && start.offset <= length ) {
  457. if ( range.isCollapsed ) {
  458. result[ end.offset ].collapsed += TEXT_RANGE_START_TOKEN + TEXT_RANGE_END_TOKEN;
  459. } else {
  460. result[ start.offset ].start += TEXT_RANGE_START_TOKEN;
  461. }
  462. }
  463. if ( end.parent == node && end.offset >= 0 && end.offset <= length && !range.isCollapsed ) {
  464. result[ end.offset ].end += TEXT_RANGE_END_TOKEN;
  465. }
  466. }
  467. return result.map( item => item.end + item.collapsed + item.start + item.letter ).join( '' );
  468. }
  469. /**
  470. * Converts passed {@link engine.treeView.Element Element} to opening tag.
  471. * Depending on current configuration opening tag can be simple (`<a>`), contain type prefix (`<container:p>` or
  472. * `<attribute:a>`), contain priority information ( `<attribute:a priority=20>` ). Element's attributes also
  473. * will be included (`<a href="http://ckeditor.com" name="foobar">`).
  474. *
  475. * @private
  476. * @param {engine.treeView.Element} element
  477. * @returns {string}
  478. */
  479. _stringifyElementOpen( element ) {
  480. const priority = this._stringifyElementPriority( element );
  481. const type = this._stringifyElementType( element );
  482. const name = [ type, element.name, priority ].filter( i=> i !== '' ).join( ':' );
  483. const attributes = this._stringifyElementAttributes( element );
  484. const parts = [ name, attributes ];
  485. return `<${ parts.filter( i => i !== '' ).join( ' ' ) }>`;
  486. }
  487. /**
  488. * Converts passed {@link engine.treeView.Element Element} to closing tag.
  489. * Depending on current configuration opening tag can be simple (`</a>`) or contain type prefix (`</container:p>` or
  490. * `</attribute:a>`).
  491. *
  492. * @private
  493. * @param {engine.treeView.Element} element
  494. * @returns {string}
  495. */
  496. _stringifyElementClose( element ) {
  497. const priority = this._stringifyElementPriority( element );
  498. const type = this._stringifyElementType( element );
  499. const name = [ type, element.name, priority ].filter( i=> i !== '' ).join( ':' );
  500. return `</${ name }>`;
  501. }
  502. /**
  503. * Converts passed {@link engine.treeView.Element Element's} type to its string representation
  504. * Returns 'attribute' for {@link engine.treeView.AttributeElement AttributeElements} and
  505. * 'container' for {@link engine.treeView.ContainerElement ContainerElements}. Returns empty string when current
  506. * configuration is preventing showing elements' types.
  507. *
  508. * @private
  509. * @param {engine.treeView.Element} element
  510. * @returns {string}
  511. */
  512. _stringifyElementType( element ) {
  513. if ( this.showType ) {
  514. if ( element instanceof AttributeElement ) {
  515. return 'attribute';
  516. }
  517. if ( element instanceof ContainerElement ) {
  518. return 'container';
  519. }
  520. }
  521. return '';
  522. }
  523. /**
  524. * Converts passed {@link engine.treeView.Element Element} to its priority representation.
  525. * Priority string representation will be returned when passed element is an instance of
  526. * {@link engine.treeView.AttributeElement AttributeElement} and current configuration allow to show priority.
  527. * Otherwise returns empty string.
  528. *
  529. * @private
  530. * @param {engine.treeView.Element} element
  531. * @returns {string}
  532. */
  533. _stringifyElementPriority( element ) {
  534. if ( this.showPriority && element instanceof AttributeElement ) {
  535. return element.priority;
  536. }
  537. return '';
  538. }
  539. /**
  540. * Converts passed {@link engine.treeView.Element Element} attributes to their string representation.
  541. * If element has no attributes - empty string is returned.
  542. *
  543. * @private
  544. * @param {engine.treeView.Element} element
  545. * @returns {string}
  546. */
  547. _stringifyElementAttributes( element ) {
  548. const attributes = [];
  549. // TODO: Maybe attributes should be put in alphabetical order, it might be easier to write expected string.
  550. for ( let attribute of element.getAttributeKeys() ) {
  551. attributes.push( `${ attribute }="${ element.getAttribute( attribute ) }"` );
  552. }
  553. return attributes.join( ' ' );
  554. }
  555. }