4fdcf4e646a85f775e3c2c45cb4d96955abe1e7e20b00031a1c5e400f5f8a360ee3e149f290642e259b287743ac8add57e54b7de2f57fec0fb9b312e7e1095 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727
  1. import {
  2. addClass,
  3. closest,
  4. isChildOf,
  5. hasClass,
  6. offset,
  7. outerWidth,
  8. outerHeight,
  9. getScrollableElement
  10. } from './../../helpers/dom/element';
  11. import {
  12. deepClone, deepExtend
  13. } from './../../helpers/object';
  14. import {
  15. debounce
  16. } from './../../helpers/function';
  17. import EventManager from './../../eventManager';
  18. import {CellCoords} from './../../3rdparty/walkontable/src';
  19. import {registerPlugin, getPlugin} from './../../plugins';
  20. import BasePlugin from './../_base';
  21. import CommentEditor from './commentEditor';
  22. import {checkSelectionConsistency, markLabelAsSelected} from './../contextMenu/utils';
  23. import './comments.css';
  24. const privatePool = new WeakMap();
  25. const META_COMMENT = 'comment';
  26. const META_COMMENT_VALUE = 'value';
  27. const META_STYLE = 'style';
  28. const META_READONLY = 'readOnly';
  29. /**
  30. * @plugin Comments
  31. *
  32. * @description
  33. * This plugin allows setting and managing cell comments by either an option in the context menu or with the use of the API.
  34. *
  35. * To enable the plugin, you'll need to set the comments property of the config object to `true`:
  36. * ```js
  37. * ...
  38. * comments: true
  39. * ...
  40. * ```
  41. *
  42. * To add comments at the table initialization, define the `comment` property in the `cell` config array as in an example below.
  43. *
  44. * @example
  45. *
  46. * ```js
  47. * ...
  48. * var hot = new Handsontable(document.getElementById('example'), {
  49. * date: getData(),
  50. * comments: true,
  51. * cell: [
  52. * {row: 1, col: 1, comment: {value: 'Foo'}},
  53. * {row: 2, col: 2, comment: {value: 'Bar'}}
  54. * ]
  55. * });
  56. *
  57. * // Access to the Comments plugin instance:
  58. * var commentsPlugin = hot.getPlugin('comments');
  59. *
  60. * // Manage comments programmatically:
  61. * commentsPlugin.editor.setCommentAtCell(1, 6, 'Comment contents');
  62. * commentsPlugin.showAtCell(1, 6);
  63. * commentsPlugin.removeCommentAtCell(1, 6);
  64. *
  65. * // You can also set range once and use proper methods:
  66. * commentsPlugin.setRange({row: 1, col: 6});
  67. * commentsPlugin.setComment('Comment contents');
  68. * commentsPlugin.show();
  69. * commentsPlugin.removeComment();
  70. * ...
  71. * ```
  72. */
  73. class Comments extends BasePlugin {
  74. constructor(hotInstance) {
  75. super(hotInstance);
  76. /**
  77. * Instance of {@link CommentEditor}.
  78. *
  79. * @type {CommentEditor}
  80. */
  81. this.editor = null;
  82. /**
  83. * Instance of {@link EventManager}.
  84. *
  85. * @private
  86. * @type {EventManager}
  87. */
  88. this.eventManager = null;
  89. /**
  90. * Current cell range.
  91. *
  92. * @type {Object}
  93. */
  94. this.range = {};
  95. /**
  96. * @private
  97. * @type {Boolean}
  98. */
  99. this.mouseDown = false;
  100. /**
  101. * @private
  102. * @type {Boolean}
  103. */
  104. this.contextMenuEvent = false;
  105. /**
  106. * @private
  107. * @type {*}
  108. */
  109. this.timer = null;
  110. /**
  111. * Delay used when showing/hiding the comments (in milliseconds).
  112. *
  113. * @type {Number}
  114. */
  115. this.displayDelay = 250;
  116. privatePool.set(this, {
  117. tempEditorDimensions: {},
  118. cellBelowCursor: null
  119. });
  120. }
  121. /**
  122. * Check if the plugin is enabled in the Handsontable settings.
  123. *
  124. * @returns {Boolean}
  125. */
  126. isEnabled() {
  127. return !!this.hot.getSettings().comments;
  128. }
  129. /**
  130. * Enable plugin for this Handsontable instance.
  131. */
  132. enablePlugin() {
  133. if (this.enabled) {
  134. return;
  135. }
  136. if (!this.editor) {
  137. this.editor = new CommentEditor();
  138. }
  139. if (!this.eventManager) {
  140. this.eventManager = new EventManager(this);
  141. }
  142. this.addHook('afterContextMenuDefaultOptions', (options) => this.addToContextMenu(options));
  143. this.addHook('afterRenderer', (TD, row, col, prop, value, cellProperties) => this.onAfterRenderer(TD, cellProperties));
  144. this.addHook('afterScrollHorizontally', () => this.hide());
  145. this.addHook('afterScrollVertically', () => this.hide());
  146. this.addHook('afterBeginEditing', (args) => this.onAfterBeginEditing(args));
  147. this.registerListeners();
  148. super.enablePlugin();
  149. }
  150. /**
  151. * Disable plugin for this Handsontable instance.
  152. */
  153. disablePlugin() {
  154. super.disablePlugin();
  155. }
  156. /**
  157. * Register all necessary DOM listeners.
  158. *
  159. * @private
  160. */
  161. registerListeners() {
  162. this.eventManager.addEventListener(document, 'mouseover', (event) => this.onMouseOver(event));
  163. this.eventManager.addEventListener(document, 'mousedown', (event) => this.onMouseDown(event));
  164. this.eventManager.addEventListener(document, 'mouseup', (event) => this.onMouseUp(event));
  165. this.eventManager.addEventListener(this.editor.getInputElement(), 'blur', (event) => this.onEditorBlur(event));
  166. this.eventManager.addEventListener(this.editor.getInputElement(), 'mousedown', (event) => this.onEditorMouseDown(event));
  167. this.eventManager.addEventListener(this.editor.getInputElement(), 'mouseup', (event) => this.onEditorMouseUp(event));
  168. }
  169. /**
  170. * Set current cell range to be able to use general methods like {@link Comments#setComment},
  171. * {@link Comments#removeComment}, {@link Comments#show}.
  172. *
  173. * @param {Object} range Object with `from` and `to` properties, each with `row` and `col` properties.
  174. */
  175. setRange(range) {
  176. this.range = range;
  177. }
  178. /**
  179. * Clear the currently selected cell.
  180. */
  181. clearRange() {
  182. this.range = {};
  183. }
  184. /**
  185. * Check if the event target is a cell containing a comment.
  186. *
  187. * @param {Event} event DOM event
  188. * @returns {Boolean}
  189. */
  190. targetIsCellWithComment(event) {
  191. const closestCell = closest(event.target, 'TD', 'TBODY');
  192. return !!(closestCell && hasClass(closestCell, 'htCommentCell') && closest(closestCell, [this.hot.rootElement]));
  193. }
  194. /**
  195. * Check if the event target is a comment textarea.
  196. *
  197. * @param {Event} event DOM event.
  198. * @returns {Boolean}
  199. */
  200. targetIsCommentTextArea(event) {
  201. return this.editor.getInputElement() === event.target;
  202. }
  203. /**
  204. * Set a comment for a cell according to the previously set range (see {@link Comments#setRange}).
  205. *
  206. * @param {String} value Comment contents.
  207. */
  208. setComment(value) {
  209. if (!this.range.from) {
  210. throw new Error('Before using this method, first set cell range (hot.getPlugin("comment").setRange())');
  211. }
  212. const editorValue = this.editor.getValue();
  213. let comment = '';
  214. if (value != null) {
  215. comment = value;
  216. } else if (editorValue != null) {
  217. comment = editorValue;
  218. }
  219. let row = this.range.from.row;
  220. let col = this.range.from.col;
  221. this.updateCommentMeta(row, col, {[META_COMMENT_VALUE]: comment});
  222. this.hot.render();
  223. }
  224. /**
  225. * Set a comment for a cell.
  226. *
  227. * @param {Number} row Row index.
  228. * @param {Number} col Column index.
  229. * @param {String} value Comment contents.
  230. */
  231. setCommentAtCell(row, col, value) {
  232. this.setRange({
  233. from: new CellCoords(row, col)
  234. });
  235. this.setComment(value);
  236. }
  237. /**
  238. * Remove a comment from a cell according to previously set range (see {@link Comments#setRange}).
  239. *
  240. * @param {Boolean} [forceRender = true] If set to `true`, the table will be re-rendered at the end of the operation.
  241. */
  242. removeComment(forceRender = true) {
  243. if (!this.range.from) {
  244. throw new Error('Before using this method, first set cell range (hot.getPlugin("comment").setRange())');
  245. }
  246. this.hot.setCellMeta(this.range.from.row, this.range.from.col, META_COMMENT, void 0);
  247. if (forceRender) {
  248. this.hot.render();
  249. }
  250. this.hide();
  251. }
  252. /**
  253. * Remove comment from a cell.
  254. *
  255. * @param {Number} row Row index.
  256. * @param {Number} col Column index.
  257. * @param {Boolean} [forceRender = true] If `true`, the table will be re-rendered at the end of the operation.
  258. */
  259. removeCommentAtCell(row, col, forceRender = true) {
  260. this.setRange({
  261. from: new CellCoords(row, col)
  262. });
  263. this.removeComment(forceRender);
  264. }
  265. /**
  266. * Get comment from a cell at the predefined range.
  267. */
  268. getComment() {
  269. const row = this.range.from.row;
  270. const column = this.range.from.col;
  271. return this.getCommentMeta(row, column, META_COMMENT_VALUE);
  272. }
  273. /**
  274. * Get comment from a cell at the provided coordinates.
  275. *
  276. * @param {Number} row Row index.
  277. * @param {Number} column Column index.
  278. */
  279. getCommentAtCell(row, column) {
  280. return this.getCommentMeta(row, column, META_COMMENT_VALUE);
  281. }
  282. /**
  283. * Show the comment editor accordingly to the previously set range (see {@link Comments#setRange}).
  284. *
  285. * @returns {Boolean} Returns `true` if comment editor was shown.
  286. */
  287. show() {
  288. if (!this.range.from) {
  289. throw new Error('Before using this method, first set cell range (hot.getPlugin("comment").setRange())');
  290. }
  291. let meta = this.hot.getCellMeta(this.range.from.row, this.range.from.col);
  292. this.refreshEditor(true);
  293. this.editor.setValue(meta[META_COMMENT] ? meta[META_COMMENT][META_COMMENT_VALUE] : null || '');
  294. if (this.editor.hidden) {
  295. this.editor.show();
  296. }
  297. return true;
  298. }
  299. /**
  300. * Show comment editor according to cell coordinates.
  301. *
  302. * @param {Number} row Row index.
  303. * @param {Number} col Column index.
  304. * @returns {Boolean} Returns `true` if comment editor was shown.
  305. */
  306. showAtCell(row, col) {
  307. this.setRange({
  308. from: new CellCoords(row, col)
  309. });
  310. return this.show();
  311. }
  312. /**
  313. * Hide the comment editor.
  314. */
  315. hide() {
  316. if (!this.editor.hidden) {
  317. this.editor.hide();
  318. }
  319. }
  320. /**
  321. * Refresh comment editor position and styling.
  322. *
  323. * @param {Boolean} [force=false] If `true` then recalculation will be forced.
  324. */
  325. refreshEditor(force = false) {
  326. if (!force && (!this.range.from || !this.editor.isVisible())) {
  327. return;
  328. }
  329. const scrollableElement = getScrollableElement(this.hot.view.wt.wtTable.TABLE);
  330. const TD = this.hot.view.wt.wtTable.getCell(this.range.from);
  331. const row = this.range.from.row;
  332. const column = this.range.from.col;
  333. let cellOffset = offset(TD);
  334. let lastColWidth = this.hot.view.wt.wtTable.getStretchedColumnWidth(column);
  335. let cellTopOffset = cellOffset.top < 0 ? 0 : cellOffset.top;
  336. let cellLeftOffset = cellOffset.left;
  337. if (this.hot.view.wt.wtViewport.hasVerticalScroll() && scrollableElement !== window) {
  338. cellTopOffset -= this.hot.view.wt.wtOverlays.topOverlay.getScrollPosition();
  339. }
  340. if (this.hot.view.wt.wtViewport.hasHorizontalScroll() && scrollableElement !== window) {
  341. cellLeftOffset -= this.hot.view.wt.wtOverlays.leftOverlay.getScrollPosition();
  342. }
  343. let x = cellLeftOffset + lastColWidth;
  344. let y = cellTopOffset;
  345. const commentStyle = this.getCommentMeta(row, column, META_STYLE);
  346. const readOnly = this.getCommentMeta(row, column, META_READONLY);
  347. if (commentStyle) {
  348. this.editor.setSize(commentStyle.width, commentStyle.height);
  349. } else {
  350. this.editor.resetSize();
  351. }
  352. this.editor.setReadOnlyState(readOnly);
  353. this.editor.setPosition(x, y);
  354. }
  355. /**
  356. * Check if there is a comment for selected range.
  357. *
  358. * @private
  359. * @returns {Boolean}
  360. */
  361. checkSelectionCommentsConsistency() {
  362. const selected = this.hot.getSelectedRange();
  363. if (!selected) {
  364. return false;
  365. }
  366. let hasComment = false;
  367. let cell = selected.from; // IN EXCEL THERE IS COMMENT ONLY FOR TOP LEFT CELL IN SELECTION
  368. if (this.getCommentMeta(cell.row, cell.col, META_COMMENT_VALUE)) {
  369. hasComment = true;
  370. }
  371. return hasComment;
  372. }
  373. /**
  374. * Set or update the comment-related cell meta.
  375. *
  376. * @param {Number} row Row index.
  377. * @param {Number} column Column index.
  378. * @param {Object} metaObject Object defining all the comment-related meta information.
  379. */
  380. updateCommentMeta(row, column, metaObject) {
  381. const oldComment = this.hot.getCellMeta(row, column)[META_COMMENT];
  382. let newComment;
  383. if (oldComment) {
  384. newComment = deepClone(oldComment);
  385. deepExtend(newComment, metaObject);
  386. } else {
  387. newComment = metaObject;
  388. }
  389. this.hot.setCellMeta(row, column, META_COMMENT, newComment);
  390. }
  391. /**
  392. * Get the comment related meta information.
  393. *
  394. * @param {Number} row Row index.
  395. * @param {Number} column Column index.
  396. * @param {String} property Cell meta property.
  397. * @returns {Mixed}
  398. */
  399. getCommentMeta(row, column, property) {
  400. const cellMeta = this.hot.getCellMeta(row, column);
  401. if (!cellMeta[META_COMMENT]) {
  402. return void 0;
  403. }
  404. return cellMeta[META_COMMENT][property];
  405. }
  406. /**
  407. * `mousedown` event callback.
  408. *
  409. * @private
  410. * @param {MouseEvent} event The `mousedown` event.
  411. */
  412. onMouseDown(event) {
  413. this.mouseDown = true;
  414. if (!this.hot.view || !this.hot.view.wt) {
  415. return;
  416. }
  417. if (!this.contextMenuEvent && !this.targetIsCommentTextArea(event)) {
  418. const eventCell = closest(event.target, 'TD', 'TBODY');
  419. let coordinates = null;
  420. if (eventCell) {
  421. coordinates = this.hot.view.wt.wtTable.getCoords(eventCell);
  422. }
  423. if (!eventCell || ((this.range.from && coordinates) && (this.range.from.row !== coordinates.row || this.range.from.col !== coordinates.col))) {
  424. this.hide();
  425. }
  426. }
  427. this.contextMenuEvent = false;
  428. }
  429. /**
  430. * `mouseover` event callback.
  431. *
  432. * @private
  433. * @param {MouseEvent} event The `mouseover` event.
  434. */
  435. onMouseOver(event) {
  436. if (this.mouseDown || this.editor.isFocused()) {
  437. return;
  438. }
  439. const priv = privatePool.get(this);
  440. priv.cellBelowCursor = document.elementFromPoint(event.clientX, event.clientY);
  441. debounce(() => {
  442. if (hasClass(event.target, 'wtBorder') || priv.cellBelowCursor !== event.target || !this.editor) {
  443. return;
  444. }
  445. if (this.targetIsCellWithComment(event)) {
  446. let coordinates = this.hot.view.wt.wtTable.getCoords(event.target);
  447. let range = {
  448. from: new CellCoords(coordinates.row, coordinates.col)
  449. };
  450. this.setRange(range);
  451. this.show();
  452. } else if (isChildOf(event.target, document) && !this.targetIsCommentTextArea(event) && !this.editor.isFocused()) {
  453. this.hide();
  454. }
  455. }, this.displayDelay)();
  456. }
  457. /**
  458. * `mouseup` event callback.
  459. *
  460. * @private
  461. * @param {MouseEvent} event The `mouseup` event.
  462. */
  463. onMouseUp(event) {
  464. this.mouseDown = false;
  465. }
  466. /** *
  467. * The `afterRenderer` hook callback..
  468. *
  469. * @private
  470. * @param {HTMLTableCellElement} TD The rendered `TD` element.
  471. * @param {Object} cellProperties The rendered cell's property object.
  472. */
  473. onAfterRenderer(TD, cellProperties) {
  474. if (cellProperties[META_COMMENT] && cellProperties[META_COMMENT][META_COMMENT_VALUE]) {
  475. addClass(TD, cellProperties.commentedCellClassName);
  476. }
  477. }
  478. /**
  479. * `blur` event callback for the comment editor.
  480. *
  481. * @private
  482. * @param {Event} event The `blur` event.
  483. */
  484. onEditorBlur(event) {
  485. this.setComment();
  486. }
  487. /**
  488. * `mousedown` hook. Along with `onEditorMouseUp` used to simulate the textarea resizing event.
  489. *
  490. * @private
  491. * @param {MouseEvent} event The `mousedown` event.
  492. */
  493. onEditorMouseDown(event) {
  494. const priv = privatePool.get(this);
  495. priv.tempEditorDimensions = {
  496. width: outerWidth(event.target),
  497. height: outerHeight(event.target)
  498. };
  499. }
  500. /**
  501. * `mouseup` hook. Along with `onEditorMouseDown` used to simulate the textarea resizing event.
  502. *
  503. * @private
  504. * @param {MouseEvent} event The `mouseup` event.
  505. */
  506. onEditorMouseUp(event) {
  507. const priv = privatePool.get(this);
  508. const currentWidth = outerWidth(event.target);
  509. const currentHeight = outerHeight(event.target);
  510. if (currentWidth !== priv.tempEditorDimensions.width + 1 || currentHeight !== priv.tempEditorDimensions.height + 2) {
  511. this.updateCommentMeta(this.range.from.row, this.range.from.col, {
  512. [META_STYLE]: {
  513. width: currentWidth,
  514. height: currentHeight
  515. }
  516. });
  517. }
  518. }
  519. /**
  520. * Context Menu's "Add comment" callback. Results in showing the comment editor.
  521. *
  522. * @private
  523. */
  524. onContextMenuAddComment() {
  525. let coords = this.hot.getSelectedRange();
  526. this.contextMenuEvent = true;
  527. this.setRange({
  528. from: coords.from
  529. });
  530. this.show();
  531. setTimeout(() => {
  532. if (this.hot) {
  533. this.hot.deselectCell();
  534. this.editor.focus();
  535. }
  536. }, 10);
  537. }
  538. /**
  539. * Context Menu's "remove comment" callback.
  540. *
  541. * @private
  542. * @param {Object} selection The current selection.
  543. */
  544. onContextMenuRemoveComment(selection) {
  545. this.contextMenuEvent = true;
  546. for (let i = selection.start.row; i <= selection.end.row; i++) {
  547. for (let j = selection.start.col; j <= selection.end.col; j++) {
  548. this.removeCommentAtCell(i, j, false);
  549. }
  550. }
  551. this.hot.render();
  552. }
  553. /**
  554. * Context Menu's "make comment read-only" callback.
  555. *
  556. * @private
  557. * @param {Object} selection The current selection.
  558. */
  559. onContextMenuMakeReadOnly(selection) {
  560. this.contextMenuEvent = true;
  561. for (let i = selection.start.row; i <= selection.end.row; i++) {
  562. for (let j = selection.start.col; j <= selection.end.col; j++) {
  563. let currentState = !!this.getCommentMeta(i, j, META_READONLY);
  564. this.updateCommentMeta(i, j, {[META_READONLY]: !currentState});
  565. }
  566. }
  567. }
  568. /**
  569. * Add Comments plugin options to the Context Menu.
  570. *
  571. * @private
  572. * @param {Object} defaultOptions
  573. */
  574. addToContextMenu(defaultOptions) {
  575. defaultOptions.items.push(
  576. getPlugin(this.hot, 'contextMenu').constructor.SEPARATOR,
  577. {
  578. key: 'commentsAddEdit',
  579. name: () => (this.checkSelectionCommentsConsistency() ? 'Edit comment' : 'Add comment'),
  580. callback: () => this.onContextMenuAddComment(),
  581. disabled() {
  582. return !(this.getSelected() && !this.selection.selectedHeader.corner);
  583. }
  584. },
  585. {
  586. key: 'commentsRemove',
  587. name() {
  588. return 'Delete comment';
  589. },
  590. callback: (key, selection) => this.onContextMenuRemoveComment(selection),
  591. disabled: () => this.hot.selection.selectedHeader.corner
  592. },
  593. {
  594. key: 'commentsReadOnly',
  595. name() {
  596. let label = 'Read only comment';
  597. let hasProperty = checkSelectionConsistency(this.getSelectedRange(), (row, col) => {
  598. let readOnlyProperty = this.getCellMeta(row, col)[META_COMMENT];
  599. if (readOnlyProperty) {
  600. readOnlyProperty = readOnlyProperty[META_READONLY];
  601. }
  602. if (readOnlyProperty) {
  603. return true;
  604. }
  605. });
  606. if (hasProperty) {
  607. label = markLabelAsSelected(label);
  608. }
  609. return label;
  610. },
  611. callback: (key, selection) => this.onContextMenuMakeReadOnly(selection),
  612. disabled: () => this.hot.selection.selectedHeader.corner || !this.checkSelectionCommentsConsistency()
  613. }
  614. );
  615. }
  616. /**
  617. * `afterBeginEditing` hook callback.
  618. *
  619. * @private
  620. * @param {Number} row Row index of the currently edited cell.
  621. * @param {Number} column Column index of the currently edited cell.
  622. */
  623. onAfterBeginEditing(row, column) {
  624. this.hide();
  625. }
  626. /**
  627. * Destroy plugin instance.
  628. */
  629. destroy() {
  630. if (this.editor) {
  631. this.editor.destroy();
  632. }
  633. super.destroy();
  634. }
  635. }
  636. registerPlugin('comments', Comments);
  637. export default Comments;