const globalWindow = window; export function CodeJar(editor, highlight, opt = {}) { const options = { tab: '\t', indentOn: /[({\[]$/, moveToNewLine: /^[)}\]]/, spellcheck: false, catchTab: true, preserveIdent: true, addClosing: true, history: true, window: globalWindow, ...opt, }; const window = options.window; const document = window.document; const listeners = []; const history = []; let at = -1; let focus = false; let onUpdate = () => void 0; let prev; // code content prior keydown event editor.setAttribute('contenteditable', 'plaintext-only'); editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false'); editor.style.outline = 'none'; editor.style.overflowWrap = 'break-word'; editor.style.overflowY = 'auto'; editor.style.whiteSpace = 'pre-wrap'; const doHighlight = (editor, pos) => { highlight(editor, pos); }; let isLegacy = false; // true if plaintext-only is not supported if (editor.contentEditable !== 'plaintext-only') isLegacy = true; if (isLegacy) editor.setAttribute('contenteditable', 'true'); const debounceHighlight = debounce(() => { const pos = save(); doHighlight(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 legacyNewLineFix(event); if (options.catchTab) handleTabCharacters(event); if (options.addClosing) handleSelfClosingCharacters(event); if (options.history) { handleUndoRedo(event); if (shouldRecord(event) && !recording) { recordHistory(); recording = true; } } if (isLegacy && !isCopy(event)) restore(save()); }); on('keyup', event => { if (event.defaultPrevented) return; if (event.isComposing) return; if (prev !== toString()) debounceHighlight(); debounceRecordHistory(event); onUpdate(toString()); }); on('focus', _event => { focus = true; }); on('blur', _event => { focus = false; }); on('paste', event => { recordHistory(); handlePaste(event); recordHistory(); onUpdate(toString()); }); on('cut', event => { recordHistory(); handleCut(event); recordHistory(); onUpdate(toString()); }); function save() { const s = getSelection(); const pos = { start: 0, end: 0, dir: undefined }; let { anchorNode, anchorOffset, focusNode, focusOffset } = s; if (!anchorNode || !focusNode) throw 'error1'; // If the anchor and focus are the editor element, return either a full // highlight or a start/end cursor position depending on the selection if (anchorNode === editor && focusNode === editor) { pos.start = (anchorOffset > 0 && editor.textContent) ? editor.textContent.length : 0; pos.end = (focusOffset > 0 && editor.textContent) ? editor.textContent.length : 0; pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-'; return pos; } // Selection anchor and focus are expected to be text nodes, // so normalize them. if (anchorNode.nodeType === Node.ELEMENT_NODE) { const node = document.createTextNode(''); anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset]); anchorNode = node; anchorOffset = 0; } if (focusNode.nodeType === Node.ELEMENT_NODE) { const node = document.createTextNode(''); focusNode.insertBefore(node, focusNode.childNodes[focusOffset]); focusNode = node; focusOffset = 0; } visit(editor, el => { if (el === anchorNode && el === focusNode) { pos.start += anchorOffset; pos.end += focusOffset; pos.dir = anchorOffset <= focusOffset ? '->' : '<-'; return 'stop'; } if (el === anchorNode) { pos.start += anchorOffset; if (!pos.dir) { pos.dir = '->'; } else { return 'stop'; } } else if (el === focusNode) { pos.end += 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; } }); editor.normalize(); // collapse empty text nodes 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 (!startNode) startNode = editor, startOffset = editor.childNodes.length; if (!endNode) endNode = editor, endOffset = editor.childNodes.length; // Flip back the selection if (pos.dir == '<-') { [startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset]; } { // If nodes not editable, create a text node. const startEl = uneditable(startNode); if (startEl) { const node = document.createTextNode(''); startEl.parentNode?.insertBefore(node, startEl); startNode = node; startOffset = 0; } const endEl = uneditable(endNode); if (endEl) { const node = document.createTextNode(''); endEl.parentNode?.insertBefore(node, endEl); endNode = node; endOffset = 0; } } s.setBaseAndExtent(startNode, startOffset, endNode, endOffset); editor.normalize(); // collapse empty text nodes } function uneditable(node) { while (node && node !== editor) { if (node.nodeType === Node.ELEMENT_NODE) { const el = node; if (el.getAttribute('contenteditable') == 'false') { return el; } } node = node.parentNode; } } 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 if (options.indentOn.test(before)) { newLinePadding += options.tab; } // Preserve padding if (newLinePadding.length > 0) { preventDefault(event); event.stopPropagation(); insert('\n' + newLinePadding); } else { legacyNewLineFix(event); } // Place adjacent "}" on next line if (newLinePadding !== padding && options.moveToNewLine.test(after)) { const pos = save(); insert('\n' + padding); restore(pos); } } } function legacyNewLineFix(event) { // Firefox does not support plaintext-only mode // and puts