codejar.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. const globalWindow = window;
  2. function CodeJar(editor, highlight, opt = {}) {
  3. const options = Object.assign({ tab: '\t', indentOn: /{$/, spellcheck: false, catchTab: true, preserveIdent: true, addClosing: true, history: true, window: globalWindow }, opt);
  4. const window = options.window;
  5. const document = window.document;
  6. let listeners = [];
  7. let history = [];
  8. let at = -1;
  9. let focus = false;
  10. let callback;
  11. let prev; // code content prior keydown event
  12. let isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
  13. editor.setAttribute('contentEditable', isFirefox ? 'true' : 'plaintext-only');
  14. editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false');
  15. editor.style.outline = 'none';
  16. editor.style.overflowWrap = 'break-word';
  17. editor.style.overflowY = 'auto';
  18. editor.style.resize = 'vertical';
  19. editor.style.whiteSpace = 'pre-wrap';
  20. highlight(editor);
  21. const debounceHighlight = debounce(() => {
  22. const pos = save();
  23. highlight(editor, pos);
  24. restore(pos);
  25. }, 30);
  26. let recording = false;
  27. const shouldRecord = (event) => {
  28. return !isUndo(event) && !isRedo(event)
  29. && event.key !== 'Meta'
  30. && event.key !== 'Control'
  31. && event.key !== 'Alt'
  32. && !event.key.startsWith('Arrow');
  33. };
  34. const debounceRecordHistory = debounce((event) => {
  35. if (shouldRecord(event)) {
  36. recordHistory();
  37. recording = false;
  38. }
  39. }, 300);
  40. const on = (type, fn) => {
  41. listeners.push([type, fn]);
  42. editor.addEventListener(type, fn);
  43. };
  44. on('keydown', event => {
  45. if (event.defaultPrevented)
  46. return;
  47. prev = toString();
  48. if (options.preserveIdent)
  49. handleNewLine(event);
  50. else
  51. firefoxNewLineFix(event);
  52. if (options.catchTab)
  53. handleTabCharacters(event);
  54. if (options.addClosing)
  55. handleSelfClosingCharacters(event);
  56. if (options.history) {
  57. handleUndoRedo(event);
  58. if (shouldRecord(event) && !recording) {
  59. recordHistory();
  60. recording = true;
  61. }
  62. }
  63. });
  64. on('keyup', event => {
  65. if (event.defaultPrevented)
  66. return;
  67. if (event.isComposing)
  68. return;
  69. if (prev !== toString())
  70. debounceHighlight();
  71. debounceRecordHistory(event);
  72. if (callback)
  73. callback(toString());
  74. });
  75. on('focus', _event => {
  76. focus = true;
  77. });
  78. on('blur', _event => {
  79. focus = false;
  80. });
  81. on('paste', event => {
  82. recordHistory();
  83. handlePaste(event);
  84. recordHistory();
  85. if (callback)
  86. callback(toString());
  87. });
  88. function save() {
  89. const s = getSelection();
  90. const pos = { start: 0, end: 0, dir: undefined };
  91. visit(editor, el => {
  92. if (el === s.anchorNode && el === s.focusNode) {
  93. pos.start += s.anchorOffset;
  94. pos.end += s.focusOffset;
  95. pos.dir = s.anchorOffset <= s.focusOffset ? '->' : '<-';
  96. return 'stop';
  97. }
  98. if (el === s.anchorNode) {
  99. pos.start += s.anchorOffset;
  100. if (!pos.dir) {
  101. pos.dir = '->';
  102. }
  103. else {
  104. return 'stop';
  105. }
  106. }
  107. else if (el === s.focusNode) {
  108. pos.end += s.focusOffset;
  109. if (!pos.dir) {
  110. pos.dir = '<-';
  111. }
  112. else {
  113. return 'stop';
  114. }
  115. }
  116. if (el.nodeType === Node.TEXT_NODE) {
  117. if (pos.dir != '->')
  118. pos.start += el.nodeValue.length;
  119. if (pos.dir != '<-')
  120. pos.end += el.nodeValue.length;
  121. }
  122. });
  123. return pos;
  124. }
  125. function restore(pos) {
  126. const s = getSelection();
  127. let startNode, startOffset = 0;
  128. let endNode, endOffset = 0;
  129. if (!pos.dir)
  130. pos.dir = '->';
  131. if (pos.start < 0)
  132. pos.start = 0;
  133. if (pos.end < 0)
  134. pos.end = 0;
  135. // Flip start and end if the direction reversed
  136. if (pos.dir == '<-') {
  137. const { start, end } = pos;
  138. pos.start = end;
  139. pos.end = start;
  140. }
  141. let current = 0;
  142. visit(editor, el => {
  143. if (el.nodeType !== Node.TEXT_NODE)
  144. return;
  145. const len = (el.nodeValue || '').length;
  146. if (current + len >= pos.start) {
  147. if (!startNode) {
  148. startNode = el;
  149. startOffset = pos.start - current;
  150. }
  151. if (current + len >= pos.end) {
  152. endNode = el;
  153. endOffset = pos.end - current;
  154. return 'stop';
  155. }
  156. }
  157. current += len;
  158. });
  159. // If everything deleted place cursor at editor
  160. if (!startNode)
  161. startNode = editor;
  162. if (!endNode)
  163. endNode = editor;
  164. // Flip back the selection
  165. if (pos.dir == '<-') {
  166. [startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset];
  167. }
  168. s.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
  169. }
  170. function beforeCursor() {
  171. const s = getSelection();
  172. const r0 = s.getRangeAt(0);
  173. const r = document.createRange();
  174. r.selectNodeContents(editor);
  175. r.setEnd(r0.startContainer, r0.startOffset);
  176. return r.toString();
  177. }
  178. function afterCursor() {
  179. const s = getSelection();
  180. const r0 = s.getRangeAt(0);
  181. const r = document.createRange();
  182. r.selectNodeContents(editor);
  183. r.setStart(r0.endContainer, r0.endOffset);
  184. return r.toString();
  185. }
  186. function handleNewLine(event) {
  187. if (event.key === 'Enter') {
  188. const before = beforeCursor();
  189. const after = afterCursor();
  190. let [padding] = findPadding(before);
  191. let newLinePadding = padding;
  192. // If last symbol is "{" ident new line
  193. // Allow user defines indent rule
  194. if (options.indentOn.test(before)) {
  195. newLinePadding += options.tab;
  196. }
  197. // Preserve padding
  198. if (newLinePadding.length > 0) {
  199. preventDefault(event);
  200. event.stopPropagation();
  201. insert('\n' + newLinePadding);
  202. }
  203. else {
  204. firefoxNewLineFix(event);
  205. }
  206. // Place adjacent "}" on next line
  207. if (newLinePadding !== padding && after[0] === '}') {
  208. const pos = save();
  209. insert('\n' + padding);
  210. restore(pos);
  211. }
  212. }
  213. }
  214. function firefoxNewLineFix(event) {
  215. // Firefox does not support plaintext-only mode
  216. // and puts <div><br></div> on Enter. Let's help.
  217. if (isFirefox && event.key === 'Enter') {
  218. preventDefault(event);
  219. event.stopPropagation();
  220. if (afterCursor() == '') {
  221. insert('\n ');
  222. const pos = save();
  223. pos.start = --pos.end;
  224. restore(pos);
  225. }
  226. else {
  227. insert('\n');
  228. }
  229. }
  230. }
  231. function handleSelfClosingCharacters(event) {
  232. const open = `([{'"`;
  233. const close = `)]}'"`;
  234. const codeAfter = afterCursor();
  235. const codeBefore = beforeCursor();
  236. const escapeCharacter = codeBefore.substr(codeBefore.length - 1) === '\\';
  237. const charAfter = codeAfter.substr(0, 1);
  238. if (close.includes(event.key) && !escapeCharacter && charAfter === event.key) {
  239. // We already have closing char next to cursor.
  240. // Move one char to right.
  241. const pos = save();
  242. preventDefault(event);
  243. pos.start = ++pos.end;
  244. restore(pos);
  245. }
  246. else if (open.includes(event.key)
  247. && !escapeCharacter
  248. && (`"'`.includes(event.key) || ['', ' ', '\n'].includes(charAfter))) {
  249. preventDefault(event);
  250. const pos = save();
  251. const wrapText = pos.start == pos.end ? '' : getSelection().toString();
  252. const text = event.key + wrapText + close[open.indexOf(event.key)];
  253. insert(text);
  254. pos.start++;
  255. pos.end++;
  256. restore(pos);
  257. }
  258. }
  259. function handleTabCharacters(event) {
  260. if (event.key === 'Tab') {
  261. preventDefault(event);
  262. if (event.shiftKey) {
  263. const before = beforeCursor();
  264. let [padding, start,] = findPadding(before);
  265. if (padding.length > 0) {
  266. const pos = save();
  267. // Remove full length tab or just remaining padding
  268. const len = Math.min(options.tab.length, padding.length);
  269. restore({ start, end: start + len });
  270. document.execCommand('delete');
  271. pos.start -= len;
  272. pos.end -= len;
  273. restore(pos);
  274. }
  275. }
  276. else {
  277. insert(options.tab);
  278. }
  279. }
  280. }
  281. function handleUndoRedo(event) {
  282. if (isUndo(event)) {
  283. preventDefault(event);
  284. at--;
  285. const record = history[at];
  286. if (record) {
  287. editor.innerHTML = record.html;
  288. restore(record.pos);
  289. }
  290. if (at < 0)
  291. at = 0;
  292. }
  293. if (isRedo(event)) {
  294. preventDefault(event);
  295. at++;
  296. const record = history[at];
  297. if (record) {
  298. editor.innerHTML = record.html;
  299. restore(record.pos);
  300. }
  301. if (at >= history.length)
  302. at--;
  303. }
  304. }
  305. function recordHistory() {
  306. if (!focus)
  307. return;
  308. const html = editor.innerHTML;
  309. const pos = save();
  310. const lastRecord = history[at];
  311. if (lastRecord) {
  312. if (lastRecord.html === html
  313. && lastRecord.pos.start === pos.start
  314. && lastRecord.pos.end === pos.end)
  315. return;
  316. }
  317. at++;
  318. history[at] = { html, pos };
  319. history.splice(at + 1);
  320. const maxHistory = 300;
  321. if (at > maxHistory) {
  322. at = maxHistory;
  323. history.splice(0, 1);
  324. }
  325. }
  326. function handlePaste(event) {
  327. preventDefault(event);
  328. const text = (event.originalEvent || event)
  329. .clipboardData
  330. .getData('text/plain')
  331. .replace(/\r/g, '');
  332. const pos = save();
  333. insert(text);
  334. highlight(editor);
  335. restore({ start: pos.start + text.length, end: pos.start + text.length });
  336. }
  337. function visit(editor, visitor) {
  338. const queue = [];
  339. if (editor.firstChild)
  340. queue.push(editor.firstChild);
  341. let el = queue.pop();
  342. while (el) {
  343. if (visitor(el) === 'stop')
  344. break;
  345. if (el.nextSibling)
  346. queue.push(el.nextSibling);
  347. if (el.firstChild)
  348. queue.push(el.firstChild);
  349. el = queue.pop();
  350. }
  351. }
  352. function isCtrl(event) {
  353. return event.metaKey || event.ctrlKey;
  354. }
  355. function isUndo(event) {
  356. return isCtrl(event) && !event.shiftKey && event.key === 'z';
  357. }
  358. function isRedo(event) {
  359. return isCtrl(event) && event.shiftKey && event.key === 'z';
  360. }
  361. function insert(text) {
  362. text = text
  363. .replace(/&/g, '&amp;')
  364. .replace(/</g, '&lt;')
  365. .replace(/>/g, '&gt;')
  366. .replace(/"/g, '&quot;')
  367. .replace(/'/g, '&#039;');
  368. document.execCommand('insertHTML', false, text);
  369. }
  370. function debounce(cb, wait) {
  371. let timeout = 0;
  372. return (...args) => {
  373. clearTimeout(timeout);
  374. timeout = window.setTimeout(() => cb(...args), wait);
  375. };
  376. }
  377. function findPadding(text) {
  378. // Find beginning of previous line.
  379. let i = text.length - 1;
  380. while (i >= 0 && text[i] !== '\n')
  381. i--;
  382. i++;
  383. // Find padding of the line.
  384. let j = i;
  385. while (j < text.length && /[ \t]/.test(text[j]))
  386. j++;
  387. return [text.substring(i, j) || '', i, j];
  388. }
  389. function toString() {
  390. return editor.textContent || '';
  391. }
  392. function preventDefault(event) {
  393. event.preventDefault();
  394. }
  395. function getSelection() {
  396. var _a;
  397. if (((_a = editor.parentNode) === null || _a === void 0 ? void 0 : _a.nodeType) == Node.DOCUMENT_FRAGMENT_NODE) {
  398. return editor.parentNode.getSelection();
  399. }
  400. return window.getSelection();
  401. }
  402. return {
  403. updateOptions(options) {
  404. options = Object.assign(Object.assign({}, options), options);
  405. },
  406. updateCode(code) {
  407. editor.textContent = code;
  408. highlight(editor);
  409. },
  410. onUpdate(cb) {
  411. callback = cb;
  412. },
  413. toString,
  414. save,
  415. restore,
  416. recordHistory,
  417. destroy() {
  418. for (let [type, fn] of listeners) {
  419. editor.removeEventListener(type, fn);
  420. }
  421. },
  422. };
  423. }