const globalWindow = window; function CodeJar(editor, highlight, opt = {}) { const options = Object.assign({ tab: '\t', indentOn: /{$/, spellcheck: false, catchTab: true, preserveIdent: true, addClosing: true, history: true, window: globalWindow }, opt); const window = options.window; const document = window.document; let listeners = []; let history = []; let at = -1; let focus = false; let callback; let prev; // code content prior keydown event let isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; editor.setAttribute('contentEditable', isFirefox ? 'true' : 'plaintext-only'); editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false'); editor.style.outline = 'none'; editor.style.overflowWrap = 'break-word'; editor.style.overflowY = 'auto'; editor.style.resize = 'vertical'; editor.style.whiteSpace = 'pre-wrap'; highlight(editor); const debounceHighlight = debounce(() => { const pos = save(); highlight(editor, pos); restore(pos); }, 30); let recording = false; const shouldRecord = (event) => { return !isUndo(event) && !isRedo(event) && event.key !== 'Meta' && event.key !== 'Control' && event.key !== 'Alt' && !event.key.startsWith('Arrow'); }; const debounceRecordHistory = debounce((event) => { if (shouldRecord(event)) { recordHistory(); recording = false; } }, 300); const on = (type, fn) => { listeners.push([type, fn]); editor.addEventListener(type, fn); }; on('keydown', event => { if (event.defaultPrevented) return; prev = toString(); if (options.preserveIdent) handleNewLine(event); else firefoxNewLineFix(event); if (options.catchTab) handleTabCharacters(event); if (options.addClosing) handleSelfClosingCharacters(event); if (options.history) { handleUndoRedo(event); if (shouldRecord(event) && !recording) { recordHistory(); recording = true; } } }); on('keyup', event => { if (event.defaultPrevented) return; if (event.isComposing) return; if (prev !== toString()) debounceHighlight(); debounceRecordHistory(event); if (callback) callback(toString()); }); on('focus', _event => { focus = true; }); on('blur', _event => { focus = false; }); on('paste', event => { recordHistory(); handlePaste(event); recordHistory(); if (callback) callback(toString()); }); function save() { const s = getSelection(); const pos = { start: 0, end: 0, dir: undefined }; visit(editor, el => { if (el === s.anchorNode && el === s.focusNode) { pos.start += s.anchorOffset; pos.end += s.focusOffset; pos.dir = s.anchorOffset <= s.focusOffset ? '->' : '<-'; return 'stop'; } if (el === s.anchorNode) { pos.start += s.anchorOffset; if (!pos.dir) { pos.dir = '->'; } else { return 'stop'; } } else if (el === s.focusNode) { pos.end += s.focusOffset; if (!pos.dir) { pos.dir = '<-'; } else { return 'stop'; } } if (el.nodeType === Node.TEXT_NODE) { if (pos.dir != '->') pos.start += el.nodeValue.length; if (pos.dir != '<-') pos.end += el.nodeValue.length; } }); return pos; } function restore(pos) { const s = getSelection(); let startNode, startOffset = 0; let endNode, endOffset = 0; if (!pos.dir) pos.dir = '->'; if (pos.start < 0) pos.start = 0; if (pos.end < 0) pos.end = 0; // Flip start and end if the direction reversed if (pos.dir == '<-') { const { start, end } = pos; pos.start = end; pos.end = start; } let current = 0; visit(editor, el => { if (el.nodeType !== Node.TEXT_NODE) return; const len = (el.nodeValue || '').length; if (current + len >= pos.start) { if (!startNode) { startNode = el; startOffset = pos.start - current; } if (current + len >= pos.end) { endNode = el; endOffset = pos.end - current; return 'stop'; } } current += len; }); // If everything deleted place cursor at editor if (!startNode) startNode = editor; if (!endNode) endNode = editor; // Flip back the selection if (pos.dir == '<-') { [startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset]; } s.setBaseAndExtent(startNode, startOffset, endNode, endOffset); } function beforeCursor() { const s = getSelection(); const r0 = s.getRangeAt(0); const r = document.createRange(); r.selectNodeContents(editor); r.setEnd(r0.startContainer, r0.startOffset); return r.toString(); } function afterCursor() { const s = getSelection(); const r0 = s.getRangeAt(0); const r = document.createRange(); r.selectNodeContents(editor); r.setStart(r0.endContainer, r0.endOffset); return r.toString(); } function handleNewLine(event) { if (event.key === 'Enter') { const before = beforeCursor(); const after = afterCursor(); let [padding] = findPadding(before); let newLinePadding = padding; // If last symbol is "{" ident new line // Allow user defines indent rule if (options.indentOn.test(before)) { newLinePadding += options.tab; } // Preserve padding if (newLinePadding.length > 0) { preventDefault(event); event.stopPropagation(); insert('\n' + newLinePadding); } else { firefoxNewLineFix(event); } // Place adjacent "}" on next line if (newLinePadding !== padding && after[0] === '}') { const pos = save(); insert('\n' + padding); restore(pos); } } } function firefoxNewLineFix(event) { // Firefox does not support plaintext-only mode // and puts

on Enter. Let's help. if (isFirefox && event.key === 'Enter') { preventDefault(event); event.stopPropagation(); if (afterCursor() == '') { insert('\n '); const pos = save(); pos.start = --pos.end; restore(pos); } else { insert('\n'); } } } function handleSelfClosingCharacters(event) { const open = `([{'"`; const close = `)]}'"`; const codeAfter = afterCursor(); const codeBefore = beforeCursor(); const escapeCharacter = codeBefore.substr(codeBefore.length - 1) === '\\'; const charAfter = codeAfter.substr(0, 1); if (close.includes(event.key) && !escapeCharacter && charAfter === event.key) { // We already have closing char next to cursor. // Move one char to right. const pos = save(); preventDefault(event); pos.start = ++pos.end; restore(pos); } else if (open.includes(event.key) && !escapeCharacter && (`"'`.includes(event.key) || ['', ' ', '\n'].includes(charAfter))) { preventDefault(event); const pos = save(); const wrapText = pos.start == pos.end ? '' : getSelection().toString(); const text = event.key + wrapText + close[open.indexOf(event.key)]; insert(text); pos.start++; pos.end++; restore(pos); } } function handleTabCharacters(event) { if (event.key === 'Tab') { preventDefault(event); if (event.shiftKey) { const before = beforeCursor(); let [padding, start,] = findPadding(before); if (padding.length > 0) { const pos = save(); // Remove full length tab or just remaining padding const len = Math.min(options.tab.length, padding.length); restore({ start, end: start + len }); document.execCommand('delete'); pos.start -= len; pos.end -= len; restore(pos); } } else { insert(options.tab); } } } function handleUndoRedo(event) { if (isUndo(event)) { preventDefault(event); at--; const record = history[at]; if (record) { editor.innerHTML = record.html; restore(record.pos); } if (at < 0) at = 0; } if (isRedo(event)) { preventDefault(event); at++; const record = history[at]; if (record) { editor.innerHTML = record.html; restore(record.pos); } if (at >= history.length) at--; } } function recordHistory() { if (!focus) return; const html = editor.innerHTML; const pos = save(); const lastRecord = history[at]; if (lastRecord) { if (lastRecord.html === html && lastRecord.pos.start === pos.start && lastRecord.pos.end === pos.end) return; } at++; history[at] = { html, pos }; history.splice(at + 1); const maxHistory = 300; if (at > maxHistory) { at = maxHistory; history.splice(0, 1); } } function handlePaste(event) { preventDefault(event); const text = (event.originalEvent || event) .clipboardData .getData('text/plain') .replace(/\r/g, ''); const pos = save(); insert(text); highlight(editor); restore({ start: pos.start + text.length, end: pos.start + text.length }); } function visit(editor, visitor) { const queue = []; if (editor.firstChild) queue.push(editor.firstChild); let el = queue.pop(); while (el) { if (visitor(el) === 'stop') break; if (el.nextSibling) queue.push(el.nextSibling); if (el.firstChild) queue.push(el.firstChild); el = queue.pop(); } } function isCtrl(event) { return event.metaKey || event.ctrlKey; } function isUndo(event) { return isCtrl(event) && !event.shiftKey && event.key === 'z'; } function isRedo(event) { return isCtrl(event) && event.shiftKey && event.key === 'z'; } function insert(text) { text = text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); document.execCommand('insertHTML', false, text); } function debounce(cb, wait) { let timeout = 0; return (...args) => { clearTimeout(timeout); timeout = window.setTimeout(() => cb(...args), wait); }; } function findPadding(text) { // Find beginning of previous line. let i = text.length - 1; while (i >= 0 && text[i] !== '\n') i--; i++; // Find padding of the line. let j = i; while (j < text.length && /[ \t]/.test(text[j])) j++; return [text.substring(i, j) || '', i, j]; } function toString() { return editor.textContent || ''; } function preventDefault(event) { event.preventDefault(); } function getSelection() { var _a; if (((_a = editor.parentNode) === null || _a === void 0 ? void 0 : _a.nodeType) == Node.DOCUMENT_FRAGMENT_NODE) { return editor.parentNode.getSelection(); } return window.getSelection(); } return { updateOptions(options) { options = Object.assign(Object.assign({}, options), options); }, updateCode(code) { editor.textContent = code; highlight(editor); }, onUpdate(cb) { callback = cb; }, toString, save, restore, recordHistory, destroy() { for (let [type, fn] of listeners) { editor.removeEventListener(type, fn); } }, }; }