textEditor.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import {
  2. addClass,
  3. getCaretPosition,
  4. getComputedStyle,
  5. getCssTransform,
  6. getScrollbarWidth,
  7. innerWidth,
  8. offset,
  9. resetCssTransform,
  10. setCaretPosition,
  11. hasVerticalScrollbar,
  12. hasHorizontalScrollbar
  13. } from './../helpers/dom/element';
  14. import autoResize from './../../lib/autoResize/autoResize';
  15. import { isMobileBrowser } from './../helpers/browser';
  16. import BaseEditor, { EditorState } from './_baseEditor';
  17. import EventManager from './../eventManager';
  18. import { KEY_CODES } from './../helpers/unicode';
  19. import { stopPropagation, stopImmediatePropagation, isImmediatePropagationStopped } from './../helpers/dom/event';
  20. const TextEditor = BaseEditor.prototype.extend();
  21. /**
  22. * @private
  23. * @editor TextEditor
  24. * @class TextEditor
  25. * @dependencies autoResize
  26. */
  27. TextEditor.prototype.init = function() {
  28. const that = this;
  29. this.createElements();
  30. this.eventManager = new EventManager(this);
  31. this.bindEvents();
  32. this.autoResize = autoResize();
  33. this.holderZIndex = -1;
  34. this.instance.addHook('afterDestroy', () => {
  35. that.destroy();
  36. });
  37. };
  38. TextEditor.prototype.prepare = function(row, col, prop, td, originalValue, cellProperties, ...args) {
  39. const previousState = this.state;
  40. BaseEditor.prototype.prepare.apply(this, [row, col, prop, td, originalValue, cellProperties, ...args]);
  41. if (!cellProperties.readOnly) {
  42. this.refreshDimensions(true);
  43. const {
  44. allowInvalid,
  45. fragmentSelection,
  46. } = cellProperties;
  47. if (allowInvalid) {
  48. this.TEXTAREA.value = ''; // Remove an empty space from texarea (added by copyPaste plugin to make copy/paste functionality work with IME)
  49. }
  50. if (previousState !== EditorState.FINISHED) {
  51. this.hideEditableElement();
  52. }
  53. // @TODO: The fragmentSelection functionality is conflicted with IME. For this feature refocus has to
  54. // be disabled (to make IME working).
  55. const restoreFocus = !fragmentSelection;
  56. if (restoreFocus && !isMobileBrowser()) {
  57. this.instance._registerImmediate(() => this.focus());
  58. }
  59. }
  60. };
  61. TextEditor.prototype.hideEditableElement = function() {
  62. this.textareaParentStyle.top = '-9999px';
  63. this.textareaParentStyle.left = '-9999px';
  64. this.textareaParentStyle.zIndex = '-1';
  65. this.textareaParentStyle.position = 'fixed';
  66. };
  67. TextEditor.prototype.showEditableElement = function() {
  68. this.textareaParentStyle.zIndex = this.holderZIndex >= 0 ? this.holderZIndex : '';
  69. this.textareaParentStyle.position = '';
  70. };
  71. TextEditor.prototype.getValue = function() {
  72. return this.TEXTAREA.value;
  73. };
  74. TextEditor.prototype.setValue = function(newValue) {
  75. this.TEXTAREA.value = newValue;
  76. };
  77. TextEditor.prototype.beginEditing = function(...args) {
  78. if (this.state !== EditorState.VIRGIN) {
  79. return;
  80. }
  81. this.TEXTAREA.value = ''; // Remove an empty space from texarea (added by copyPaste plugin to make copy/paste functionality work with IME).
  82. BaseEditor.prototype.beginEditing.apply(this, args);
  83. };
  84. const onBeforeKeyDown = function onBeforeKeyDown(event) {
  85. const instance = this;
  86. const that = instance.getActiveEditor();
  87. // catch CTRL but not right ALT (which in some systems triggers ALT+CTRL)
  88. const ctrlDown = (event.ctrlKey || event.metaKey) && !event.altKey;
  89. // Process only events that have been fired in the editor
  90. if (event.target !== that.TEXTAREA || isImmediatePropagationStopped(event)) {
  91. return;
  92. }
  93. switch (event.keyCode) {
  94. case KEY_CODES.ARROW_RIGHT:
  95. if (that.isInFullEditMode()) {
  96. if ((!that.isWaiting() && !that.allowKeyEventPropagation) ||
  97. (!that.isWaiting() && that.allowKeyEventPropagation && !that.allowKeyEventPropagation(event.keyCode))) {
  98. stopImmediatePropagation(event);
  99. }
  100. }
  101. break;
  102. case KEY_CODES.ARROW_LEFT:
  103. if (that.isInFullEditMode()) {
  104. if ((!that.isWaiting() && !that.allowKeyEventPropagation) ||
  105. (!that.isWaiting() && that.allowKeyEventPropagation && !that.allowKeyEventPropagation(event.keyCode))) {
  106. stopImmediatePropagation(event);
  107. }
  108. }
  109. break;
  110. case KEY_CODES.ARROW_UP:
  111. case KEY_CODES.ARROW_DOWN:
  112. if (that.isInFullEditMode()) {
  113. if ((!that.isWaiting() && !that.allowKeyEventPropagation) ||
  114. (!that.isWaiting() && that.allowKeyEventPropagation && !that.allowKeyEventPropagation(event.keyCode))) {
  115. stopImmediatePropagation(event);
  116. }
  117. }
  118. break;
  119. case KEY_CODES.ENTER: {
  120. const isMultipleSelection = this.selection.isMultiple();
  121. if ((ctrlDown && !isMultipleSelection) || event.altKey) { // if ctrl+enter or alt+enter, add new line
  122. if (that.isOpened()) {
  123. const caretPosition = getCaretPosition(that.TEXTAREA);
  124. const value = that.getValue();
  125. const newValue = `${value.slice(0, caretPosition)}\n${value.slice(caretPosition)}`;
  126. that.setValue(newValue);
  127. setCaretPosition(that.TEXTAREA, caretPosition + 1);
  128. } else {
  129. that.beginEditing(`${that.originalValue}\n`);
  130. }
  131. stopImmediatePropagation(event);
  132. }
  133. event.preventDefault(); // don't add newline to field
  134. break;
  135. }
  136. case KEY_CODES.BACKSPACE:
  137. case KEY_CODES.DELETE:
  138. case KEY_CODES.HOME:
  139. case KEY_CODES.END:
  140. stopImmediatePropagation(event); // backspace, delete, home, end should only work locally when cell is edited (not in table context)
  141. break;
  142. default:
  143. break;
  144. }
  145. if ([KEY_CODES.ARROW_UP, KEY_CODES.ARROW_RIGHT, KEY_CODES.ARROW_DOWN, KEY_CODES.ARROW_LEFT].indexOf(event.keyCode) === -1) {
  146. that.autoResize.resize(String.fromCharCode(event.keyCode));
  147. }
  148. };
  149. TextEditor.prototype.open = function() {
  150. this.refreshDimensions(); // need it instantly, to prevent https://github.com/handsontable/handsontable/issues/348
  151. this.showEditableElement();
  152. this.instance.addHook('beforeKeyDown', onBeforeKeyDown);
  153. };
  154. TextEditor.prototype.close = function() {
  155. this.autoResize.unObserve();
  156. if (document.activeElement === this.TEXTAREA) {
  157. this.instance.listen(); // don't refocus the table if user focused some cell outside of HT on purpose
  158. }
  159. this.hideEditableElement();
  160. this.instance.removeHook('beforeKeyDown', onBeforeKeyDown);
  161. };
  162. TextEditor.prototype.focus = function() {
  163. // For IME editor textarea element must be focused using ".select" method. Using ".focus" browser automatically scroll into
  164. // the focused element which is undesire effect.
  165. this.TEXTAREA.select();
  166. setCaretPosition(this.TEXTAREA, this.TEXTAREA.value.length);
  167. };
  168. TextEditor.prototype.createElements = function() {
  169. this.TEXTAREA = document.createElement('TEXTAREA');
  170. this.TEXTAREA.tabIndex = -1;
  171. addClass(this.TEXTAREA, 'handsontableInput');
  172. this.textareaStyle = this.TEXTAREA.style;
  173. this.textareaStyle.width = 0;
  174. this.textareaStyle.height = 0;
  175. this.TEXTAREA_PARENT = document.createElement('DIV');
  176. addClass(this.TEXTAREA_PARENT, 'handsontableInputHolder');
  177. this.textareaParentStyle = this.TEXTAREA_PARENT.style;
  178. this.textareaParentStyle.zIndex = '-1';
  179. this.TEXTAREA_PARENT.appendChild(this.TEXTAREA);
  180. this.instance.rootElement.appendChild(this.TEXTAREA_PARENT);
  181. };
  182. TextEditor.prototype.getEditedCell = function() {
  183. const editorSection = this.checkEditorSection();
  184. let editedCell;
  185. switch (editorSection) {
  186. case 'top':
  187. editedCell = this.instance.view.wt.wtOverlays.topOverlay.clone.wtTable.getCell({
  188. row: this.row,
  189. col: this.col
  190. });
  191. this.holderZIndex = 101;
  192. break;
  193. case 'top-left-corner':
  194. editedCell = this.instance.view.wt.wtOverlays.topLeftCornerOverlay.clone.wtTable.getCell({
  195. row: this.row,
  196. col: this.col
  197. });
  198. this.holderZIndex = 103;
  199. break;
  200. case 'bottom-left-corner':
  201. editedCell = this.instance.view.wt.wtOverlays.bottomLeftCornerOverlay.clone.wtTable.getCell({
  202. row: this.row,
  203. col: this.col
  204. });
  205. this.holderZIndex = 103;
  206. break;
  207. case 'left':
  208. editedCell = this.instance.view.wt.wtOverlays.leftOverlay.clone.wtTable.getCell({
  209. row: this.row,
  210. col: this.col
  211. });
  212. this.holderZIndex = 102;
  213. break;
  214. case 'bottom':
  215. editedCell = this.instance.view.wt.wtOverlays.bottomOverlay.clone.wtTable.getCell({
  216. row: this.row,
  217. col: this.col
  218. });
  219. this.holderZIndex = 102;
  220. break;
  221. default:
  222. editedCell = this.instance.getCell(this.row, this.col);
  223. this.holderZIndex = -1;
  224. break;
  225. }
  226. return editedCell !== -1 && editedCell !== -2 ? editedCell : void 0;
  227. };
  228. TextEditor.prototype.refreshValue = function() {
  229. const physicalRow = this.instance.toPhysicalRow(this.row);
  230. const sourceData = this.instance.getSourceDataAtCell(physicalRow, this.col);
  231. this.originalValue = sourceData;
  232. this.setValue(sourceData);
  233. this.refreshDimensions();
  234. };
  235. TextEditor.prototype.refreshDimensions = function(force = false) {
  236. if (this.state !== EditorState.EDITING && !force) {
  237. return;
  238. }
  239. this.TD = this.getEditedCell();
  240. // TD is outside of the viewport.
  241. if (!this.TD) {
  242. if (!force) {
  243. this.close(true);
  244. }
  245. return;
  246. }
  247. const currentOffset = offset(this.TD);
  248. const containerOffset = offset(this.instance.rootElement);
  249. const scrollableContainerTop = this.instance.view.wt.wtOverlays.topOverlay.mainTableScrollableElement;
  250. const scrollableContainerLeft = this.instance.view.wt.wtOverlays.leftOverlay.mainTableScrollableElement;
  251. const totalRowsCount = this.instance.countRows();
  252. const containerScrollTop = scrollableContainerTop !== window ? scrollableContainerTop.scrollTop : 0;
  253. const containerScrollLeft = scrollableContainerLeft !== window ? scrollableContainerLeft.scrollLeft : 0;
  254. const editorSection = this.checkEditorSection();
  255. const scrollTop = ['', 'left'].includes(editorSection) ? containerScrollTop : 0;
  256. const scrollLeft = ['', 'top', 'bottom'].includes(editorSection) ? containerScrollLeft : 0;
  257. // If colHeaders is disabled, cells in the first row have border-top
  258. const editTopModifier = currentOffset.top === containerOffset.top ? 0 : 1;
  259. const settings = this.instance.getSettings();
  260. const colHeadersCount = this.instance.hasColHeaders();
  261. const backgroundColor = this.TD.style.backgroundColor;
  262. let editTop = currentOffset.top - containerOffset.top - editTopModifier - scrollTop;
  263. let editLeft = currentOffset.left - containerOffset.left - 1 - scrollLeft;
  264. let cssTransformOffset;
  265. // TODO: Refactor this to the new instance.getCell method (from #ply-59), after 0.12.1 is released
  266. switch (editorSection) {
  267. case 'top':
  268. cssTransformOffset = getCssTransform(this.instance.view.wt.wtOverlays.topOverlay.clone.wtTable.holder.parentNode);
  269. break;
  270. case 'left':
  271. cssTransformOffset = getCssTransform(this.instance.view.wt.wtOverlays.leftOverlay.clone.wtTable.holder.parentNode);
  272. break;
  273. case 'top-left-corner':
  274. cssTransformOffset = getCssTransform(this.instance.view.wt.wtOverlays.topLeftCornerOverlay.clone.wtTable.holder.parentNode);
  275. break;
  276. case 'bottom-left-corner':
  277. cssTransformOffset = getCssTransform(this.instance.view.wt.wtOverlays.bottomLeftCornerOverlay.clone.wtTable.holder.parentNode);
  278. break;
  279. case 'bottom':
  280. cssTransformOffset = getCssTransform(this.instance.view.wt.wtOverlays.bottomOverlay.clone.wtTable.holder.parentNode);
  281. break;
  282. default:
  283. break;
  284. }
  285. if (colHeadersCount && this.instance.getSelectedLast()[0] === 0 ||
  286. (settings.fixedRowsBottom && this.instance.getSelectedLast()[0] === totalRowsCount - settings.fixedRowsBottom)) {
  287. editTop += 1;
  288. }
  289. if (this.instance.getSelectedLast()[1] === 0) {
  290. editLeft += 1;
  291. }
  292. if (cssTransformOffset && cssTransformOffset !== -1) {
  293. this.textareaParentStyle[cssTransformOffset[0]] = cssTransformOffset[1];
  294. } else {
  295. resetCssTransform(this.TEXTAREA_PARENT);
  296. }
  297. this.textareaParentStyle.top = `${editTop}px`;
  298. this.textareaParentStyle.left = `${editLeft}px`;
  299. this.showEditableElement();
  300. const firstRowOffset = this.instance.view.wt.wtViewport.rowsRenderCalculator.startPosition;
  301. const firstColumnOffset = this.instance.view.wt.wtViewport.columnsRenderCalculator.startPosition;
  302. const horizontalScrollPosition = this.instance.view.wt.wtOverlays.leftOverlay.getScrollPosition();
  303. const verticalScrollPosition = this.instance.view.wt.wtOverlays.topOverlay.getScrollPosition();
  304. const scrollbarWidth = getScrollbarWidth();
  305. const cellTopOffset = this.TD.offsetTop + firstRowOffset - verticalScrollPosition;
  306. const cellLeftOffset = this.TD.offsetLeft + firstColumnOffset - horizontalScrollPosition;
  307. const width = innerWidth(this.TD) - 8;
  308. const actualVerticalScrollbarWidth = hasVerticalScrollbar(scrollableContainerTop) ? scrollbarWidth : 0;
  309. const actualHorizontalScrollbarWidth = hasHorizontalScrollbar(scrollableContainerLeft) ? scrollbarWidth : 0;
  310. const maxWidth = this.instance.view.maximumVisibleElementWidth(cellLeftOffset) - 9 - actualVerticalScrollbarWidth;
  311. const height = this.TD.scrollHeight + 1;
  312. const maxHeight = Math.max(this.instance.view.maximumVisibleElementHeight(cellTopOffset) - actualHorizontalScrollbarWidth, 23);
  313. const cellComputedStyle = getComputedStyle(this.TD);
  314. this.TEXTAREA.style.fontSize = cellComputedStyle.fontSize;
  315. this.TEXTAREA.style.fontFamily = cellComputedStyle.fontFamily;
  316. this.TEXTAREA.style.backgroundColor = backgroundColor;
  317. this.autoResize.init(this.TEXTAREA, {
  318. minHeight: Math.min(height, maxHeight),
  319. maxHeight, // TEXTAREA should never be higher than visible part of the viewport (should not cover the scrollbar)
  320. minWidth: Math.min(width, maxWidth),
  321. maxWidth // TEXTAREA should never be wider than visible part of the viewport (should not cover the scrollbar)
  322. }, true);
  323. };
  324. TextEditor.prototype.bindEvents = function() {
  325. const editor = this;
  326. this.eventManager.addEventListener(this.TEXTAREA, 'cut', (event) => {
  327. stopPropagation(event);
  328. });
  329. this.eventManager.addEventListener(this.TEXTAREA, 'paste', (event) => {
  330. stopPropagation(event);
  331. });
  332. this.instance.addHook('afterScrollHorizontally', () => {
  333. editor.refreshDimensions();
  334. });
  335. this.instance.addHook('afterScrollVertically', () => {
  336. editor.refreshDimensions();
  337. });
  338. this.instance.addHook('afterColumnResize', () => {
  339. editor.refreshDimensions();
  340. editor.focus();
  341. });
  342. this.instance.addHook('afterRowResize', () => {
  343. editor.refreshDimensions();
  344. editor.focus();
  345. });
  346. this.instance.addHook('afterDestroy', () => {
  347. editor.eventManager.destroy();
  348. });
  349. };
  350. TextEditor.prototype.destroy = function() {
  351. this.eventManager.destroy();
  352. };
  353. export default TextEditor;