tablepropertiesui.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  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 table/tableproperties/tablepropertiesui
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
  10. import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
  11. import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
  12. import TablePropertiesView from './ui/tablepropertiesview';
  13. import tableProperties from './../../theme/icons/table-properties.svg';
  14. import {
  15. colorFieldValidator,
  16. getLocalizedColorErrorText,
  17. getLocalizedLengthErrorText,
  18. lengthFieldValidator,
  19. lineWidthFieldValidator,
  20. defaultColors
  21. } from '../utils/ui/table-properties';
  22. import {
  23. getLocalizedColorOptions,
  24. normalizeColorOptions
  25. } from '@ckeditor/ckeditor5-ui/src/colorgrid/utils';
  26. import { debounce } from 'lodash-es';
  27. import { getTableWidgetAncestor } from '../utils/ui/widget';
  28. import { getBalloonTablePositionData, repositionContextualBalloon } from '../utils/ui/contextualballoon';
  29. const ERROR_TEXT_TIMEOUT = 500;
  30. // Map of view properties and related commands.
  31. const propertyToCommandMap = {
  32. borderStyle: 'tableBorderStyle',
  33. borderColor: 'tableBorderColor',
  34. borderWidth: 'tableBorderWidth',
  35. backgroundColor: 'tableBackgroundColor',
  36. width: 'tableWidth',
  37. height: 'tableHeight',
  38. alignment: 'tableAlignment'
  39. };
  40. /**
  41. * The table properties UI plugin. It introduces the `'tableProperties'` button
  42. * that opens a form allowing to specify visual styling of an entire table.
  43. *
  44. * It uses the
  45. * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
  46. *
  47. * @extends module:core/plugin~Plugin
  48. */
  49. export default class TablePropertiesUI extends Plugin {
  50. /**
  51. * @inheritDoc
  52. */
  53. static get requires() {
  54. return [ ContextualBalloon ];
  55. }
  56. /**
  57. * @inheritDoc
  58. */
  59. static get pluginName() {
  60. return 'TablePropertiesUI';
  61. }
  62. /**
  63. * @inheritDoc
  64. */
  65. constructor( editor ) {
  66. super( editor );
  67. editor.config.define( 'table.tableProperties', {
  68. borderColors: defaultColors,
  69. backgroundColors: defaultColors
  70. } );
  71. }
  72. /**
  73. * @inheritDoc
  74. */
  75. init() {
  76. const editor = this.editor;
  77. const t = editor.t;
  78. /**
  79. * The contextual balloon plugin instance.
  80. *
  81. * @private
  82. * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
  83. */
  84. this._balloon = editor.plugins.get( ContextualBalloon );
  85. /**
  86. * The properties form view displayed inside the balloon.
  87. *
  88. * @member {module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView}
  89. */
  90. this.view = this._createPropertiesView();
  91. /**
  92. * The batch used to undo all changes made by the form (which are live, as the user types)
  93. * when "Cancel" was pressed. Each time the view is shown, a new batch is created.
  94. *
  95. * @protected
  96. * @member {module:engine/model/batch~Batch}
  97. */
  98. this._undoStepBatch = null;
  99. editor.ui.componentFactory.add( 'tableProperties', locale => {
  100. const view = new ButtonView( locale );
  101. view.set( {
  102. label: t( 'Table properties' ),
  103. icon: tableProperties,
  104. tooltip: true
  105. } );
  106. this.listenTo( view, 'execute', () => this._showView() );
  107. const commands = Object.values( propertyToCommandMap )
  108. .map( commandName => editor.commands.get( commandName ) );
  109. view.bind( 'isEnabled' ).toMany( commands, 'isEnabled', ( ...areEnabled ) => (
  110. areEnabled.some( isCommandEnabled => isCommandEnabled )
  111. ) );
  112. return view;
  113. } );
  114. }
  115. /**
  116. * @inheritDoc
  117. */
  118. destroy() {
  119. super.destroy();
  120. // Destroy created UI components as they are not automatically destroyed.
  121. // See https://github.com/ckeditor/ckeditor5/issues/1341.
  122. this.view.destroy();
  123. }
  124. /**
  125. * Creates the {@link module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView} instance.
  126. *
  127. * @private
  128. * @returns {module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView} The table
  129. * properties form view instance.
  130. */
  131. _createPropertiesView() {
  132. const editor = this.editor;
  133. const viewDocument = editor.editing.view.document;
  134. const config = editor.config.get( 'table.tableProperties' );
  135. const borderColorsConfig = normalizeColorOptions( config.borderColors );
  136. const localizedBorderColors = getLocalizedColorOptions( editor.locale, borderColorsConfig );
  137. const backgroundColorsConfig = normalizeColorOptions( config.backgroundColors );
  138. const localizedBackgroundColors = getLocalizedColorOptions( editor.locale, backgroundColorsConfig );
  139. const view = new TablePropertiesView( editor.locale, {
  140. borderColors: localizedBorderColors,
  141. backgroundColors: localizedBackgroundColors
  142. } );
  143. const t = editor.t;
  144. // Render the view so its #element is available for the clickOutsideHandler.
  145. view.render();
  146. this.listenTo( view, 'submit', () => {
  147. this._hideView();
  148. } );
  149. this.listenTo( view, 'cancel', () => {
  150. // https://github.com/ckeditor/ckeditor5/issues/6180
  151. if ( this._undoStepBatch.operations.length ) {
  152. editor.execute( 'undo', this._undoStepBatch );
  153. }
  154. this._hideView();
  155. } );
  156. // Close the balloon on Esc key press.
  157. view.keystrokes.set( 'Esc', ( data, cancel ) => {
  158. this._hideView();
  159. cancel();
  160. } );
  161. // Reposition the balloon or hide the form if a table is no longer selected.
  162. this.listenTo( editor.ui, 'update', () => {
  163. if ( !getTableWidgetAncestor( viewDocument.selection ) ) {
  164. this._hideView();
  165. } else if ( this._isViewVisible ) {
  166. repositionContextualBalloon( editor, 'table' );
  167. }
  168. } );
  169. // Close on click outside of balloon panel element.
  170. clickOutsideHandler( {
  171. emitter: view,
  172. activator: () => this._isViewInBalloon,
  173. contextElements: [ this._balloon.view.element ],
  174. callback: () => this._hideView()
  175. } );
  176. const colorErrorText = getLocalizedColorErrorText( t );
  177. const lengthErrorText = getLocalizedLengthErrorText( t );
  178. // Create the "UI -> editor data" binding.
  179. // These listeners update the editor data (via table commands) when any observable
  180. // property of the view has changed. They also validate the value and display errors in the UI
  181. // when necessary. This makes the view live, which means the changes are
  182. // visible in the editing as soon as the user types or changes fields' values.
  183. view.on( 'change:borderStyle', this._getPropertyChangeCallback( 'tableBorderStyle' ) );
  184. view.on( 'change:borderColor', this._getValidatedPropertyChangeCallback( {
  185. viewField: view.borderColorInput,
  186. commandName: 'tableBorderColor',
  187. errorText: colorErrorText,
  188. validator: colorFieldValidator
  189. } ) );
  190. view.on( 'change:borderWidth', this._getValidatedPropertyChangeCallback( {
  191. viewField: view.borderWidthInput,
  192. commandName: 'tableBorderWidth',
  193. errorText: lengthErrorText,
  194. validator: lineWidthFieldValidator
  195. } ) );
  196. view.on( 'change:backgroundColor', this._getValidatedPropertyChangeCallback( {
  197. viewField: view.backgroundInput,
  198. commandName: 'tableBackgroundColor',
  199. errorText: colorErrorText,
  200. validator: colorFieldValidator
  201. } ) );
  202. view.on( 'change:width', this._getValidatedPropertyChangeCallback( {
  203. viewField: view.widthInput,
  204. commandName: 'tableWidth',
  205. errorText: lengthErrorText,
  206. validator: lengthFieldValidator
  207. } ) );
  208. view.on( 'change:height', this._getValidatedPropertyChangeCallback( {
  209. viewField: view.heightInput,
  210. commandName: 'tableHeight',
  211. errorText: lengthErrorText,
  212. validator: lengthFieldValidator
  213. } ) );
  214. view.on( 'change:alignment', this._getPropertyChangeCallback( 'tableAlignment' ) );
  215. return view;
  216. }
  217. /**
  218. * In this method the "editor data -> UI" binding is happening.
  219. *
  220. * When executed, this method obtains selected table property values from various table commands
  221. * and passes them to the {@link #view}.
  222. *
  223. * This way, the UI stays up–to–date with the editor data.
  224. *
  225. * @private
  226. */
  227. _fillViewFormFromCommandValues() {
  228. const commands = this.editor.commands;
  229. Object.entries( propertyToCommandMap )
  230. .map( ( [ property, commandName ] ) => [ property, commands.get( commandName ).value || '' ] )
  231. .forEach( ( [ property, value ] ) => this.view.set( property, value ) );
  232. }
  233. /**
  234. * Shows the {@link #view} in the {@link #_balloon}.
  235. *
  236. * **Note**: Each time a view is shown, the new {@link #_undoStepBatch} is created that contains
  237. * all changes made to the document when the view is visible, allowing a single undo step
  238. * for all of them.
  239. *
  240. * @protected
  241. */
  242. _showView() {
  243. const editor = this.editor;
  244. this._balloon.add( {
  245. view: this.view,
  246. position: getBalloonTablePositionData( editor )
  247. } );
  248. // Create a new batch. Clicking "Cancel" will undo this batch.
  249. this._undoStepBatch = editor.model.createBatch();
  250. // Update the view with the model values.
  251. this._fillViewFormFromCommandValues();
  252. // Basic a11y.
  253. this.view.focus();
  254. }
  255. /**
  256. * Removes the {@link #view} from the {@link #_balloon}.
  257. *
  258. * @protected
  259. */
  260. _hideView() {
  261. if ( !this._isViewInBalloon ) {
  262. return;
  263. }
  264. const editor = this.editor;
  265. this.stopListening( editor.ui, 'update' );
  266. // Blur any input element before removing it from DOM to prevent issues in some browsers.
  267. // See https://github.com/ckeditor/ckeditor5/issues/1501.
  268. this.view.saveButtonView.focus();
  269. this._balloon.remove( this.view );
  270. // Make sure the focus is not lost in the process by putting it directly
  271. // into the editing view.
  272. this.editor.editing.view.focus();
  273. }
  274. /**
  275. * Returns `true` when the {@link #view} is the visible in the {@link #_balloon}.
  276. *
  277. * @private
  278. * @type {Boolean}
  279. */
  280. get _isViewVisible() {
  281. return this._balloon.visibleView === this.view;
  282. }
  283. /**
  284. * Returns `true` when the {@link #view} is in the {@link #_balloon}.
  285. *
  286. * @private
  287. * @type {Boolean}
  288. */
  289. get _isViewInBalloon() {
  290. return this._balloon.hasView( this.view );
  291. }
  292. /**
  293. * Creates a callback that when executed upon {@link #view view's} property change
  294. * executes a related editor command with the new property value.
  295. *
  296. * @private
  297. * @param {String} commandName
  298. * @returns {Function}
  299. */
  300. _getPropertyChangeCallback( commandName ) {
  301. return ( evt, propertyName, newValue ) => {
  302. this.editor.execute( commandName, {
  303. value: newValue,
  304. batch: this._undoStepBatch
  305. } );
  306. };
  307. }
  308. /**
  309. * Creates a callback that when executed upon {@link #view view's} property change:
  310. * * executes a related editor command with the new property value if the value is valid,
  311. * * or sets the error text next to the invalid field, if the value did not pass the validation.
  312. *
  313. * @private
  314. * @param {Object} options
  315. * @param {String} options.commandName
  316. * @param {module:ui/view~View} options.viewField
  317. * @param {Function} options.validator
  318. * @param {String} options.errorText
  319. * @returns {Function}
  320. */
  321. _getValidatedPropertyChangeCallback( { commandName, viewField, validator, errorText } ) {
  322. const setErrorTextDebounced = debounce( () => {
  323. viewField.errorText = errorText;
  324. }, ERROR_TEXT_TIMEOUT );
  325. return ( evt, propertyName, newValue ) => {
  326. setErrorTextDebounced.cancel();
  327. if ( validator( newValue ) ) {
  328. this.editor.execute( commandName, {
  329. value: newValue,
  330. batch: this._undoStepBatch
  331. } );
  332. viewField.errorText = null;
  333. } else {
  334. setErrorTextDebounced();
  335. }
  336. };
  337. }
  338. }