TextBox.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. import {
  2. assign,
  3. bind,
  4. pick
  5. } from 'min-dash';
  6. import {
  7. domify,
  8. query as domQuery,
  9. event as domEvent,
  10. remove as domRemove
  11. } from 'min-dom';
  12. var min = Math.min,
  13. max = Math.max;
  14. function preventDefault(e) {
  15. e.preventDefault();
  16. }
  17. function stopPropagation(e) {
  18. e.stopPropagation();
  19. }
  20. function isTextNode(node) {
  21. return node.nodeType === Node.TEXT_NODE;
  22. }
  23. function toArray(nodeList) {
  24. return [].slice.call(nodeList);
  25. }
  26. /**
  27. * Initializes a container for a content editable div.
  28. *
  29. * Structure:
  30. *
  31. * container
  32. * parent
  33. * content
  34. * resize-handle
  35. *
  36. * @param {object} options
  37. * @param {DOMElement} options.container The DOM element to append the contentContainer to
  38. * @param {Function} options.keyHandler Handler for key events
  39. * @param {Function} options.resizeHandler Handler for resize events
  40. */
  41. export default function TextBox(options) {
  42. this.container = options.container;
  43. this.parent = domify(
  44. '<div class="djs-direct-editing-parent">' +
  45. '<div class="djs-direct-editing-content" contenteditable="true"></div>' +
  46. '</div>'
  47. );
  48. this.content = domQuery('[contenteditable]', this.parent);
  49. this.keyHandler = options.keyHandler || function() {};
  50. this.resizeHandler = options.resizeHandler || function() {};
  51. this.autoResize = bind(this.autoResize, this);
  52. this.handlePaste = bind(this.handlePaste, this);
  53. }
  54. /**
  55. * Create a text box with the given position, size, style and text content
  56. *
  57. * @param {Object} bounds
  58. * @param {Number} bounds.x absolute x position
  59. * @param {Number} bounds.y absolute y position
  60. * @param {Number} [bounds.width] fixed width value
  61. * @param {Number} [bounds.height] fixed height value
  62. * @param {Number} [bounds.maxWidth] maximum width value
  63. * @param {Number} [bounds.maxHeight] maximum height value
  64. * @param {Number} [bounds.minWidth] minimum width value
  65. * @param {Number} [bounds.minHeight] minimum height value
  66. * @param {Object} [style]
  67. * @param {String} value text content
  68. *
  69. * @return {DOMElement} The created content DOM element
  70. */
  71. TextBox.prototype.create = function(bounds, style, value, options) {
  72. var self = this;
  73. var parent = this.parent,
  74. content = this.content,
  75. container = this.container;
  76. options = this.options = options || {};
  77. style = this.style = style || {};
  78. var parentStyle = pick(style, [
  79. 'width',
  80. 'height',
  81. 'maxWidth',
  82. 'maxHeight',
  83. 'minWidth',
  84. 'minHeight',
  85. 'left',
  86. 'top',
  87. 'backgroundColor',
  88. 'position',
  89. 'overflow',
  90. 'border',
  91. 'wordWrap',
  92. 'textAlign',
  93. 'outline',
  94. 'transform'
  95. ]);
  96. assign(parent.style, {
  97. width: bounds.width + 'px',
  98. height: bounds.height + 'px',
  99. maxWidth: bounds.maxWidth + 'px',
  100. maxHeight: bounds.maxHeight + 'px',
  101. minWidth: bounds.minWidth + 'px',
  102. minHeight: bounds.minHeight + 'px',
  103. left: bounds.x + 'px',
  104. top: bounds.y + 'px',
  105. backgroundColor: '#ffffff',
  106. position: 'absolute',
  107. overflow: 'visible',
  108. border: '1px solid #ccc',
  109. boxSizing: 'border-box',
  110. wordWrap: 'normal',
  111. textAlign: 'center',
  112. outline: 'none'
  113. }, parentStyle);
  114. var contentStyle = pick(style, [
  115. 'fontFamily',
  116. 'fontSize',
  117. 'fontWeight',
  118. 'lineHeight',
  119. 'padding',
  120. 'paddingTop',
  121. 'paddingRight',
  122. 'paddingBottom',
  123. 'paddingLeft'
  124. ]);
  125. assign(content.style, {
  126. boxSizing: 'border-box',
  127. width: '100%',
  128. outline: 'none',
  129. wordWrap: 'break-word'
  130. }, contentStyle);
  131. if (options.centerVertically) {
  132. assign(content.style, {
  133. position: 'absolute',
  134. top: '50%',
  135. transform: 'translate(0, -50%)'
  136. }, contentStyle);
  137. }
  138. content.innerText = value;
  139. domEvent.bind(content, 'keydown', this.keyHandler);
  140. domEvent.bind(content, 'mousedown', stopPropagation);
  141. domEvent.bind(content, 'paste', self.handlePaste);
  142. if (options.autoResize) {
  143. domEvent.bind(content, 'input', this.autoResize);
  144. }
  145. if (options.resizable) {
  146. this.resizable(style);
  147. }
  148. container.appendChild(parent);
  149. // set selection to end of text
  150. this.setSelection(content.lastChild, content.lastChild && content.lastChild.length);
  151. return parent;
  152. };
  153. /**
  154. * Intercept paste events to remove formatting from pasted text.
  155. */
  156. TextBox.prototype.handlePaste = function(e) {
  157. var options = this.options,
  158. style = this.style;
  159. e.preventDefault();
  160. var text;
  161. if (e.clipboardData) {
  162. // Chrome, Firefox, Safari
  163. text = e.clipboardData.getData('text/plain');
  164. } else {
  165. // Internet Explorer
  166. text = window.clipboardData.getData('Text');
  167. }
  168. this.insertText(text);
  169. if (options.autoResize) {
  170. var hasResized = this.autoResize(style);
  171. if (hasResized) {
  172. this.resizeHandler(hasResized);
  173. }
  174. }
  175. };
  176. TextBox.prototype.insertText = function(text) {
  177. text = normalizeEndOfLineSequences(text);
  178. // insertText command not supported by Internet Explorer
  179. var success = document.execCommand('insertText', false, text);
  180. if (success) {
  181. return;
  182. }
  183. this._insertTextIE(text);
  184. };
  185. TextBox.prototype._insertTextIE = function(text) {
  186. // Internet Explorer
  187. var range = this.getSelection(),
  188. startContainer = range.startContainer,
  189. endContainer = range.endContainer,
  190. startOffset = range.startOffset,
  191. endOffset = range.endOffset,
  192. commonAncestorContainer = range.commonAncestorContainer;
  193. var childNodesArray = toArray(commonAncestorContainer.childNodes);
  194. var container,
  195. offset;
  196. if (isTextNode(commonAncestorContainer)) {
  197. var containerTextContent = startContainer.textContent;
  198. startContainer.textContent =
  199. containerTextContent.substring(0, startOffset)
  200. + text
  201. + containerTextContent.substring(endOffset);
  202. container = startContainer;
  203. offset = startOffset + text.length;
  204. } else if (startContainer === this.content && endContainer === this.content) {
  205. var textNode = document.createTextNode(text);
  206. this.content.insertBefore(textNode, childNodesArray[startOffset]);
  207. container = textNode;
  208. offset = textNode.textContent.length;
  209. } else {
  210. var startContainerChildIndex = childNodesArray.indexOf(startContainer),
  211. endContainerChildIndex = childNodesArray.indexOf(endContainer);
  212. childNodesArray.forEach(function(childNode, index) {
  213. if (index === startContainerChildIndex) {
  214. childNode.textContent =
  215. startContainer.textContent.substring(0, startOffset) +
  216. text +
  217. endContainer.textContent.substring(endOffset);
  218. } else if (index > startContainerChildIndex && index <= endContainerChildIndex) {
  219. domRemove(childNode);
  220. }
  221. });
  222. container = startContainer;
  223. offset = startOffset + text.length;
  224. }
  225. if (container && offset !== undefined) {
  226. // is necessary in Internet Explorer
  227. setTimeout(function() {
  228. self.setSelection(container, offset);
  229. });
  230. }
  231. };
  232. /**
  233. * Automatically resize element vertically to fit its content.
  234. */
  235. TextBox.prototype.autoResize = function() {
  236. var parent = this.parent,
  237. content = this.content;
  238. var fontSize = parseInt(this.style.fontSize) || 12;
  239. if (content.scrollHeight > parent.offsetHeight ||
  240. content.scrollHeight < parent.offsetHeight - fontSize) {
  241. var bounds = parent.getBoundingClientRect();
  242. var height = content.scrollHeight;
  243. parent.style.height = height + 'px';
  244. this.resizeHandler({
  245. width: bounds.width,
  246. height: bounds.height,
  247. dx: 0,
  248. dy: height - bounds.height
  249. });
  250. }
  251. };
  252. /**
  253. * Make an element resizable by adding a resize handle.
  254. */
  255. TextBox.prototype.resizable = function() {
  256. var self = this;
  257. var parent = this.parent,
  258. resizeHandle = this.resizeHandle;
  259. var minWidth = parseInt(this.style.minWidth) || 0,
  260. minHeight = parseInt(this.style.minHeight) || 0,
  261. maxWidth = parseInt(this.style.maxWidth) || Infinity,
  262. maxHeight = parseInt(this.style.maxHeight) || Infinity;
  263. if (!resizeHandle) {
  264. resizeHandle = this.resizeHandle = domify(
  265. '<div class="djs-direct-editing-resize-handle"></div>'
  266. );
  267. var startX, startY, startWidth, startHeight;
  268. var onMouseDown = function(e) {
  269. preventDefault(e);
  270. stopPropagation(e);
  271. startX = e.clientX;
  272. startY = e.clientY;
  273. var bounds = parent.getBoundingClientRect();
  274. startWidth = bounds.width;
  275. startHeight = bounds.height;
  276. domEvent.bind(document, 'mousemove', onMouseMove);
  277. domEvent.bind(document, 'mouseup', onMouseUp);
  278. };
  279. var onMouseMove = function(e) {
  280. preventDefault(e);
  281. stopPropagation(e);
  282. var newWidth = min(max(startWidth + e.clientX - startX, minWidth), maxWidth);
  283. var newHeight = min(max(startHeight + e.clientY - startY, minHeight), maxHeight);
  284. parent.style.width = newWidth + 'px';
  285. parent.style.height = newHeight + 'px';
  286. self.resizeHandler({
  287. width: startWidth,
  288. height: startHeight,
  289. dx: e.clientX - startX,
  290. dy: e.clientY - startY
  291. });
  292. };
  293. var onMouseUp = function(e) {
  294. preventDefault(e);
  295. stopPropagation(e);
  296. domEvent.unbind(document,'mousemove', onMouseMove, false);
  297. domEvent.unbind(document, 'mouseup', onMouseUp, false);
  298. };
  299. domEvent.bind(resizeHandle, 'mousedown', onMouseDown);
  300. }
  301. assign(resizeHandle.style, {
  302. position: 'absolute',
  303. bottom: '0px',
  304. right: '0px',
  305. cursor: 'nwse-resize',
  306. width: '0',
  307. height: '0',
  308. borderTop: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid transparent',
  309. borderRight: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid #ccc',
  310. borderBottom: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid #ccc',
  311. borderLeft: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid transparent'
  312. });
  313. parent.appendChild(resizeHandle);
  314. };
  315. /**
  316. * Clear content and style of the textbox, unbind listeners and
  317. * reset CSS style.
  318. */
  319. TextBox.prototype.destroy = function() {
  320. var parent = this.parent,
  321. content = this.content,
  322. resizeHandle = this.resizeHandle;
  323. // clear content
  324. content.innerText = '';
  325. // clear styles
  326. parent.removeAttribute('style');
  327. content.removeAttribute('style');
  328. domEvent.unbind(content, 'keydown', this.keyHandler);
  329. domEvent.unbind(content, 'mousedown', stopPropagation);
  330. domEvent.unbind(content, 'input', this.autoResize);
  331. domEvent.unbind(content, 'paste', this.handlePaste);
  332. if (resizeHandle) {
  333. resizeHandle.removeAttribute('style');
  334. domRemove(resizeHandle);
  335. }
  336. domRemove(parent);
  337. };
  338. TextBox.prototype.getValue = function() {
  339. return this.content.innerText.trim();
  340. };
  341. TextBox.prototype.getSelection = function() {
  342. var selection = window.getSelection(),
  343. range = selection.getRangeAt(0);
  344. return range;
  345. };
  346. TextBox.prototype.setSelection = function(container, offset) {
  347. var range = document.createRange();
  348. if (container === null) {
  349. range.selectNodeContents(this.content);
  350. } else {
  351. range.setStart(container, offset);
  352. range.setEnd(container, offset);
  353. }
  354. var selection = window.getSelection();
  355. selection.removeAllRanges();
  356. selection.addRange(range);
  357. };
  358. // helpers //////////
  359. function normalizeEndOfLineSequences(string) {
  360. return string.replace(/\r\n|\r|\n/g, '\n');
  361. }