type Options = { tab: string indentOn: RegExp spellcheck: boolean addClosing: boolean } type HistoryRecord = { html: string pos: Position } type Position = { start: number end: number dir?: "->" | "<-" } export type CodeJar = ReturnType export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement) => void, opt: Partial = {}) { const options: Options = { tab: "\t", indentOn: /{$/, spellcheck: false, addClosing: true, ...opt } let listeners: [string, any][] = [] let history: HistoryRecord[] = [] let at = -1 let focus = false let callback: (code: string) => void | undefined let prev: string // 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) restore(pos) }, 30) let recording = false const shouldRecord = (event: KeyboardEvent): boolean => { return !isUndo(event) && !isRedo(event) && event.key !== "Meta" && event.key !== "Control" && event.key !== "Alt" && !event.key.startsWith("Arrow") } const debounceRecordHistory = debounce((event: KeyboardEvent) => { if (shouldRecord(event)) { recordHistory() recording = false } }, 300) const on = (type: K, fn: (event: HTMLElementEventMap[K]) => void) => { listeners.push([type, fn]) editor.addEventListener(type, fn) } on("keydown", event => { if (event.defaultPrevented) return prev = toString() handleNewLine(event) handleTabCharacters(event) if (options.addClosing) handleSelfClosingCharacters(event) 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(): Position { const s = window.getSelection()! const pos: Position = {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: Position) { const s = window.getSelection()! let startNode: Node | undefined, startOffset = 0 let endNode: Node | undefined, 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 = window.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 = window.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: KeyboardEvent) { 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 } if (isFirefox) { preventDefault(event) insert("\n" + newLinePadding) } else { // Normal browsers if (newLinePadding.length > 0) { preventDefault(event) insert("\n" + newLinePadding) } } // Place adjacent "}" on next line if (newLinePadding !== padding && after[0] === "}") { const pos = save() insert("\n" + padding) restore(pos) } } } function handleSelfClosingCharacters(event: KeyboardEvent) { const open = `([{'"` const close = `)]}'"` const codeAfter = afterCursor() if (close.includes(event.key) && codeAfter.substr(0, 1) === event.key) { const pos = save() preventDefault(event) pos.start = ++pos.end restore(pos) } else if (open.includes(event.key)) { const pos = save() preventDefault(event) const text = event.key + close[open.indexOf(event.key)] insert(text) pos.start = ++pos.end restore(pos) } } function handleTabCharacters(event: KeyboardEvent) { 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: KeyboardEvent) { 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: ClipboardEvent) { preventDefault(event) const text = ((event as any).originalEvent || event).clipboardData.getData("text/plain") const pos = save() insert(text) highlight(editor) restore({start: pos.end + text.length, end: pos.end + text.length}) } function visit(editor: HTMLElement, visitor: (el: Node) => "stop" | undefined) { const queue: Node[] = [] 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: KeyboardEvent) { return event.metaKey || event.ctrlKey } function isUndo(event: KeyboardEvent) { return isCtrl(event) && !event.shiftKey && event.code === "KeyZ" } function isRedo(event: KeyboardEvent) { return isCtrl(event) && event.shiftKey && event.code === "KeyZ" } function insert(text: string) { text = text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") document.execCommand("insertHTML", false, text) } function debounce(cb: any, wait: number) { let timeout = 0 return (...args: any) => { clearTimeout(timeout) timeout = window.setTimeout(() => cb(...args), wait) } } function findPadding(text: string): [string, number, number] { // 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) { event.preventDefault() } return { updateOptions(options: Partial) { options = {...options, ...options} }, updateCode(code: string) { editor.textContent = code highlight(editor) }, onUpdate(cb: (code: string) => void) { callback = cb }, toString, destroy() { for (let [type, fn] of listeners) { editor.removeEventListener(type, fn) } }, } }