resizecontext.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import View from '@ckeditor/ckeditor5-ui/src/view';
  2. import ResizerTopBound from './resizertopbound';
  3. import { getAbsoluteBoundaryPoint } from './utils';
  4. import Template from '@ckeditor/ckeditor5-ui/src/template';
  5. import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
  6. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  7. /**
  8. * @module widget/resizecontext
  9. */
  10. const WIDTH_ATTRIBUTE_NAME = 'width';
  11. /**
  12. * Stores the internal state of a single resizable object.
  13. *
  14. * @class ResizeContext
  15. */
  16. export default class ResizeContext {
  17. constructor( options ) {
  18. /**
  19. * View to a wrapper containing all the resizer-related views.
  20. *
  21. * @member {module:engine/view/uielement~UIElement}
  22. */
  23. this.resizeWrapperElement = null;
  24. /**
  25. * View of a widget associated with the resizer.
  26. *
  27. * @member {module:engine/view/element~Element}
  28. */
  29. this.widgetWrapperElement = null;
  30. this.resizeStrategy = new ResizerTopBound( this, options );
  31. /**
  32. * Container of entire resize UI.
  33. *
  34. * Note that this property is initialized only after the element bound with resizer is drawn
  35. * so it will be a `null` when uninitialized.
  36. *
  37. * @member {HTMLElement|null}
  38. */
  39. this.domResizeWrapper = null;
  40. /**
  41. * @member {HTMLElement|null}
  42. */
  43. this.domResizeShadow = null;
  44. this.options = options || {};
  45. /**
  46. * The size of resize host before current resize process.
  47. *
  48. * This information is only known after DOM was rendered, so it will be updated later.
  49. */
  50. this.originalSize = {
  51. x: 0,
  52. y: 0
  53. };
  54. /**
  55. * Reference point of resizer where the dragging started. It is used to measure the distance to user cursor
  56. * traveled, thus how much the image should be enlarged.
  57. * This information is only known after DOM was rendered, so it will be updated later.
  58. *
  59. * @protected
  60. */
  61. this.referenceCoordinates = {
  62. y: 0,
  63. x: 0
  64. };
  65. this._cleanupContext();
  66. /**
  67. * Width proposed (but not yet accepted) using the widget resizer.
  68. *
  69. * It goes back to `null` once the resizer is dismissed or accepted.
  70. *
  71. * @readonly
  72. * @observable
  73. * @member {Number|null} #proposedX
  74. */
  75. /**
  76. * Height proposed (but not yet accepted) using the widget resizer.
  77. *
  78. * It goes back to `null` once the resizer is dismissed or accepted.
  79. *
  80. * @readonly
  81. * @observable
  82. * @member {Number|null} #proposedY
  83. */
  84. /**
  85. * Position of a handler that has initiated the resizing. E.g. `"top-left"`, `"bottom-right"` etc or `null`
  86. * if not active.
  87. *
  88. * @readonly
  89. * @observable
  90. * @member {String|null} #orientation
  91. */
  92. }
  93. /**
  94. * Method to be called to attach a resizer to a given widget element.
  95. *
  96. * @param {module:engine/view/element~Element} widgetElement Widget's wrapper.
  97. * @param {module:engine/view/downcastwriter~DowncastWriter} writer
  98. */
  99. attach( widgetElement, writer ) {
  100. const that = this;
  101. this.widgetWrapperElement = widgetElement;
  102. this.resizeWrapperElement = writer.createUIElement( 'div', {
  103. class: 'ck ck-widget__resizer-wrapper'
  104. }, function( domDocument ) {
  105. const domElement = this.toDomElement( domDocument );
  106. that.domResizeShadow = that._appendShadowElement( domDocument, domElement );
  107. that._appendResizers( that.domResizeShadow );
  108. that._appendSizeUi( that.domResizeShadow );
  109. that.domResizeWrapper = domElement;
  110. return domElement;
  111. } );
  112. // Append resizer wrapper to the widget's wrapper.
  113. writer.insert( writer.createPositionAt( widgetElement, widgetElement.childCount ), this.resizeWrapperElement );
  114. writer.addClass( [ 'ck-widget_with-resizer' ], widgetElement );
  115. }
  116. /**
  117. *
  118. * @param {HTMLElement} domResizeHandler Handler used to calculate reference point.
  119. */
  120. begin( domResizeHandler ) {
  121. const resizeHost = this._getResizeHost();
  122. const clientRect = resizeHost.getBoundingClientRect();
  123. this.domResizeShadow.classList.add( 'ck-widget__resizer-shadow-active' );
  124. /**
  125. * Position of the handler that has initiated the resizing. E.g. `"top-left"`, `"bottom-right"` etc or `null`
  126. * if unknown.
  127. *
  128. * @member {String|null}
  129. */
  130. this.referenceHandlerPosition = this._getResizerPosition( domResizeHandler );
  131. this.set( 'orientation', this.referenceHandlerPosition );
  132. const reversedPosition = this._invertPosition( this.referenceHandlerPosition );
  133. this.referenceCoordinates = getAbsoluteBoundaryPoint( resizeHost, reversedPosition );
  134. this.originalSize = {
  135. width: clientRect.width,
  136. height: clientRect.height
  137. };
  138. this.aspectRatio = this.options.getAspectRatio ?
  139. this.options.getAspectRatio( resizeHost ) : clientRect.width / clientRect.height;
  140. this.resizeStrategy.begin( domResizeHandler );
  141. }
  142. commit( editor ) {
  143. const modelEntry = this._getModel( editor, this.widgetWrapperElement );
  144. const newWidth = this.domResizeShadow.clientWidth;
  145. this._dismissShadow();
  146. this.redraw();
  147. this.resizeStrategy.commit( editor );
  148. editor.model.change( writer => {
  149. writer.setAttribute( WIDTH_ATTRIBUTE_NAME, newWidth, modelEntry );
  150. } );
  151. this._cleanupContext();
  152. }
  153. cancel() {
  154. this._dismissShadow();
  155. this.resizeStrategy.cancel();
  156. this._cleanupContext();
  157. }
  158. destroy() {
  159. this.cancel();
  160. this.domResizeShadow = null;
  161. this.wrapper = null;
  162. }
  163. /**
  164. * Method used to calculate the proposed size as the resize handlers are dragged.
  165. *
  166. * Proposed size can also be observed with {@link #proposedX} and {@link #proposedY} properties.
  167. *
  168. * @param {Event} domEventData Event data that caused the size update request. It should be used to calculate the proposed size.
  169. */
  170. updateSize( domEventData ) {
  171. const proposedSize = this.resizeStrategy.updateSize( domEventData );
  172. this.domResizeWrapper.style.width = proposedSize.x + 'px';
  173. this.domResizeWrapper.style.height = proposedSize.y + 'px';
  174. this.set( {
  175. proposedX: proposedSize.x,
  176. proposedY: proposedSize.y
  177. } );
  178. }
  179. redraw() {
  180. if ( this.domResizeWrapper ) {
  181. const widgetWrapper = this.domResizeWrapper.parentElement;
  182. const resizingHost = this._getResizeHost();
  183. if ( !widgetWrapper.isSameNode( resizingHost ) ) {
  184. this.domResizeWrapper.style.left = resizingHost.offsetLeft + 'px';
  185. this.domResizeWrapper.style.top = resizingHost.offsetTop + 'px';
  186. this.domResizeWrapper.style.height = resizingHost.offsetHeight + 'px';
  187. this.domResizeWrapper.style.width = resizingHost.offsetWidth + 'px';
  188. }
  189. }
  190. }
  191. /**
  192. * Cleans up the context state.
  193. *
  194. * @protected
  195. */
  196. _cleanupContext() {
  197. this.referenceHandlerPosition = null;
  198. this.set( {
  199. proposedX: null,
  200. proposedY: null,
  201. orientation: null
  202. } );
  203. }
  204. /**
  205. * Method used to obtain the resize host.
  206. *
  207. * Resize host is an object that is actually resized.
  208. *
  209. * Resize host will not always be an entire widget itself. Take an image as an example. Image widget
  210. * contains an image and caption. Only the image should be used to resize the widget, while the caption
  211. * will simply follow the image size.
  212. *
  213. * @protected
  214. */
  215. _getResizeHost() {
  216. const widgetWrapper = this.domResizeWrapper.parentElement;
  217. return this.options.getResizeHost ?
  218. this.options.getResizeHost( widgetWrapper ) : widgetWrapper;
  219. }
  220. /**
  221. * @private
  222. * @param {HTMLDocument} domDocument Document where the widget is used.
  223. * @param {HTMLElement} domElement The outer wrapper of resize UI within a given widget.
  224. */
  225. _appendShadowElement( domDocument, domElement ) {
  226. const shadowElement = domDocument.createElement( 'div' );
  227. shadowElement.setAttribute( 'class', 'ck ck-widget__resizer-shadow' );
  228. domElement.appendChild( shadowElement );
  229. return shadowElement;
  230. }
  231. /**
  232. * Renders the resize handlers in DOM.
  233. *
  234. * @private
  235. * @param {HTMLElement} domElement Resize shadow where the resizers should be appended to.
  236. */
  237. _appendResizers( domElement ) {
  238. const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
  239. for ( const currentPosition of resizerPositions ) {
  240. domElement.appendChild( ( new Template( {
  241. tag: 'div',
  242. attributes: {
  243. class: `ck-widget__resizer ${ this._getResizerClass( currentPosition ) }`
  244. }
  245. } ).render() ) );
  246. }
  247. }
  248. /**
  249. * @private
  250. * @param {HTMLElement} domElement
  251. */
  252. _appendSizeUi( domElement ) {
  253. const sizeUi = new SizeView();
  254. sizeUi.bind( 'isVisible' ).to( this, 'proposedX', this, 'proposedY', ( x, y ) =>
  255. x !== null && y !== null );
  256. sizeUi.bind( 'label' ).to( this, 'proposedX', this, 'proposedY', ( x, y ) =>
  257. `${ Math.round( x ) }x${ Math.round( y ) }` );
  258. sizeUi.bind( 'orientation' ).to( this );
  259. // Make sure icon#element is rendered before passing to appendChild().
  260. sizeUi.render();
  261. this.sizeElement = sizeUi.element;
  262. domElement.appendChild( this.sizeElement );
  263. }
  264. _dismissShadow() {
  265. this.domResizeShadow.classList.remove( 'ck-widget__resizer-shadow-active' );
  266. this.domResizeShadow.removeAttribute( 'style' );
  267. }
  268. /**
  269. *
  270. * @param {module:core/editor/editor~Editor} editor
  271. * @param {module:engine/view/element~Element} widgetWrapperElement
  272. * @returns {module:engine/model/element~Element|undefined}
  273. * @protected
  274. */
  275. _getModel( editor, widgetWrapperElement ) {
  276. return editor.editing.mapper.toModelElement( widgetWrapperElement );
  277. }
  278. _extractCoordinates( event ) {
  279. return {
  280. x: event.pageX,
  281. y: event.pageY
  282. };
  283. }
  284. /**
  285. * @param {String} resizerPosition Expected resizer position like `"top-left"`, `"bottom-right"`.
  286. * @returns {String} A prefixed HTML class name for the resizer element
  287. * @private
  288. */
  289. _getResizerClass( resizerPosition ) {
  290. return `ck-widget__resizer-${ resizerPosition }`;
  291. }
  292. /**
  293. * Determines the position of a given resize handler.
  294. *
  295. * @private
  296. * @param {HTMLElement} domResizeHandler Handler used to calculate reference point.
  297. * @returns {String|undefined} Returns a string like `"top-left"` or `undefined` if not matched.
  298. */
  299. _getResizerPosition( domResizeHandler ) {
  300. const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
  301. for ( const position of resizerPositions ) {
  302. if ( domResizeHandler.classList.contains( this._getResizerClass( position ) ) ) {
  303. return position;
  304. }
  305. }
  306. }
  307. /**
  308. * @param {String} position Like `"top-left"`.
  309. * @returns {String} Inverted `position`.
  310. * @protected
  311. */
  312. _invertPosition( position ) {
  313. const parts = position.split( '-' );
  314. const replacements = {
  315. top: 'bottom',
  316. bottom: 'top',
  317. left: 'right',
  318. right: 'left'
  319. };
  320. return `${ replacements[ parts[ 0 ] ] }-${ replacements[ parts[ 1 ] ] }`;
  321. }
  322. }
  323. mix( ResizeContext, ObservableMixin );
  324. class SizeView extends View {
  325. constructor() {
  326. super();
  327. const bind = this.bindTemplate;
  328. this.setTemplate( {
  329. tag: 'div',
  330. attributes: {
  331. class: [
  332. 'ck',
  333. 'ck-size-view',
  334. bind.to( 'orientation', value => value ? `ck-orientation-${ value }` : '' )
  335. ],
  336. style: {
  337. display: bind.if( 'isVisible', 'none', visible => !visible )
  338. }
  339. },
  340. children: [ {
  341. text: bind.to( 'label' )
  342. } ]
  343. } );
  344. }
  345. }