model.js 11 KB

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