resizecontext.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. import View from '@ckeditor/ckeditor5-ui/src/view';
  2. import {
  3. getAbsoluteBoundaryPoint
  4. } from './utils';
  5. import Template from '@ckeditor/ckeditor5-ui/src/template';
  6. import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
  7. import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
  8. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  9. /**
  10. * @module widget/resizecontext
  11. */
  12. const WIDTH_ATTRIBUTE_NAME = 'width';
  13. /**
  14. * Stores the internal state of a single resizable object.
  15. *
  16. * @class ResizeContext
  17. */
  18. export default class ResizeContext {
  19. constructor( options ) {
  20. /**
  21. * View of a widget associated with the resizer.
  22. *
  23. * @member {module:engine/view/element~Element}
  24. */
  25. this.widgetWrapperElement = null;
  26. /**
  27. * Container of entire resize UI.
  28. *
  29. * Note that this property is initialized only after the element bound with resizer is drawn
  30. * so it will be a `null` when uninitialized.
  31. *
  32. * @member {HTMLElement|null}
  33. */
  34. this.domResizeWrapper = null;
  35. /**
  36. * The size of resize host before current resize process.
  37. *
  38. * This information is only known after DOM was rendered, so it will be updated later.
  39. */
  40. this.originalSize = {
  41. x: 0,
  42. y: 0
  43. };
  44. /**
  45. * @member {module:widget/widgetresizer~ResizerOptions}
  46. */
  47. this._options = options || {};
  48. /**
  49. * Reference point of resizer where the dragging started. It is used to measure the distance to user cursor
  50. * traveled, thus how much the image should be enlarged.
  51. * This information is only known after DOM was rendered, so it will be updated later.
  52. *
  53. * @protected
  54. */
  55. this._referenceCoordinates = {
  56. y: 0,
  57. x: 0
  58. };
  59. /**
  60. * View to a wrapper containing all the resizer-related views.
  61. *
  62. * @private
  63. * @member {module:engine/view/uielement~UIElement}
  64. */
  65. this._resizeWrapperElement = null;
  66. /**
  67. * @private
  68. * @member {HTMLElement|null}
  69. */
  70. this._domResizeShadow = null;
  71. this._cleanupContext();
  72. this.decorate( 'begin' );
  73. this.decorate( 'cancel' );
  74. this.decorate( 'commit' );
  75. this.decorate( 'updateSize' );
  76. /**
  77. * Width proposed (but not yet accepted) using the widget resizer.
  78. *
  79. * It goes back to `null` once the resizer is dismissed or accepted.
  80. *
  81. * @readonly
  82. * @observable
  83. * @member {Number|null} #proposedX
  84. */
  85. /**
  86. * Height proposed (but not yet accepted) using the widget resizer.
  87. *
  88. * It goes back to `null` once the resizer is dismissed or accepted.
  89. *
  90. * @readonly
  91. * @observable
  92. * @member {Number|null} #proposedY
  93. */
  94. /**
  95. * Direction towards which the widget is being resized, e.g. `"top-left"`, `"bottom-right"` etc or `null`
  96. * if not active.
  97. *
  98. * @readonly
  99. * @observable
  100. * @member {String|null} #orientation
  101. */
  102. }
  103. /**
  104. * Method to be called to attach a resizer to a given widget element.
  105. *
  106. * @param {module:engine/view/element~Element} widgetElement Widget's wrapper.
  107. * @param {module:engine/view/downcastwriter~DowncastWriter} writer
  108. */
  109. attach( widgetElement, writer ) {
  110. const that = this;
  111. this.widgetWrapperElement = widgetElement;
  112. this._resizeWrapperElement = writer.createUIElement( 'div', {
  113. class: 'ck ck-widget__resizer-wrapper'
  114. }, function( domDocument ) {
  115. const domElement = this.toDomElement( domDocument );
  116. that._domResizeShadow = that._appendShadowElement( domElement );
  117. that._appendResizers( that._domResizeShadow );
  118. that._appendSizeUi( that._domResizeShadow );
  119. that.domResizeWrapper = domElement;
  120. return domElement;
  121. } );
  122. // Append resizer wrapper to the widget's wrapper.
  123. writer.insert( writer.createPositionAt( widgetElement, widgetElement.childCount ), this._resizeWrapperElement );
  124. writer.addClass( [ 'ck-widget_with-resizer' ], widgetElement );
  125. }
  126. /**
  127. *
  128. * @param {HTMLElement} domResizeHandler Handler used to calculate reference point.
  129. */
  130. begin( domResizeHandler ) {
  131. const resizeHost = this._getResizeHost();
  132. const clientRect = new Rect( resizeHost );
  133. this._domResizeShadow.classList.add( 'ck-widget__resizer-shadow-active' );
  134. /**
  135. * Position of the handler that has initiated the resizing. E.g. `"top-left"`, `"bottom-right"` etc or `null`
  136. * if unknown.
  137. *
  138. * @member {String|null}
  139. */
  140. this.referenceHandlerPosition = this._getResizerPosition( domResizeHandler );
  141. this.set( 'orientation', this.referenceHandlerPosition );
  142. const reversedPosition = this._invertPosition( this.referenceHandlerPosition );
  143. this._referenceCoordinates = getAbsoluteBoundaryPoint( resizeHost, reversedPosition );
  144. this.originalSize = {
  145. width: clientRect.width,
  146. height: clientRect.height
  147. };
  148. this.aspectRatio = this._options.getAspectRatio ?
  149. this._options.getAspectRatio( resizeHost ) : clientRect.width / clientRect.height;
  150. this.redraw();
  151. }
  152. /**
  153. * Accepts currently proposed resize and applies it on the resize host.
  154. *
  155. * @param {module:core/editor/editor~Editor} editor
  156. */
  157. commit( editor ) {
  158. const modelEntry = this._getModel( editor, this.widgetWrapperElement );
  159. const newWidth = this._domResizeShadow.clientWidth;
  160. this._dismissShadow();
  161. this.redraw();
  162. editor.model.change( writer => {
  163. writer.setAttribute( WIDTH_ATTRIBUTE_NAME, newWidth, modelEntry );
  164. } );
  165. this._cleanupContext();
  166. }
  167. /**
  168. * Cancels and rejects proposed resize dimensions hiding all the UI.
  169. */
  170. cancel() {
  171. this._dismissShadow();
  172. this._cleanupContext();
  173. }
  174. destroy() {
  175. this.cancel();
  176. this._domResizeShadow = null;
  177. this.wrapper = null;
  178. }
  179. /**
  180. * Method used to calculate the proposed size as the resize handlers are dragged.
  181. *
  182. * Proposed size can also be observed with {@link #proposedX} and {@link #proposedY} properties.
  183. *
  184. * @param {Event} domEventData Event data that caused the size update request. It should be used to calculate the proposed size.
  185. */
  186. updateSize( domEventData ) {
  187. const proposedSize = this._updateImageSize( domEventData );
  188. this.domResizeWrapper.style.width = proposedSize.x + 'px';
  189. this.domResizeWrapper.style.height = proposedSize.y + 'px';
  190. this.set( {
  191. proposedX: proposedSize.x,
  192. proposedY: proposedSize.y
  193. } );
  194. }
  195. redraw() {
  196. const domWrapper = this.domResizeWrapper;
  197. if ( existsInDom( domWrapper ) ) {
  198. // Refresh only if resizer exists in the DOM.
  199. const widgetWrapper = domWrapper.parentElement;
  200. const resizingHost = this._getResizeHost();
  201. const clientRect = new Rect( resizingHost );
  202. domWrapper.style.width = clientRect.width + 'px';
  203. domWrapper.style.height = clientRect.height + 'px';
  204. // In case a resizing host is not a widget wrapper, we need to compensate
  205. // for any additional offsets the resize host might have. E.g. wrapper padding
  206. // or simply another editable. By doing that the border and resizers are shown
  207. // only around the resize host.
  208. if ( !widgetWrapper.isSameNode( resizingHost ) ) {
  209. domWrapper.style.left = resizingHost.offsetLeft + 'px';
  210. domWrapper.style.top = resizingHost.offsetTop + 'px';
  211. domWrapper.style.height = resizingHost.offsetHeight + 'px';
  212. domWrapper.style.width = resizingHost.offsetWidth + 'px';
  213. }
  214. }
  215. function existsInDom( element ) {
  216. return element && element.ownerDocument && element.ownerDocument.contains( element );
  217. }
  218. }
  219. /**
  220. * Cleans up the context state.
  221. *
  222. * @protected
  223. */
  224. _cleanupContext() {
  225. this.referenceHandlerPosition = null;
  226. this.set( {
  227. proposedX: null,
  228. proposedY: null,
  229. orientation: null
  230. } );
  231. }
  232. /**
  233. * Method used to obtain the resize host.
  234. *
  235. * Resize host is an object that is actually resized.
  236. *
  237. * Resize host will not always be an entire widget itself. Take an image as an example. Image widget
  238. * contains an image and caption. Only the image should be used to resize the widget, while the caption
  239. * will simply follow the image size.
  240. *
  241. * @protected
  242. * @returns {HTMLElement}
  243. */
  244. _getResizeHost() {
  245. const widgetWrapper = this.domResizeWrapper.parentElement;
  246. return this._options.getResizeHost ?
  247. this._options.getResizeHost( widgetWrapper ) : widgetWrapper;
  248. }
  249. /**
  250. * @protected
  251. */
  252. _dismissShadow() {
  253. this._domResizeShadow.classList.remove( 'ck-widget__resizer-shadow-active' );
  254. this._domResizeShadow.removeAttribute( 'style' );
  255. }
  256. /**
  257. *
  258. * @param {module:core/editor/editor~Editor} editor
  259. * @param {module:engine/view/element~Element} widgetWrapperElement
  260. * @returns {module:engine/model/element~Element|undefined}
  261. * @protected
  262. */
  263. _getModel( editor, widgetWrapperElement ) {
  264. return editor.editing.mapper.toModelElement( widgetWrapperElement );
  265. }
  266. /**
  267. * @param {String} position Like `"top-left"`.
  268. * @returns {String} Inverted `position`, e.g. returns `"bottom-right"` if `"top-left"` was given as `position`.
  269. * @protected
  270. */
  271. _invertPosition( position ) {
  272. const parts = position.split( '-' );
  273. const replacements = {
  274. top: 'bottom',
  275. bottom: 'top',
  276. left: 'right',
  277. right: 'left'
  278. };
  279. return `${ replacements[ parts[ 0 ] ] }-${ replacements[ parts[ 1 ] ] }`;
  280. }
  281. /**
  282. * @private
  283. * @param {HTMLElement} domElement The outer wrapper of resize UI within a given widget.
  284. */
  285. _appendShadowElement( domElement ) {
  286. const shadowElement = new Template( {
  287. tag: 'div',
  288. attributes: {
  289. class: 'ck ck-widget__resizer-shadow'
  290. }
  291. } ).render();
  292. domElement.appendChild( shadowElement );
  293. return shadowElement;
  294. }
  295. /**
  296. * Renders the resize handlers in DOM.
  297. *
  298. * @private
  299. * @param {HTMLElement} domElement Resize shadow where the resizers should be appended to.
  300. */
  301. _appendResizers( domElement ) {
  302. const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
  303. for ( const currentPosition of resizerPositions ) {
  304. domElement.appendChild( ( new Template( {
  305. tag: 'div',
  306. attributes: {
  307. class: `ck-widget__resizer ${ this._getResizerClass( currentPosition ) }`
  308. }
  309. } ).render() ) );
  310. }
  311. }
  312. /**
  313. * @private
  314. * @param {HTMLElement} domElement
  315. */
  316. _appendSizeUi( domElement ) {
  317. const sizeUi = new SizeView();
  318. sizeUi.bind( 'isVisible' ).to( this, 'proposedX', this, 'proposedY', ( x, y ) =>
  319. x !== null && y !== null );
  320. sizeUi.bind( 'label' ).to( this, 'proposedX', this, 'proposedY', ( x, y ) =>
  321. `${ Math.round( x ) }x${ Math.round( y ) }` );
  322. sizeUi.bind( 'orientation' ).to( this );
  323. // Make sure icon#element is rendered before passing to appendChild().
  324. sizeUi.render();
  325. this.sizeElement = sizeUi.element;
  326. domElement.appendChild( this.sizeElement );
  327. }
  328. /**
  329. * Method used to calculate the proposed size as the resize handlers are dragged.
  330. *
  331. * @private
  332. * @param {Event} domEventData Event data that caused the size update request. It should be used to calculate the proposed size.
  333. * @returns {Object} return
  334. * @returns {Number} return.x Proposed width.
  335. * @returns {Number} return.y Proposed height.
  336. */
  337. _updateImageSize( domEventData ) {
  338. const currentCoordinates = this._extractCoordinates( domEventData );
  339. const isCentered = this._options.isCentered ? this._options.isCentered( this ) : true;
  340. const initialSize = this.originalSize;
  341. // Enlargement defines how much the resize host has changed in a given axis. Naturally it could be a negative number
  342. // meaning that it has been shrunk.
  343. //
  344. // +----------------+--+
  345. // | | |
  346. // | img | |
  347. // | | |
  348. // +----------------+ | ^
  349. // | | | - enlarge y
  350. // +-------------------+ v
  351. // <-->
  352. // enlarge x
  353. const enlargement = {
  354. x: this._referenceCoordinates.x - ( currentCoordinates.x + initialSize.width ),
  355. y: ( currentCoordinates.y - initialSize.height ) - this._referenceCoordinates.y
  356. };
  357. if ( isCentered && this.referenceHandlerPosition.endsWith( '-right' ) ) {
  358. enlargement.x = currentCoordinates.x - ( this._referenceCoordinates.x + initialSize.width );
  359. }
  360. // Objects needs to be resized twice as much in horizontal axis if centered, since enlargement is counted from
  361. // one resized corner to your cursor. It needs to be duplicated to compensate for the other side too.
  362. if ( isCentered ) {
  363. enlargement.x *= 2;
  364. }
  365. const resizeHost = this._getResizeHost();
  366. const proposedSize = {
  367. x: Math.abs( initialSize.width + enlargement.x ),
  368. y: Math.abs( initialSize.height + enlargement.y )
  369. };
  370. // Dominant determination must take the ratio into account.
  371. proposedSize.dominant = proposedSize.x / this.aspectRatio > proposedSize.y ? 'x' : 'y';
  372. proposedSize.max = proposedSize[ proposedSize.dominant ];
  373. const drawnSize = {
  374. x: proposedSize.x,
  375. y: proposedSize.y
  376. };
  377. if ( proposedSize.dominant == 'x' ) {
  378. drawnSize.y = drawnSize.x / this.aspectRatio;
  379. } else {
  380. drawnSize.x = drawnSize.y * this.aspectRatio;
  381. }
  382. resizeHost.style.width = `${ drawnSize.x }px`;
  383. // Refresh values based on real image. Real image might be limited by max-width, and thus fetching it
  384. // here will reflect this limitation on resizer shadow later on.
  385. const latestRect = new Rect( resizeHost );
  386. drawnSize.x = latestRect.width;
  387. drawnSize.y = latestRect.height;
  388. return drawnSize;
  389. }
  390. /**
  391. * @private
  392. */
  393. _extractCoordinates( event ) {
  394. return {
  395. x: event.pageX,
  396. y: event.pageY
  397. };
  398. }
  399. /**
  400. * @param {String} resizerPosition Expected resizer position like `"top-left"`, `"bottom-right"`.
  401. * @returns {String} A prefixed HTML class name for the resizer element
  402. * @private
  403. */
  404. _getResizerClass( resizerPosition ) {
  405. return `ck-widget__resizer-${ resizerPosition }`;
  406. }
  407. /**
  408. * Determines the position of a given resize handler.
  409. *
  410. * @private
  411. * @param {HTMLElement} domResizeHandler Handler used to calculate reference point.
  412. * @returns {String|undefined} Returns a string like `"top-left"` or `undefined` if not matched.
  413. */
  414. _getResizerPosition( domResizeHandler ) {
  415. const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
  416. for ( const position of resizerPositions ) {
  417. if ( domResizeHandler.classList.contains( this._getResizerClass( position ) ) ) {
  418. return position;
  419. }
  420. }
  421. }
  422. }
  423. mix( ResizeContext, ObservableMixin );
  424. class SizeView extends View {
  425. constructor() {
  426. super();
  427. const bind = this.bindTemplate;
  428. this.setTemplate( {
  429. tag: 'div',
  430. attributes: {
  431. class: [
  432. 'ck',
  433. 'ck-size-view',
  434. bind.to( 'orientation', value => value ? `ck-orientation-${ value }` : '' )
  435. ],
  436. style: {
  437. display: bind.if( 'isVisible', 'none', visible => !visible )
  438. }
  439. },
  440. children: [ {
  441. text: bind.to( 'label' )
  442. } ]
  443. } );
  444. }
  445. }