styles.js 13 KB

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