8
0

view.js 42 KB

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