mirror of
				https://github.com/pocketpy/pocketpy
				synced 2025-10-31 00:40:16 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			424 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			424 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 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);
 | |
|             }
 | |
|         },
 | |
|     };
 | |
| }
 |