|
|
@@ -0,0 +1,423 @@
|
|
|
+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 <div><br></div> 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, '"')
|
|
|
+ .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);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|