resizer.js 15 KB

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