model.js 9.0 KB

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