8
0

model.js 12 KB

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