styles.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. /**
  2. * @license Copyright (c) 2003-2019, 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/view/styles
  7. */
  8. import { get, isObject, merge, set, unset } from 'lodash-es';
  9. import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
  10. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  11. /**
  12. * Styles class.
  13. *
  14. * Handles styles normalization.
  15. */
  16. export default class Styles {
  17. /**
  18. * Creates Styles instance.
  19. */
  20. constructor( styleProcessor ) {
  21. /**
  22. * @private
  23. */
  24. this._styles = {};
  25. // Hide _styleProcessor from the watchdog by making this property non-enumarable. Watchdog checks errors for their editor origin
  26. // by checking if two objects are connected through properties. Using singleton is against this check as it would detect
  27. // that two editors are connected through single style processor instance.
  28. Object.defineProperty( this, '_styleProcessor', {
  29. get() {
  30. return styleProcessor || Styles.processor;
  31. },
  32. enumerable: false
  33. } );
  34. }
  35. /**
  36. * Number of styles defined.
  37. *
  38. * @type {Number}
  39. */
  40. get size() {
  41. return this.getStyleNames().length;
  42. }
  43. static get processor() {
  44. if ( !this._processor ) {
  45. this._processor = new StylesProcessor();
  46. }
  47. return this._processor;
  48. }
  49. static setProcessor( processor ) {
  50. this._processor = processor;
  51. }
  52. /**
  53. * Re-sets internal styles definition.
  54. *
  55. * @param {String} styleString
  56. */
  57. setStyle( styleString ) {
  58. this.clear();
  59. const map = parseInlineStyles( styleString );
  60. for ( const key of map.keys() ) {
  61. const value = map.get( key );
  62. this._styleProcessor.toNormalizedForm( key, value, this._styles );
  63. }
  64. }
  65. /**
  66. * Checks if single style rule is set.
  67. *
  68. * Supports shorthands.
  69. *
  70. * @param {String} propertyName
  71. * @returns {Boolean}
  72. */
  73. hasProperty( propertyName ) {
  74. const normalized = this._styleProcessor.getNormalized( propertyName, this._styles );
  75. if ( !normalized ) {
  76. // Try return styles set directly - values that are not parsed.
  77. return this._styles[ propertyName ] !== undefined;
  78. }
  79. if ( isObject( normalized ) ) {
  80. const styles = this._styleProcessor.getReducedForm( propertyName, normalized );
  81. const propertyDescriptor = styles.find( ( [ property ] ) => property === propertyName );
  82. // Only return a value if it is set;
  83. return Array.isArray( propertyDescriptor );
  84. } else {
  85. return true;
  86. }
  87. }
  88. /**
  89. * Inserts single style property.
  90. *
  91. * Can insert one by one
  92. *
  93. * styles.insertProperty( 'color', 'blue' );
  94. * styles.insertProperty( 'margin-right', '1em' );
  95. *
  96. * or many styles at once:
  97. *
  98. * styles.insertProperty( {
  99. * color: 'blue',
  100. * 'margin-right': '1em'
  101. * } );
  102. *
  103. * Supports shorthands.
  104. *
  105. * @param {String|Object} nameOrObject
  106. * @param {String|Object} value
  107. * @returns {Boolean}
  108. */
  109. insertProperty( nameOrObject, value ) {
  110. if ( isObject( nameOrObject ) ) {
  111. for ( const key of Object.keys( nameOrObject ) ) {
  112. this.insertProperty( key, nameOrObject[ key ] );
  113. }
  114. } else {
  115. this._styleProcessor.toNormalizedForm( nameOrObject, value, this._styles );
  116. }
  117. }
  118. /**
  119. * Removes styles property.
  120. *
  121. * @param name
  122. */
  123. removeProperty( name ) {
  124. unset( this._styles, toPath( name ) );
  125. delete this._styles[ name ];
  126. }
  127. /**
  128. * Return a normalized style object.
  129. *
  130. * const styles = new Styles();
  131. * styles.setStyle( 'margin:1px 2px 3em;' );
  132. *
  133. * console.log( styles.getNormalized( 'margin' ) );
  134. * // will log:
  135. * // {
  136. * // top: '1px',
  137. * // right: '2px',
  138. * // bottom: '3em',
  139. * // left: '2px'
  140. * // }
  141. *
  142. * @param {String} name
  143. * @returns {Object|undefined}
  144. */
  145. getNormalized( name ) {
  146. return this._styleProcessor.getNormalized( name, this._styles );
  147. }
  148. /**
  149. * Returns a string containing normalized styles string or undefined if no style properties are set.
  150. *
  151. * @returns {String|undefined}
  152. */
  153. getInlineStyle() {
  154. const entries = this._getStylesEntries();
  155. // Return undefined for empty styles map.
  156. if ( !entries.length ) {
  157. return;
  158. }
  159. return entries
  160. .map( arr => arr.join( ':' ) )
  161. .sort()
  162. .join( ';' ) + ';';
  163. }
  164. /**
  165. * Returns property value string.
  166. *
  167. * @param {String} propertyName
  168. * @returns {String|undefined}
  169. */
  170. getInlineProperty( propertyName ) {
  171. const normalized = this._styleProcessor.getNormalized( propertyName, this._styles );
  172. if ( !normalized ) {
  173. // Try return styles set directly - values that are not parsed.
  174. return this._styles[ propertyName ];
  175. }
  176. if ( isObject( normalized ) ) {
  177. const styles = this._styleProcessor.getReducedForm( propertyName, normalized );
  178. const propertyDescriptor = styles.find( ( [ property ] ) => property === propertyName );
  179. // Only return a value if it is set;
  180. if ( Array.isArray( propertyDescriptor ) ) {
  181. return propertyDescriptor[ 1 ];
  182. }
  183. } else {
  184. return normalized;
  185. }
  186. }
  187. /**
  188. * Returns style properties names as the would appear when using {@link #getInlineStyle}
  189. *
  190. * @returns {module:engine/view/styles~PropertyEntry}
  191. */
  192. getStyleNames() {
  193. const entries = this._getStylesEntries();
  194. return entries.map( ( [ key ] ) => key );
  195. }
  196. /**
  197. * Removes all styles.
  198. */
  199. clear() {
  200. this._styles = {};
  201. }
  202. /**
  203. * Returns normalized styles entries for further processing.
  204. *
  205. * @private
  206. * @returns {module:engine/view/styles~PropertyEntry}
  207. */
  208. _getStylesEntries() {
  209. const parsed = [];
  210. const keys = Object.keys( this._styles );
  211. for ( const key of keys ) {
  212. const normalized = this._styleProcessor.getNormalized( key, this._styles );
  213. parsed.push( ...this._styleProcessor.getReducedForm( key, normalized ) );
  214. }
  215. return parsed;
  216. }
  217. }
  218. export class StylesProcessor {
  219. constructor() {
  220. this._groups = new Set();
  221. }
  222. /**
  223. * Returns reduced form of style property form normalized object.
  224. *
  225. * @private
  226. * @param {String} styleName
  227. * @param {Object|String} normalizedValue
  228. * @returns {module:engine/view/styles~PropertyEntry}
  229. */
  230. getReducedForm( styleName, normalizedValue ) {
  231. const data = {
  232. value: normalizedValue
  233. };
  234. this.fire( 'reduce:' + styleName, data );
  235. return data.reduced || [ [ styleName, normalizedValue ] ];
  236. }
  237. getNormalized( name, styles ) {
  238. if ( !name ) {
  239. return merge( {}, styles );
  240. }
  241. if ( styles[ name ] ) {
  242. return styles[ name ];
  243. }
  244. const data = {
  245. name,
  246. styles
  247. };
  248. this.fire( `extract:${ name }`, data );
  249. if ( data.path ) {
  250. return get( styles, data.path );
  251. }
  252. if ( data.value ) {
  253. return data.value;
  254. }
  255. return get( styles, toPath( name ) );
  256. }
  257. /**
  258. * Parse style property value to a normalized form.
  259. *
  260. * @param {String} propertyName Name of style property.
  261. * @param {String} value Value of style property.
  262. * @param {Object} styles
  263. * @private
  264. */
  265. toNormalizedForm( propertyName, value, styles ) {
  266. if ( isObject( value ) ) {
  267. appendStyleValue( styles, toPath( propertyName ), value );
  268. return;
  269. }
  270. const data = {
  271. path: propertyName,
  272. value
  273. };
  274. this.fire( 'normalize:' + propertyName, data );
  275. appendStyleValue( styles, data.path, data.value );
  276. }
  277. registerListeners( groupName, callback ) {
  278. if ( this._groups.has( groupName ) ) {
  279. return;
  280. }
  281. callback( this );
  282. }
  283. }
  284. mix( StylesProcessor, EmitterMixin );
  285. // Parses inline styles and puts property - value pairs into styles map.
  286. //
  287. // @param {String} stylesString Styles to parse.
  288. // @returns {Map.<String, String>} stylesMap Map of parsed properties and values.
  289. function parseInlineStyles( stylesString ) {
  290. // `null` if no quote was found in input string or last found quote was a closing quote. See below.
  291. let quoteType = null;
  292. let propertyNameStart = 0;
  293. let propertyValueStart = 0;
  294. let propertyName = null;
  295. const stylesMap = new Map();
  296. // Do not set anything if input string is empty.
  297. if ( stylesString === '' ) {
  298. return stylesMap;
  299. }
  300. // Fix inline styles that do not end with `;` so they are compatible with algorithm below.
  301. if ( stylesString.charAt( stylesString.length - 1 ) != ';' ) {
  302. stylesString = stylesString + ';';
  303. }
  304. // Seek the whole string for "special characters".
  305. for ( let i = 0; i < stylesString.length; i++ ) {
  306. const char = stylesString.charAt( i );
  307. if ( quoteType === null ) {
  308. // No quote found yet or last found quote was a closing quote.
  309. switch ( char ) {
  310. case ':':
  311. // Most of time colon means that property name just ended.
  312. // Sometimes however `:` is found inside property value (for example in background image url).
  313. if ( !propertyName ) {
  314. // Treat this as end of property only if property name is not already saved.
  315. // Save property name.
  316. propertyName = stylesString.substr( propertyNameStart, i - propertyNameStart );
  317. // Save this point as the start of property value.
  318. propertyValueStart = i + 1;
  319. }
  320. break;
  321. case '"':
  322. case '\'':
  323. // Opening quote found (this is an opening quote, because `quoteType` is `null`).
  324. quoteType = char;
  325. break;
  326. case ';': {
  327. // Property value just ended.
  328. // Use previously stored property value start to obtain property value.
  329. const propertyValue = stylesString.substr( propertyValueStart, i - propertyValueStart );
  330. if ( propertyName ) {
  331. // Save parsed part.
  332. stylesMap.set( propertyName.trim(), propertyValue.trim() );
  333. }
  334. propertyName = null;
  335. // Save this point as property name start. Property name starts immediately after previous property value ends.
  336. propertyNameStart = i + 1;
  337. break;
  338. }
  339. }
  340. } else if ( char === quoteType ) {
  341. // If a quote char is found and it is a closing quote, mark this fact by `null`-ing `quoteType`.
  342. quoteType = null;
  343. }
  344. }
  345. return stylesMap;
  346. }
  347. function toPath( name ) {
  348. return name.replace( '-', '.' );
  349. }
  350. // Appends style definition to the styles object.
  351. //
  352. // @param {String} nameOrPath
  353. // @param {String|Object} valueOrObject
  354. // @private
  355. function appendStyleValue( stylesObject, nameOrPath, valueOrObject ) {
  356. let valueToSet = valueOrObject;
  357. if ( isObject( valueOrObject ) ) {
  358. valueToSet = merge( {}, get( stylesObject, nameOrPath ), valueOrObject );
  359. }
  360. set( stylesObject, nameOrPath, valueToSet );
  361. }
  362. /**
  363. * An style reducer takes normalized object of style property and outputs array of normalized property-value pairs that can
  364. * be later used to inline a style.
  365. *
  366. * Those work in opposite direction to "normalize" event and always outputs style in the same way.
  367. *
  368. * If normalized style is represented as:
  369. *
  370. * const style = {
  371. * border: {
  372. * color: {
  373. * top: '#f00',
  374. * right: '#ba7',
  375. * bottom: '#f00',
  376. * left: '#ba7'
  377. * }
  378. * }
  379. * }
  380. *
  381. * The border reducer will output:
  382. *
  383. * const reduced = [
  384. * [ 'border-color', '#f00 #ba7' ]
  385. * ];
  386. *
  387. * which can be used to return the inline style string:
  388. *
  389. * style="border-color:#f00 #ba7;"
  390. *
  391. * @event reduce
  392. */
  393. /**
  394. * Holds shorthand properties normalizers.
  395. *
  396. * Shorthand properties must be normalized as they can be written in various ways.
  397. * Normalizer must return object describing given shorthand.
  398. *
  399. * Example:
  400. * The `border-color` style is a shorthand property for `border-top-color`, `border-right-color`, `border-bottom-color`
  401. * and `border-left-color`. Similarly there are shorthand for border width (`border-width`) and style (`border-style`).
  402. *
  403. * For `border-color` the given shorthand:
  404. *
  405. * border-color: #f00 #ba7;
  406. *
  407. * might be written as:
  408. *
  409. * border-color-top: #f00;
  410. * border-color-right: #ba7;
  411. * border-color-bottom: #f00;
  412. * border-color-left: #ba7;
  413. *
  414. * Normalizers produces coherent object representation for both shorthand and longhand forms:
  415. *
  416. * stylesProcessor.on( 'normalize:border-color', ( evt, data ) => {
  417. * data.path = 'border.color';
  418. * data.value = {
  419. * top: '#f00',
  420. * right: '#ba7',
  421. * bottom: '#f00',
  422. * left: '#ba7'
  423. * }
  424. * } );
  425. *
  426. * @event normalize
  427. */
  428. /**
  429. * @typedef {Array.<Array.<String>>} module:engine/view/styles~PropertyEntry
  430. */