model.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. 'use strict';
  6. import TreeWalker from '/ckeditor5/engine/model/treewalker.js';
  7. import Range from '/ckeditor5/engine/model/range.js';
  8. import Position from '/ckeditor5/engine/model/position.js';
  9. import Text from '/ckeditor5/engine/model/text.js';
  10. import RootElement from '/ckeditor5/engine/model/rootelement.js';
  11. import Element from '/ckeditor5/engine/model/element.js';
  12. import DocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
  13. import Selection from '/ckeditor5/engine/model/selection.js';
  14. import Document from '/ckeditor5/engine/model/document.js';
  15. import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
  16. /**
  17. * Writes the contents of the {@link engine.model.Document Document} to an HTML-like string.
  18. *
  19. * @param {engine.model.Document} document
  20. * @param {Object} [options]
  21. * @param {Boolean} [options.withoutSelection=false] Whether to write the selection. When set to `true` selection will
  22. * be not included in returned string.
  23. * @param {Boolean} [options.rootName='main'] Name of the root from which data should be stringified. If not provided
  24. * default `main` name will be used.
  25. * @returns {String} The stringified data.
  26. */
  27. export function getData( document, options = {} ) {
  28. if ( !( document instanceof Document ) ) {
  29. throw new CKEditorError(
  30. 'model-getData-invalid-document: document needs to be an instance of Document class',
  31. { document }
  32. );
  33. }
  34. const withoutSelection = !!options.withoutSelection;
  35. const rootName = options.rootName || 'main';
  36. const root = document.getRoot( rootName );
  37. return withoutSelection ? getData._stringify( root ) : getData._stringify( root, document.selection );
  38. }
  39. // Set stringify as getData private method - needed for testing/spying.
  40. getData._stringify = stringify;
  41. /**
  42. * Sets the contents of the {@link engine.model.Document Document} provided as HTML-like string.
  43. *
  44. * @param {engine.model.Document} document
  45. * @param {String} data HTML-like string to write into Document.
  46. * @param {Object} options
  47. * @param {String} [options.rootName] Root name where parsed data will be stored. If not provided, default `main` name will be
  48. * used.
  49. */
  50. export function setData( document, data, options = {} ) {
  51. setData._parse( data, {
  52. document: document,
  53. rootName: options.rootName
  54. } );
  55. }
  56. // Set parse as setData private method - needed for testing/spying.
  57. setData._parse = parse;
  58. /**
  59. * Converts model nodes to HTML-like string representation.
  60. *
  61. * @param {engine.model.RootElement|engine.model.Element|engine.model.Text|
  62. * engine.model.DocumentFragment} node Node to stringify.
  63. * @param {engine.model.Selection|engine.model.Position|engine.model.Range} [selectionOrPositionOrRange = null ]
  64. * Selection instance which ranges will be included in returned string data. If Range instance is provided - it will be
  65. * converted to selection containing this range. If Position instance is provided - it will be converted to selection
  66. * containing one range collapsed at this position.
  67. * @returns {String} HTML-like string representing the model.
  68. */
  69. export function stringify( node, selectionOrPositionOrRange = null ) {
  70. let selection;
  71. let document;
  72. if ( node instanceof RootElement ) {
  73. document = node.document;
  74. } else if ( node instanceof Element || node instanceof Text ) {
  75. // If root is Element or Text - wrap it with DocumentFragment.
  76. node = new DocumentFragment( node );
  77. }
  78. document = document || new Document();
  79. const walker = new TreeWalker( {
  80. boundaries: Range.createFromElement( node )
  81. } );
  82. if ( selectionOrPositionOrRange instanceof Selection ) {
  83. selection = selectionOrPositionOrRange;
  84. } else if ( selectionOrPositionOrRange instanceof Range ) {
  85. selection = document.selection;
  86. selection.addRange( selectionOrPositionOrRange );
  87. } else if ( selectionOrPositionOrRange instanceof Position ) {
  88. selection = document.selection;
  89. selection.addRange( new Range( selectionOrPositionOrRange, selectionOrPositionOrRange ) );
  90. }
  91. let ret = '';
  92. let lastPosition = Position.createFromParentAndOffset( node, 0 );
  93. const withSelection = !!selection;
  94. for ( let value of walker ) {
  95. if ( withSelection ) {
  96. ret += writeSelection( value.previousPosition, selection );
  97. }
  98. ret += writeItem( value, selection, { selection: withSelection } );
  99. lastPosition = value.nextPosition;
  100. }
  101. if ( withSelection ) {
  102. ret += writeSelection( lastPosition, selection );
  103. }
  104. return ret;
  105. }
  106. /**
  107. * Parses HTML-like string and returns model {@link engine.model.RootElement rootElement}.
  108. *
  109. * @param {String} data HTML-like string to be parsed.
  110. * @param {Object} options
  111. * @param {engine.model.Document} [options.document] Document from which root element and selection will be used. If
  112. * not provided new {engine.model.Document document} instance will be created.
  113. * @param {String} [options.rootName='main'] When `document` option is provided this root name will be used to create
  114. * {engine.model.RootElement RootElement} instance.
  115. * @returns {engine.model.RootElement|Object} Returns parsed RootElement or object with two fields `model`
  116. * and `selection` when selection ranges were included in data to parse.
  117. */
  118. export function parse( data, options = {} ) {
  119. let document, root;
  120. let withSelection = false;
  121. const rootName = options.rootName || 'main';
  122. if ( options.document ) {
  123. document = options.document;
  124. root = document.getRoot( rootName );
  125. root.removeChildren( 0, root.getChildCount() );
  126. } else {
  127. document = new Document();
  128. root = document.createRoot( rootName );
  129. }
  130. const path = [];
  131. let selectionStart, selectionEnd, selectionAttributes, textAttributes;
  132. const handlers = {
  133. text( token ) {
  134. root.appendChildren( new Text( token.text, textAttributes ) );
  135. },
  136. textStart( token ) {
  137. textAttributes = token.attributes;
  138. path.push( '$text' );
  139. },
  140. textEnd() {
  141. if ( path.pop() != '$text' ) {
  142. throw new Error( 'Parse error - unexpected closing tag.' );
  143. }
  144. textAttributes = null;
  145. },
  146. openingTag( token ) {
  147. let el = new Element( token.name, token.attributes );
  148. root.appendChildren( el );
  149. root = el;
  150. path.push( token.name );
  151. },
  152. closingTag( token ) {
  153. if ( path.pop() != token.name ) {
  154. throw new Error( 'Parse error - unexpected closing tag.' );
  155. }
  156. root = root.parent;
  157. },
  158. collapsedSelection( token ) {
  159. withSelection = true;
  160. document.selection.collapse( root, 'END' );
  161. document.selection.setAttributesTo( token.attributes );
  162. },
  163. selectionStart( token ) {
  164. selectionStart = Position.createFromParentAndOffset( root, root.getChildCount() );
  165. selectionAttributes = token.attributes;
  166. },
  167. selectionEnd() {
  168. if ( !selectionStart ) {
  169. throw new Error( 'Parse error - missing selection start.' );
  170. }
  171. withSelection = true;
  172. selectionEnd = Position.createFromParentAndOffset( root, root.getChildCount() );
  173. document.selection.setRanges(
  174. [ new Range( selectionStart, selectionEnd ) ],
  175. selectionAttributes.backward
  176. );
  177. delete selectionAttributes.backward;
  178. document.selection.setAttributesTo( selectionAttributes );
  179. }
  180. };
  181. for ( let token of tokenize( data ) ) {
  182. handlers[ token.type ]( token );
  183. }
  184. if ( path.length ) {
  185. throw new Error( 'Parse error - missing closing tags: ' + path.join( ', ' ) + '.' );
  186. }
  187. if ( selectionStart && !selectionEnd ) {
  188. throw new Error( 'Parse error - missing selection end.' );
  189. }
  190. if ( withSelection ) {
  191. return {
  192. model: root,
  193. selection: document.selection
  194. };
  195. }
  196. return root;
  197. }
  198. // -- getData helpers ---------------------------------------------------------
  199. function writeItem( walkerValue, selection, options ) {
  200. const type = walkerValue.type;
  201. const item = walkerValue.item;
  202. if ( type == 'ELEMENT_START' ) {
  203. let attrs = writeAttributes( item.getAttributes() );
  204. if ( attrs ) {
  205. return `<${ item.name } ${ attrs }>`;
  206. }
  207. return `<${ item.name }>`;
  208. }
  209. if ( type == 'ELEMENT_END' ) {
  210. return `</${ item.name }>`;
  211. }
  212. return writeText( walkerValue, selection, options );
  213. }
  214. function writeText( walkerValue, selection, options ) {
  215. const item = walkerValue.item;
  216. const attrs = writeAttributes( item.getAttributes() );
  217. let text = Array.from( item.text );
  218. if ( options.selection ) {
  219. const startIndex = walkerValue.previousPosition.offset + 1;
  220. const endIndex = walkerValue.nextPosition.offset - 1;
  221. let index = startIndex;
  222. while ( index <= endIndex ) {
  223. // Add the selection marker without changing any indexes, so if second marker must be added
  224. // in the same loop it does not blow up.
  225. text[ index - startIndex ] +=
  226. writeSelection( Position.createFromParentAndOffset( item.commonParent, index ), selection );
  227. index++;
  228. }
  229. }
  230. text = text.join( '' );
  231. if ( attrs ) {
  232. return `<$text ${ attrs }>${ text }</$text>`;
  233. }
  234. return text;
  235. }
  236. function writeAttributes( attrs ) {
  237. attrs = Array.from( attrs );
  238. return attrs.map( attr => attr[ 0 ] + '=' + JSON.stringify( attr[ 1 ] ) ).sort().join( ' ' );
  239. }
  240. function writeSelection( currentPosition, selection ) {
  241. // TODO: This function obviously handles only the first range.
  242. const range = selection.getFirstRange();
  243. // Handle end of the selection.
  244. if ( !selection.isCollapsed && range.end.compareWith( currentPosition ) == 'SAME' ) {
  245. return '</selection>';
  246. }
  247. // Handle no match.
  248. if ( range.start.compareWith( currentPosition ) != 'SAME' ) {
  249. return '';
  250. }
  251. // Handle beginning of the selection.
  252. let ret = '<selection';
  253. const attrs = writeAttributes( selection.getAttributes() );
  254. // TODO: Once we'll support multiple ranges this will need to check which range it is.
  255. if ( selection.isBackward ) {
  256. ret += ' backward';
  257. }
  258. if ( attrs ) {
  259. ret += ' ' + attrs;
  260. }
  261. ret += ( selection.isCollapsed ? ' />' : '>' );
  262. return ret;
  263. }
  264. // -- setData helpers ---------------------------------------------------------
  265. const patterns = {
  266. selection: /^<(\/?selection)( [^>]*)?>/,
  267. tag: /^<([^>]+)>/,
  268. text: /^[^<]+/
  269. };
  270. const handlers = {
  271. selection( match ) {
  272. const tagName = match[ 1 ];
  273. const tagExtension = match[ 2 ] || '';
  274. if ( tagName[ 0 ] == '/' ) {
  275. return {
  276. type: 'selectionEnd'
  277. };
  278. }
  279. if ( tagExtension.endsWith( ' /' ) ) {
  280. return {
  281. type: 'collapsedSelection',
  282. attributes: parseAttributes( tagExtension.slice( 1, -2 ) )
  283. };
  284. }
  285. return {
  286. type: 'selectionStart',
  287. attributes: parseAttributes( tagExtension.slice( 1 ) )
  288. };
  289. },
  290. tag( match ) {
  291. const tagContents = match[ 1 ].split( /\s+/ );
  292. const tagName = tagContents.shift();
  293. const attrs = tagContents.join( ' ' );
  294. if ( tagName == '/$text' ) {
  295. return {
  296. type: 'textEnd'
  297. };
  298. }
  299. if ( tagName == '$text' ) {
  300. return {
  301. type: 'textStart',
  302. attributes: parseAttributes( attrs )
  303. };
  304. }
  305. if ( tagName[ 0 ] == '/' ) {
  306. return {
  307. type: 'closingTag',
  308. name: tagName.slice( 1 )
  309. };
  310. }
  311. return {
  312. type: 'openingTag',
  313. name: tagName,
  314. attributes: parseAttributes( attrs )
  315. };
  316. },
  317. text( match ) {
  318. return {
  319. type: 'text',
  320. text: match[ 0 ]
  321. };
  322. }
  323. };
  324. function *tokenize( data ) {
  325. while ( data ) {
  326. const consumed = consumeNextToken( data );
  327. data = consumed.data;
  328. yield consumed.token;
  329. }
  330. }
  331. function consumeNextToken( data ) {
  332. let match;
  333. for ( let patternName in patterns ) {
  334. match = data.match( patterns[ patternName ] );
  335. if ( match ) {
  336. data = data.slice( match[ 0 ].length );
  337. return {
  338. token: handlers[ patternName ]( match ),
  339. data
  340. };
  341. }
  342. }
  343. throw new Error( 'Parse error - unexpected token: ' + data + '.' );
  344. }
  345. function parseAttributes( attrsString ) {
  346. attrsString = attrsString.trim();
  347. if ( !attrsString ) {
  348. return {};
  349. }
  350. const pattern = /(?:backward|(\w+)=("[^"]+"|[^\s]+))\s*/;
  351. const attrs = {};
  352. while ( attrsString ) {
  353. let match = attrsString.match( pattern );
  354. if ( !match ) {
  355. throw new Error( 'Parse error - unexpected token: ' + attrsString + '.' );
  356. }
  357. if ( match[ 0 ].trim() == 'backward' ) {
  358. attrs.backward = true;
  359. } else {
  360. attrs[ match[ 1 ] ] = JSON.parse( match[ 2 ] );
  361. }
  362. attrsString = attrsString.slice( match[ 0 ].length );
  363. }
  364. return attrs;
  365. }