resizer.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  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. * Stores the internal state of a single resizable object.
  16. *
  17. * @class Resizer
  18. */
  19. export default class Resizer {
  20. /**
  21. * @param {module:widget/widgetresizer~ResizerOptions} options Resizer options.
  22. */
  23. constructor( options ) {
  24. /**
  25. * @readonly
  26. * @member {module:widget/widgetresizer/resizerstate~ResizerState} #state
  27. */
  28. /**
  29. * @private
  30. * @type {module:widget/widgetresizer~ResizerOptions}
  31. */
  32. this._options = options;
  33. /**
  34. * Container of the entire resize UI.
  35. *
  36. * Note that this property is initialized only after the element bound with the resizer is drawn
  37. * so it will be a `null` when uninitialized.
  38. *
  39. * @private
  40. * @type {HTMLElement|null}
  41. */
  42. this._domResizerWrapper = null;
  43. /**
  44. * View to a wrapper containing all the resizer-related views.
  45. *
  46. * @private
  47. * @type {module:engine/view/uielement~UIElement}
  48. */
  49. this._viewResizerWrapper = null;
  50. this.decorate( 'begin' );
  51. this.decorate( 'cancel' );
  52. this.decorate( 'commit' );
  53. this.decorate( 'updateSize' );
  54. }
  55. /**
  56. *
  57. */
  58. attach() {
  59. const that = this;
  60. const widgetElement = this._options.viewElement;
  61. const writer = this._options.downcastWriter;
  62. this._viewResizerWrapper = writer.createUIElement( 'div', {
  63. class: 'ck ck-reset_all ck-widget__resizer'
  64. }, function( domDocument ) {
  65. const domElement = this.toDomElement( domDocument );
  66. that._appendHandles( domElement );
  67. that._appendSizeUI( domElement );
  68. that._domResizerWrapper = domElement;
  69. return domElement;
  70. } );
  71. // Append resizer wrapper to the widget's wrapper.
  72. writer.insert( writer.createPositionAt( widgetElement, 'end' ), this._viewResizerWrapper );
  73. writer.addClass( 'ck-widget_with-resizer', widgetElement );
  74. }
  75. begin( domResizeHandle ) {
  76. this.state = new ResizeState( this._options );
  77. this.sizeUI.bindToState( this.state );
  78. this.state.begin( domResizeHandle, this._getResizeHost() );
  79. this.redraw();
  80. }
  81. updateSize( domEventData ) {
  82. const resizeHost = this._getResizeHost();
  83. const newSize = this._proposeNewSize( domEventData );
  84. resizeHost.style.width = newSize.width + 'px';
  85. this.state.fetchSizeFromElement( resizeHost );
  86. // Refresh values based on the real image. Real image might be limited by max-width, and thus fetching it
  87. // here will reflect this limitation.
  88. this._domResizerWrapper.style.width = this.state.proposedWidth + 'px';
  89. this._domResizerWrapper.style.height = this.state.proposedHeight + 'px';
  90. }
  91. commit() {
  92. this._options.onCommit( this.state );
  93. this._cleanup();
  94. }
  95. /**
  96. * Cancels and rejects proposed resize dimensions hiding all the UI.
  97. */
  98. cancel() {
  99. this._cleanup();
  100. }
  101. destroy() {
  102. this.cancel();
  103. }
  104. redraw() {
  105. const domWrapper = this._domResizerWrapper;
  106. if ( existsInDom( domWrapper ) ) {
  107. // Refresh only if resizer exists in the DOM.
  108. const widgetWrapper = domWrapper.parentElement;
  109. const resizingHost = this._getResizeHost();
  110. const clientRect = new Rect( resizingHost );
  111. domWrapper.style.width = clientRect.width + 'px';
  112. domWrapper.style.height = clientRect.height + 'px';
  113. // In case a resizing host is not a widget wrapper, we need to compensate
  114. // for any additional offsets the resize host might have. E.g. wrapper padding
  115. // or simply another editable. By doing that the border and resizers are shown
  116. // only around the resize host.
  117. if ( !widgetWrapper.isSameNode( resizingHost ) ) {
  118. domWrapper.style.left = resizingHost.offsetLeft + 'px';
  119. domWrapper.style.top = resizingHost.offsetTop + 'px';
  120. domWrapper.style.height = resizingHost.offsetHeight + 'px';
  121. domWrapper.style.width = resizingHost.offsetWidth + 'px';
  122. }
  123. }
  124. function existsInDom( element ) {
  125. return element && element.ownerDocument && element.ownerDocument.contains( element );
  126. }
  127. }
  128. containsHandle( domElement ) {
  129. return this._domResizerWrapper.contains( domElement );
  130. }
  131. static isResizeHandle( domElement ) {
  132. return domElement.classList.contains( 'ck-widget__resizer__handle' );
  133. }
  134. /**
  135. * Cleans up the context state.
  136. *
  137. * @protected
  138. */
  139. _cleanup() {
  140. this.sizeUI.dismiss();
  141. this.sizeUI.isVisible = false;
  142. }
  143. /**
  144. * Method used to calculate the proposed size as the resize handles are dragged.
  145. *
  146. * @private
  147. * @param {Event} domEventData Event data that caused the size update request. It should be used to calculate the proposed size.
  148. * @returns {Object} return
  149. * @returns {Number} return.x Proposed width.
  150. * @returns {Number} return.y Proposed height.
  151. */
  152. _proposeNewSize( domEventData ) {
  153. const state = this.state;
  154. const currentCoordinates = extractCoordinates( domEventData );
  155. const isCentered = this._options.isCentered ? this._options.isCentered( this ) : true;
  156. const originalSize = state.originalSize;
  157. // Enlargement defines how much the resize host has changed in a given axis. Naturally it could be a negative number
  158. // meaning that it has been shrunk.
  159. //
  160. // +----------------+--+
  161. // | | |
  162. // | img | |
  163. // | | |
  164. // +----------------+ | ^
  165. // | | | - enlarge y
  166. // +-------------------+ v
  167. // <-->
  168. // enlarge x
  169. const enlargement = {
  170. x: state._referenceCoordinates.x - ( currentCoordinates.x + originalSize.width ),
  171. y: ( currentCoordinates.y - originalSize.height ) - state._referenceCoordinates.y
  172. };
  173. if ( isCentered && state.activeHandlePosition.endsWith( '-right' ) ) {
  174. enlargement.x = currentCoordinates.x - ( state._referenceCoordinates.x + originalSize.width );
  175. }
  176. // Objects needs to be resized twice as much in horizontal axis if centered, since enlargement is counted from
  177. // one resized corner to your cursor. It needs to be duplicated to compensate for the other side too.
  178. if ( isCentered ) {
  179. enlargement.x *= 2;
  180. }
  181. // const resizeHost = this._getResizeHost();
  182. // The size proposed by the user. It does not consider the aspect ratio.
  183. const proposedSize = {
  184. width: Math.abs( originalSize.width + enlargement.x ),
  185. height: Math.abs( originalSize.height + enlargement.y )
  186. };
  187. // Dominant determination must take the ratio into account.
  188. proposedSize.dominant = proposedSize.width / state.aspectRatio > proposedSize.height ? 'width' : 'height';
  189. proposedSize.max = proposedSize[ proposedSize.dominant ];
  190. // Proposed size, respecting the aspect ratio.
  191. const targetSize = {
  192. width: proposedSize.width,
  193. height: proposedSize.height
  194. };
  195. if ( proposedSize.dominant == 'width' ) {
  196. targetSize.height = targetSize.width / state.aspectRatio;
  197. } else {
  198. targetSize.width = targetSize.height * state.aspectRatio;
  199. }
  200. return targetSize;
  201. }
  202. /**
  203. * Method used to obtain the resize host.
  204. *
  205. * Resize host is an object that is actually resized.
  206. *
  207. * Resize host will not always be an entire widget itself. Take an image as an example. Image widget
  208. * contains an image and caption. Only the image should be used to resize the widget, while the caption
  209. * will simply follow the image size.
  210. *
  211. * @protected
  212. * @returns {HTMLElement}
  213. */
  214. _getResizeHost() {
  215. const widgetWrapper = this._domResizerWrapper.parentElement;
  216. return this._options.getResizeHost ?
  217. this._options.getResizeHost( widgetWrapper ) : widgetWrapper;
  218. }
  219. /**
  220. * Renders the resize handles in DOM.
  221. *
  222. * @private
  223. * @param {HTMLElement} domElement The resizer wrapper.
  224. */
  225. _appendHandles( domElement ) {
  226. const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
  227. for ( const currentPosition of resizerPositions ) {
  228. domElement.appendChild( ( new Template( {
  229. tag: 'div',
  230. attributes: {
  231. class: `ck-widget__resizer__handle ${ getResizerClass( currentPosition ) }`
  232. }
  233. } ).render() ) );
  234. }
  235. }
  236. /**
  237. * @private
  238. * @param {HTMLElement} domElement
  239. */
  240. _appendSizeUI( domElement ) {
  241. const sizeUI = new SizeView();
  242. // Make sure icon#element is rendered before passing to appendChild().
  243. sizeUI.render();
  244. this.sizeUI = sizeUI;
  245. domElement.appendChild( sizeUI.element );
  246. }
  247. /**
  248. * Determines the position of a given resize handle.
  249. *
  250. * @private
  251. * @param {HTMLElement} domHandle Handler used to calculate reference point.
  252. * @returns {String|undefined} Returns a string like `"top-left"` or `undefined` if not matched.
  253. */
  254. _getHandlePosition( domHandle ) {
  255. const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
  256. for ( const position of resizerPositions ) {
  257. if ( domHandle.classList.contains( getResizerClass( position ) ) ) {
  258. return position;
  259. }
  260. }
  261. }
  262. }
  263. mix( Resizer, ObservableMixin );
  264. class SizeView extends View {
  265. constructor() {
  266. super();
  267. const bind = this.bindTemplate;
  268. this.setTemplate( {
  269. tag: 'div',
  270. attributes: {
  271. class: [
  272. 'ck',
  273. 'ck-size-view',
  274. bind.to( 'activeHandlePosition', value => value ? `ck-orientation-${ value }` : '' )
  275. ],
  276. style: {
  277. display: bind.if( 'isVisible', 'none', visible => !visible )
  278. }
  279. },
  280. children: [ {
  281. text: bind.to( 'label' )
  282. } ]
  283. } );
  284. }
  285. bindToState( resizerState ) {
  286. this.bind( 'isVisible' ).to( resizerState, 'proposedWidth', resizerState, 'proposedHeight', ( x, y ) =>
  287. x !== null && y !== null );
  288. this.bind( 'label' ).to( resizerState, 'proposedWidth', resizerState, 'proposedHeight', ( x, y ) =>
  289. `${ Math.round( x ) }×${ Math.round( y ) }` );
  290. this.bind( 'activeHandlePosition' ).to( resizerState );
  291. }
  292. dismiss() {
  293. this.unbind();
  294. this.isVisible = false;
  295. }
  296. }
  297. // @param {String} resizerPosition Expected resizer position like `"top-left"`, `"bottom-right"`.
  298. // @returns {String} A prefixed HTML class name for the resizer element
  299. function getResizerClass( resizerPosition ) {
  300. return `ck-widget__resizer__handle-${ resizerPosition }`;
  301. }
  302. function extractCoordinates( event ) {
  303. return {
  304. x: event.pageX,
  305. y: event.pageY
  306. };
  307. }