resizer.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  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 widget/widgetresize/resizer
  7. */
  8. import View from '@ckeditor/ckeditor5-ui/src/view';
  9. import Template from '@ckeditor/ckeditor5-ui/src/template';
  10. import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
  11. import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
  12. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  13. import ResizeState from './resizerstate';
  14. /**
  15. * Represents a resizer for a single resizable object.
  16. *
  17. * @mixes module:utils/observablemixin~ObservableMixin
  18. */
  19. export default class Resizer {
  20. /**
  21. * @param {module:widget/widgetresize~ResizerOptions} options Resizer options.
  22. */
  23. constructor( options ) {
  24. /**
  25. * Stores the state of the resizable host geometry, such as the original width, the currently proposed height, etc.
  26. *
  27. * Note that a new state is created for each resize transaction.
  28. *
  29. * @readonly
  30. * @member {module:widget/widgetresize/resizerstate~ResizerState} #state
  31. */
  32. /**
  33. * A view displaying the proposed new element size during the resizing.
  34. *
  35. * @protected
  36. * @readonly
  37. * @member {module:widget/widgetresize/resizer~SizeView} #_sizeUI
  38. */
  39. /**
  40. * Options passed to the {@link #constructor}.
  41. *
  42. * @private
  43. * @type {module:widget/widgetresize~ResizerOptions}
  44. */
  45. this._options = options;
  46. /**
  47. * Container of the entire resize UI.
  48. *
  49. * Note that this property is initialized only after the element bound with the resizer is drawn
  50. * so it will be a `null` when uninitialized.
  51. *
  52. * @private
  53. * @type {HTMLElement|null}
  54. */
  55. this._domResizerWrapper = null;
  56. /**
  57. * @observable
  58. */
  59. this.set( 'isEnabled', true );
  60. this.decorate( 'begin' );
  61. this.decorate( 'cancel' );
  62. this.decorate( 'commit' );
  63. this.decorate( 'updateSize' );
  64. }
  65. /**
  66. * Attaches the resizer to the DOM.
  67. */
  68. attach() {
  69. const that = this;
  70. const widgetElement = this._options.viewElement;
  71. const writer = this._options.downcastWriter;
  72. const viewResizerWrapper = writer.createUIElement( 'div', {
  73. class: 'ck ck-reset_all ck-widget__resizer'
  74. }, function( domDocument ) {
  75. const domElement = this.toDomElement( domDocument );
  76. that._appendHandles( domElement );
  77. that._appendSizeUI( domElement );
  78. that._domResizerWrapper = domElement;
  79. that.on( 'change:isEnabled', ( evt, propName, newValue ) => {
  80. domElement.style.display = newValue ? '' : 'none';
  81. } );
  82. domElement.style.display = that.isEnabled ? '' : 'none';
  83. return domElement;
  84. } );
  85. // Append the resizer wrapper to the widget's wrapper.
  86. writer.insert( writer.createPositionAt( widgetElement, 'end' ), viewResizerWrapper );
  87. writer.addClass( 'ck-widget_with-resizer', widgetElement );
  88. }
  89. /**
  90. * Starts the resizing process.
  91. *
  92. * Creates a new {@link #state} for the current process.
  93. *
  94. * @fires begin
  95. * @param {HTMLElement} domResizeHandle Clicked handle.
  96. */
  97. begin( domResizeHandle ) {
  98. this.state = new ResizeState( this._options );
  99. this._sizeUI.bindToState( this._options, this.state );
  100. this.state.begin( domResizeHandle, this._getHandleHost(), this._getResizeHost() );
  101. this.redraw();
  102. }
  103. /**
  104. * Updates the proposed size based on `domEventData`.
  105. *
  106. * @fires updateSize
  107. * @param {Event} domEventData
  108. */
  109. updateSize( domEventData ) {
  110. const domHandleHost = this._getHandleHost();
  111. const domResizeHost = this._getResizeHost();
  112. const unit = this._options.unit;
  113. const newSize = this._proposeNewSize( domEventData );
  114. domResizeHost.style.width = ( unit === '%' ? newSize.widthPercents : newSize.width ) + this._options.unit;
  115. const domHandleHostRect = new Rect( domHandleHost );
  116. newSize.handleHostWidth = Math.round( domHandleHostRect.width );
  117. newSize.handleHostHeight = Math.round( domHandleHostRect.height );
  118. const domResizeHostRect = new Rect( domHandleHost );
  119. newSize.width = Math.round( domResizeHostRect.width );
  120. newSize.height = Math.round( domResizeHostRect.height );
  121. this.state.update( newSize );
  122. // Refresh values based on the real image. Real image might be limited by max-width, and thus fetching it
  123. // here will reflect this limitation.
  124. this._domResizerWrapper.style.width = newSize.handleHostWidth + 'px';
  125. this._domResizerWrapper.style.height = newSize.handleHostHeight + 'px';
  126. }
  127. /**
  128. * Applies the geometry proposed with the resizer.
  129. *
  130. * @fires commit
  131. */
  132. commit() {
  133. const unit = this._options.unit;
  134. const newValue = ( unit === '%' ? this.state.proposedWidthPercents : this.state.proposedWidth ) + this._options.unit;
  135. this._options.onCommit( newValue );
  136. this._cleanup();
  137. }
  138. /**
  139. * Cancels and rejects the proposed resize dimensions, hiding the UI.
  140. *
  141. * @fires cancel
  142. */
  143. cancel() {
  144. this._cleanup();
  145. }
  146. /**
  147. * Destroys the resizer.
  148. */
  149. destroy() {
  150. this.cancel();
  151. }
  152. /**
  153. * Redraws the resizer.
  154. */
  155. redraw() {
  156. // TODO review this
  157. const domWrapper = this._domResizerWrapper;
  158. if ( existsInDom( domWrapper ) ) {
  159. // Refresh only if resizer exists in the DOM.
  160. const widgetWrapper = domWrapper.parentElement;
  161. const handleHost = this._getHandleHost();
  162. const clientRect = new Rect( handleHost );
  163. domWrapper.style.width = clientRect.width + 'px';
  164. domWrapper.style.height = clientRect.height + 'px';
  165. // In case a resizing host is not a widget wrapper, we need to compensate
  166. // for any additional offsets the resize host might have. E.g. wrapper padding
  167. // or simply another editable. By doing that the border and resizers are shown
  168. // only around the resize host.
  169. if ( !widgetWrapper.isSameNode( handleHost ) ) {
  170. domWrapper.style.left = handleHost.offsetLeft + 'px';
  171. domWrapper.style.top = handleHost.offsetTop + 'px';
  172. domWrapper.style.height = handleHost.offsetHeight + 'px';
  173. domWrapper.style.width = handleHost.offsetWidth + 'px';
  174. }
  175. }
  176. function existsInDom( element ) {
  177. return element && element.ownerDocument && element.ownerDocument.contains( element );
  178. }
  179. }
  180. containsHandle( domElement ) {
  181. return this._domResizerWrapper.contains( domElement );
  182. }
  183. static isResizeHandle( domElement ) {
  184. return domElement.classList.contains( 'ck-widget__resizer__handle' );
  185. }
  186. /**
  187. * Cleans up the context state.
  188. *
  189. * @protected
  190. */
  191. _cleanup() {
  192. this._sizeUI.dismiss();
  193. this._sizeUI.isVisible = false;
  194. }
  195. /**
  196. * Calculates the proposed size as the resize handles are dragged.
  197. *
  198. * @private
  199. * @param {Event} domEventData Event data that caused the size update request. It should be used to calculate the proposed size.
  200. * @returns {Object} return
  201. * @returns {Number} return.width Proposed width.
  202. * @returns {Number} return.height Proposed height.
  203. */
  204. _proposeNewSize( domEventData ) {
  205. const state = this.state;
  206. const currentCoordinates = extractCoordinates( domEventData );
  207. const isCentered = this._options.isCentered ? this._options.isCentered( this ) : true;
  208. // Enlargement defines how much the resize host has changed in a given axis. Naturally it could be a negative number
  209. // meaning that it has been shrunk.
  210. //
  211. // +----------------+--+
  212. // | | |
  213. // | img | |
  214. // | /handle host | |
  215. // +----------------+ | ^
  216. // | | | - enlarge y
  217. // +-------------------+ v
  218. // <-->
  219. // enlarge x
  220. const enlargement = {
  221. x: state._referenceCoordinates.x - ( currentCoordinates.x + state.originalWidth ),
  222. y: ( currentCoordinates.y - state.originalHeight ) - state._referenceCoordinates.y
  223. };
  224. if ( isCentered && state.activeHandlePosition.endsWith( '-right' ) ) {
  225. enlargement.x = currentCoordinates.x - ( state._referenceCoordinates.x + state.originalWidth );
  226. }
  227. // Objects needs to be resized twice as much in horizontal axis if centered, since enlargement is counted from
  228. // one resized corner to your cursor. It needs to be duplicated to compensate for the other side too.
  229. if ( isCentered ) {
  230. enlargement.x *= 2;
  231. }
  232. // const resizeHost = this._getResizeHost();
  233. // The size proposed by the user. It does not consider the aspect ratio.
  234. const proposedSize = {
  235. width: Math.abs( state.originalWidth + enlargement.x ),
  236. height: Math.abs( state.originalHeight + enlargement.y )
  237. };
  238. // Dominant determination must take the ratio into account.
  239. proposedSize.dominant = proposedSize.width / state.aspectRatio > proposedSize.height ? 'width' : 'height';
  240. proposedSize.max = proposedSize[ proposedSize.dominant ];
  241. // Proposed size, respecting the aspect ratio.
  242. const targetSize = {
  243. width: proposedSize.width,
  244. height: proposedSize.height
  245. };
  246. if ( proposedSize.dominant == 'width' ) {
  247. targetSize.height = targetSize.width / state.aspectRatio;
  248. } else {
  249. targetSize.width = targetSize.height * state.aspectRatio;
  250. }
  251. return {
  252. width: Math.round( targetSize.width ),
  253. height: Math.round( targetSize.height ),
  254. widthPercents: Math.min( Math.round( state.originalWidthPercents / state.originalWidth * targetSize.width * 100 ) / 100, 100 )
  255. };
  256. }
  257. /**
  258. * Obtains the resize host.
  259. *
  260. * Resize host is an object that receives dimensions which are the result of resizing.
  261. *
  262. * @protected
  263. * @returns {HTMLElement}
  264. */
  265. _getResizeHost() {
  266. const widgetWrapper = this._domResizerWrapper.parentElement;
  267. return this._options.getResizeHost( widgetWrapper );
  268. }
  269. /**
  270. * Obtains the handle host.
  271. *
  272. * Handle host is an object that the handles are aligned to.
  273. *
  274. * Handle host will not always be an entire widget itself. Take an image as an example. The image widget
  275. * contains an image and a caption. Only the image should be surrounded with handles.
  276. *
  277. * @protected
  278. * @returns {HTMLElement}
  279. */
  280. _getHandleHost() {
  281. const widgetWrapper = this._domResizerWrapper.parentElement;
  282. return this._options.getHandleHost( widgetWrapper );
  283. }
  284. /**
  285. * Renders the resize handles in the DOM.
  286. *
  287. * @private
  288. * @param {HTMLElement} domElement The resizer wrapper.
  289. */
  290. _appendHandles( domElement ) {
  291. const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
  292. for ( const currentPosition of resizerPositions ) {
  293. domElement.appendChild( ( new Template( {
  294. tag: 'div',
  295. attributes: {
  296. class: `ck-widget__resizer__handle ${ getResizerClass( currentPosition ) }`
  297. }
  298. } ).render() ) );
  299. }
  300. }
  301. /**
  302. * Sets up the {@link #_sizeUI} property and adds it to the passed `domElement`.
  303. *
  304. * @private
  305. * @param {HTMLElement} domElement
  306. */
  307. _appendSizeUI( domElement ) {
  308. const sizeUI = new SizeView();
  309. // Make sure icon#element is rendered before passing to appendChild().
  310. sizeUI.render();
  311. this._sizeUI = sizeUI;
  312. domElement.appendChild( sizeUI.element );
  313. }
  314. /**
  315. * Determines the position of a given resize handle.
  316. *
  317. * @private
  318. * @param {HTMLElement} domHandle Handler used to calculate the reference point.
  319. * @returns {String|undefined} Returns a string like `"top-left"` or `undefined` if not matched.
  320. */
  321. _getHandlePosition( domHandle ) {
  322. const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
  323. for ( const position of resizerPositions ) {
  324. if ( domHandle.classList.contains( getResizerClass( position ) ) ) {
  325. return position;
  326. }
  327. }
  328. }
  329. /**
  330. * @event begin
  331. */
  332. /**
  333. * @event updateSize
  334. */
  335. /**
  336. * @event commit
  337. */
  338. /**
  339. * @event cancel
  340. */
  341. }
  342. mix( Resizer, ObservableMixin );
  343. /**
  344. * A view displaying the proposed new element size during the resizing.
  345. *
  346. * @extends {module:ui/view~View}
  347. */
  348. class SizeView extends View {
  349. constructor() {
  350. super();
  351. const bind = this.bindTemplate;
  352. this.setTemplate( {
  353. tag: 'div',
  354. attributes: {
  355. class: [
  356. 'ck',
  357. 'ck-size-view',
  358. bind.to( 'activeHandlePosition', value => value ? `ck-orientation-${ value }` : '' )
  359. ],
  360. style: {
  361. display: bind.if( 'isVisible', 'none', visible => !visible )
  362. }
  363. },
  364. children: [ {
  365. text: bind.to( 'label' )
  366. } ]
  367. } );
  368. }
  369. bindToState( options, resizerState ) {
  370. this.bind( 'isVisible' ).to( resizerState, 'proposedWidth', resizerState, 'proposedHeight', ( width, height ) =>
  371. width !== null && height !== null );
  372. this.bind( 'label' ).to(
  373. resizerState, 'proposedHandleHostWidth',
  374. resizerState, 'proposedHandleHostHeight',
  375. resizerState, 'proposedWidthPercents',
  376. ( width, height, widthPercents ) => {
  377. if ( options.unit === 'px' ) {
  378. return `${ width }×${ height }`;
  379. } else {
  380. return `${ widthPercents }%`;
  381. }
  382. }
  383. );
  384. this.bind( 'activeHandlePosition' ).to( resizerState );
  385. }
  386. dismiss() {
  387. this.unbind();
  388. this.isVisible = false;
  389. }
  390. }
  391. // @private
  392. // @param {String} resizerPosition Expected resizer position like `"top-left"`, `"bottom-right"`.
  393. // @returns {String} A prefixed HTML class name for the resizer element
  394. function getResizerClass( resizerPosition ) {
  395. return `ck-widget__resizer__handle-${ resizerPosition }`;
  396. }
  397. function extractCoordinates( event ) {
  398. return {
  399. x: event.pageX,
  400. y: event.pageY
  401. };
  402. }