styles.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  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, isPlainObject, merge, set, unset } from 'lodash-es';
  9. const setOnPathStyles = [
  10. // Margin & padding.
  11. 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
  12. 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
  13. // Background.
  14. 'background-color'
  15. ];
  16. /**
  17. * Styles class.
  18. *
  19. * Handles styles normalization.
  20. */
  21. export default class Styles {
  22. /**
  23. * Creates Styles instance.
  24. *
  25. * @param {String} styleString Initial styles value.
  26. */
  27. constructor( styleString = '' ) {
  28. this._styles = {};
  29. this.parsers = new Map();
  30. this.parsers.set( 'border', parseBorder );
  31. this.parsers.set( 'border-top', parseBorderSide( 'top' ) );
  32. this.parsers.set( 'border-right', parseBorderSide( 'right' ) );
  33. this.parsers.set( 'border-bottom', parseBorderSide( 'bottom' ) );
  34. this.parsers.set( 'border-left', parseBorderSide( 'left' ) );
  35. this.parsers.set( 'border-color', parseBorderProperty( 'color' ) );
  36. this.parsers.set( 'border-width', parseBorderProperty( 'width' ) );
  37. this.parsers.set( 'border-style', parseBorderProperty( 'style' ) );
  38. this.parsers.set( 'background', parseBackground );
  39. this.parsers.set( 'margin', parseShorthandSides( 'margin' ) );
  40. this.parsers.set( 'padding', parseShorthandSides( 'padding' ) );
  41. this.setStyle( styleString );
  42. }
  43. /**
  44. * Number of styles defined.
  45. *
  46. * @type {Number}
  47. */
  48. get size() {
  49. return this.getStyleNames().length;
  50. }
  51. /**
  52. * Re-sets internal styles definition.
  53. *
  54. * @param styleString
  55. */
  56. setStyle( styleString = '' ) {
  57. this.clear();
  58. const map = parseInlineStyles( styleString );
  59. for ( const key of map.keys() ) {
  60. const value = map.get( key );
  61. this._parseProperty( key, value );
  62. }
  63. }
  64. /**
  65. * Checks if single style rule is set.
  66. *
  67. * Supports shorthands.
  68. *
  69. * @param {String} name
  70. * @returns {Boolean}
  71. */
  72. hasProperty( name ) {
  73. const nameNorm = toPath( name );
  74. return has( this._styles, nameNorm ) || !!this._styles[ name ];
  75. }
  76. /**
  77. * Inserts single style rule.
  78. *
  79. * Supports shorthands.
  80. *
  81. * @param {String|Object} nameOrObject
  82. * @param {String|Object} value
  83. * @returns {Boolean}
  84. */
  85. insertProperty( nameOrObject, value ) {
  86. if ( isPlainObject( nameOrObject ) ) {
  87. for ( const key of Object.keys( nameOrObject ) ) {
  88. this.insertProperty( key, nameOrObject[ key ] );
  89. }
  90. } else {
  91. this._parseProperty( nameOrObject, value );
  92. }
  93. }
  94. removeProperty( name ) {
  95. unset( this._styles, toPath( name ) );
  96. delete this._styles[ name ];
  97. }
  98. getNormalized( name ) {
  99. if ( !name ) {
  100. return merge( {}, this._styles );
  101. }
  102. const path = toPath( name );
  103. if ( has( this._styles, path ) ) {
  104. return get( this._styles, path );
  105. } else {
  106. return this._styles[ name ];
  107. }
  108. }
  109. getInlineStyle() {
  110. const parsed = [];
  111. const keys = Object.keys( this._styles ).sort();
  112. if ( !keys.length ) {
  113. return;
  114. }
  115. for ( const key of keys ) {
  116. const normalized = this.getNormalized( key );
  117. parsed.push( toInlineStyle( key, normalized ) );
  118. }
  119. return parsed.join( ';' ) + ';';
  120. }
  121. getInlineProperty( name ) {
  122. const normalized = this.getNormalized( name );
  123. if ( !normalized ) {
  124. // Try return directly
  125. return this._styles[ name ];
  126. }
  127. if ( isObject( normalized ) ) {
  128. return toInlineStyleProperty( name, normalized );
  129. }
  130. // String value
  131. else {
  132. return normalized;
  133. }
  134. }
  135. getStyleNames() {
  136. const inlineStyle = this.getInlineStyle();
  137. return ( inlineStyle || '' )
  138. .split( ';' )
  139. .filter( f => f !== '' )
  140. .map( abc => abc.split( ':' )[ 0 ] )
  141. .sort( sortTopRightBottomLeftProperties );
  142. }
  143. clear() {
  144. this._styles = {};
  145. }
  146. _appendStyleValue( nameOrPath, valueOrObject ) {
  147. if ( typeof valueOrObject === 'object' ) {
  148. if ( nameOrPath.includes( '.' ) ) {
  149. const got = get( this._styles, nameOrPath );
  150. set( this._styles, nameOrPath, merge( {}, got, valueOrObject ) );
  151. } else {
  152. this._styles[ nameOrPath ] = merge( {}, this._styles[ nameOrPath ], valueOrObject );
  153. }
  154. } else {
  155. set( this._styles, nameOrPath, valueOrObject );
  156. }
  157. }
  158. _parseProperty( key, value ) {
  159. if ( isPlainObject( value ) ) {
  160. this._appendStyleValue( toPath( key ), value );
  161. return;
  162. }
  163. // Set directly to an object.
  164. if ( setOnPathStyles.includes( key ) ) {
  165. this._appendStyleValue( toPath( key ), value );
  166. return;
  167. }
  168. if ( this.parsers.has( key ) ) {
  169. const parser = this.parsers.get( key );
  170. this._styles = merge( {}, this._styles, parser( value ) );
  171. } else {
  172. this._appendStyleValue( key, value );
  173. }
  174. }
  175. }
  176. function getTopRightBottomLeftValues( value = '' ) {
  177. const values = value.split( ' ' );
  178. const top = values[ 0 ];
  179. const bottom = values[ 2 ] || top;
  180. const right = values[ 1 ] || top;
  181. const left = values[ 3 ] || right;
  182. return { top, bottom, right, left };
  183. }
  184. function toBorderPropertyShorthand( value, property ) {
  185. return {
  186. [ property ]: getTopRightBottomLeftValues( value )
  187. };
  188. }
  189. function parseShorthandSides( longhand ) {
  190. return value => {
  191. return { [ longhand ]: getTopRightBottomLeftValues( value ) };
  192. };
  193. }
  194. function parseBorder( value ) {
  195. const { color, style, width } = parseShorthandBorderAttribute( value );
  196. return {
  197. border: {
  198. color: getTopRightBottomLeftValues( color ),
  199. style: getTopRightBottomLeftValues( style ),
  200. width: getTopRightBottomLeftValues( width )
  201. }
  202. };
  203. }
  204. function parseBorderSide( side ) {
  205. return value => {
  206. const { color, style, width } = parseShorthandBorderAttribute( value );
  207. const border = {};
  208. if ( color !== undefined ) {
  209. border.color = { [ side ]: color };
  210. }
  211. if ( style !== undefined ) {
  212. border.style = { [ side ]: style };
  213. }
  214. if ( width !== undefined ) {
  215. border.width = { [ side ]: width };
  216. }
  217. return { border };
  218. };
  219. }
  220. function parseBorderProperty( foo ) {
  221. return value => ( {
  222. border: toBorderPropertyShorthand( value, foo )
  223. } );
  224. }
  225. function parseShorthandBorderAttribute( string ) {
  226. const result = {};
  227. for ( const part of string.split( ' ' ) ) {
  228. if ( isLength( part ) ) {
  229. result.width = part;
  230. }
  231. if ( isLineStyle( part ) ) {
  232. result.style = part;
  233. }
  234. if ( isColor( part ) ) {
  235. result.color = part;
  236. }
  237. }
  238. return result;
  239. }
  240. function parseBackground( value ) {
  241. const background = {};
  242. const parts = value.split( ' ' );
  243. for ( const part of parts ) {
  244. if ( isColor( part ) ) {
  245. background.color = part;
  246. }
  247. }
  248. return { background };
  249. }
  250. function isColor( string ) {
  251. return /^([#0-9A-Fa-f]{3,8}|[a-zA-Z]+)$/.test( string ) && !isLineStyle( string );
  252. }
  253. function isLineStyle( string ) {
  254. return /^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/.test( string );
  255. }
  256. function isLength( string ) {
  257. return /^[+-]?[0-9]?[.]?[0-9]+([a-z]+|%)$/.test( string );
  258. }
  259. function printSingleValues( { top, right, bottom, left }, prefix ) {
  260. const ret = [];
  261. if ( top ) {
  262. ret.push( prefix + '-top:' + top );
  263. }
  264. if ( right ) {
  265. ret.push( prefix + '-right:' + right );
  266. }
  267. if ( bottom ) {
  268. ret.push( prefix + '-bottom:' + bottom );
  269. }
  270. if ( left ) {
  271. ret.push( prefix + '-left:' + left );
  272. }
  273. return ret.join( ';' );
  274. }
  275. function shBorder( which ) {
  276. return value => {
  277. return outputShorthandableValue( value[ which ], false, `border-${ which }` );
  278. };
  279. }
  280. function getABCDEDGHIJK( { left, right, top, bottom } ) {
  281. const out = [];
  282. if ( left !== right ) {
  283. out.push( top, right, bottom, left );
  284. } else if ( bottom !== top ) {
  285. out.push( top, right, bottom );
  286. } else if ( right != top ) {
  287. out.push( top, right );
  288. } else {
  289. out.push( top );
  290. }
  291. return out;
  292. }
  293. function outputShorthandableValue( styleObject = {}, strict, styleShorthand ) {
  294. const { top, right, bottom, left } = styleObject;
  295. if ( top === left && left === bottom && bottom === right ) {
  296. // Might be not set.
  297. if ( top === undefined ) {
  298. return '';
  299. }
  300. return ( strict ? '' : styleShorthand + ':' ) + top;
  301. } else if ( ![ top, right, left, bottom ].every( value => !!value ) ) {
  302. return printSingleValues( { top, right, bottom, left }, 'margin' );
  303. } else {
  304. const out = getABCDEDGHIJK( styleObject );
  305. return `${ strict ? '' : styleShorthand + ':' }${ out.join( ' ' ) }`;
  306. }
  307. }
  308. function stringifyBorderProperty( styleObjectOrString ) {
  309. const top = toInlineBorder( styleObjectOrString.top );
  310. const right = toInlineBorder( styleObjectOrString.right );
  311. const bottom = toInlineBorder( styleObjectOrString.bottom );
  312. const left = toInlineBorder( styleObjectOrString.left );
  313. if ( top === right && right === bottom && bottom === left ) {
  314. return top;
  315. }
  316. }
  317. function toInlineStyleProperty( styleName, styleObjectOrString ) {
  318. if ( styleName === 'border' ) {
  319. return stringifyBorderProperty( styleObjectOrString );
  320. }
  321. if ( styleName === 'border-color' ) {
  322. return outputShorthandableValue( styleObjectOrString, true, 'border-color' );
  323. }
  324. if ( styleName === 'border-style' ) {
  325. return outputShorthandableValue( styleObjectOrString, true, 'border-style' );
  326. }
  327. if ( styleName === 'border-width' ) {
  328. return outputShorthandableValue( styleObjectOrString, true, 'border-width' );
  329. }
  330. if ( styleName === 'margin' ) {
  331. return outputShorthandableValue( styleObjectOrString, true, 'margin' );
  332. }
  333. if ( styleName === 'padding' ) {
  334. return outputShorthandableValue( styleObjectOrString, true, 'padding' );
  335. }
  336. return styleObjectOrString;
  337. }
  338. const topRightBottomLeftOrder = [ 'top', 'right', 'bottom', 'left' ];
  339. function sortTopRightBottomLeftProperties( a, b ) {
  340. if ( topRightBottomLeftOrder.includes( a ) && topRightBottomLeftOrder.includes( b ) ) {
  341. return topRightBottomLeftOrder.indexOf( a ) - topRightBottomLeftOrder.indexOf( b );
  342. }
  343. return 0;
  344. }
  345. function leWhat( styleObjectOrString, styleName ) {
  346. const values = [];
  347. for ( const key of Object.keys( styleObjectOrString ).sort( sortTopRightBottomLeftProperties ) ) {
  348. let styleObjectOrStringElement;
  349. if ( isObject( styleObjectOrString[ key ] ) ) {
  350. styleObjectOrStringElement = outputShorthandableValue( styleObjectOrString[ key ], true, styleName + 'key' );
  351. } else {
  352. styleObjectOrStringElement = styleObjectOrString[ key ];
  353. }
  354. values.push( `${ styleName }-${ key }:${ styleObjectOrStringElement }` );
  355. }
  356. return values.join( ';' );
  357. }
  358. function toInlineStyle( styleName, styleObjectOrString ) {
  359. const inliners = new Map();
  360. inliners.set( 'border-color', shBorder( 'color' ) );
  361. inliners.set( 'border-style', shBorder( 'style' ) );
  362. inliners.set( 'border-width', shBorder( 'width' ) );
  363. if ( inliners.has( styleName ) ) {
  364. const inliner = inliners.get( styleName );
  365. return inliner( styleObjectOrString );
  366. }
  367. // Generic, one-level, object to style:
  368. if ( isObject( styleObjectOrString ) ) {
  369. return leWhat( styleObjectOrString, styleName );
  370. }
  371. return `${ styleName }:${ styleObjectOrString }`;
  372. }
  373. function toInlineBorder( object = {} ) {
  374. const style = [];
  375. if ( object.width ) {
  376. style.push( object.width );
  377. }
  378. if ( object.style ) {
  379. style.push( object.style );
  380. }
  381. if ( object.color ) {
  382. style.push( object.color );
  383. }
  384. return style.join( ' ' );
  385. }
  386. // Parses inline styles and puts property - value pairs into styles map.
  387. //
  388. // @param {String} stylesString Styles to parse.
  389. // @returns {Map.<String, String>} stylesMap Map of parsed properties and values.
  390. function parseInlineStyles( stylesString ) {
  391. // `null` if no quote was found in input string or last found quote was a closing quote. See below.
  392. let quoteType = null;
  393. let propertyNameStart = 0;
  394. let propertyValueStart = 0;
  395. let propertyName = null;
  396. const stylesMap = new Map();
  397. // Do not set anything if input string is empty.
  398. if ( stylesString === '' ) {
  399. return stylesMap;
  400. }
  401. // Fix inline styles that do not end with `;` so they are compatible with algorithm below.
  402. if ( stylesString.charAt( stylesString.length - 1 ) != ';' ) {
  403. stylesString = stylesString + ';';
  404. }
  405. // Seek the whole string for "special characters".
  406. for ( let i = 0; i < stylesString.length; i++ ) {
  407. const char = stylesString.charAt( i );
  408. if ( quoteType === null ) {
  409. // No quote found yet or last found quote was a closing quote.
  410. switch ( char ) {
  411. case ':':
  412. // Most of time colon means that property name just ended.
  413. // Sometimes however `:` is found inside property value (for example in background image url).
  414. if ( !propertyName ) {
  415. // Treat this as end of property only if property name is not already saved.
  416. // Save property name.
  417. propertyName = stylesString.substr( propertyNameStart, i - propertyNameStart );
  418. // Save this point as the start of property value.
  419. propertyValueStart = i + 1;
  420. }
  421. break;
  422. case '"':
  423. case '\'':
  424. // Opening quote found (this is an opening quote, because `quoteType` is `null`).
  425. quoteType = char;
  426. break;
  427. case ';': {
  428. // Property value just ended.
  429. // Use previously stored property value start to obtain property value.
  430. const propertyValue = stylesString.substr( propertyValueStart, i - propertyValueStart );
  431. if ( propertyName ) {
  432. // Save parsed part.
  433. stylesMap.set( propertyName.trim(), propertyValue.trim() );
  434. }
  435. propertyName = null;
  436. // Save this point as property name start. Property name starts immediately after previous property value ends.
  437. propertyNameStart = i + 1;
  438. break;
  439. }
  440. }
  441. } else if ( char === quoteType ) {
  442. // If a quote char is found and it is a closing quote, mark this fact by `null`-ing `quoteType`.
  443. quoteType = null;
  444. }
  445. }
  446. return stylesMap;
  447. }
  448. function toPath( name ) {
  449. return name.replace( '-', '.' );
  450. }
  451. // 'border-style' -> d{}
  452. // 'border-top' -> d{}
  453. // 'border' -> d{}
  454. // {} -> style=""
  455. // {} -> border-top=""