8
0

resizer.js 14 KB

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