import { canHighlight, editorLanguage } from './editor-language.mjs'; export function createEditorUI(element) { const input = element.querySelector('#editor'), highlight = element.querySelector('#editor-highlight'); let path = '', revision = 0, timer, watchdog, worker, active = false, composing = false, queued; function syncScroll() { // Match the textarea's actual viewport, excluding either native scrollbar. highlight.style.width = input.clientWidth + 'px'; highlight.style.height = input.clientHeight + 'px'; highlight.scrollTop = input.scrollTop; highlight.scrollLeft = input.scrollLeft; } function plain() { element.classList.remove('is-highlighted'); } function stopWorker() { clearTimeout(watchdog); worker?.terminate(); worker = undefined; active = false; } function send() { if (active || !queued) return; const job = queued; queued = undefined; try { if (!worker) { worker = new Worker(new URL('./editor-highlight.worker.mjs', import.meta.url), { type: 'module' }); worker.onmessage = ({ data }) => { clearTimeout(watchdog); active = false; if (data.id === revision && data.html !== null) { // Prism escapes file contents before adding its own token spans. highlight.innerHTML = data.html + '\n'; syncScroll(); element.classList.add('is-highlighted'); } send(); }; worker.onerror = () => { stopWorker(); queued = undefined; }; } active = true; worker.postMessage(job); // A pathological regex can only occupy this worker, and is terminated. watchdog = setTimeout(() => { stopWorker(); send(); }, 1500); } catch { stopWorker(); } } function refresh(delay = 0) { revision++; clearTimeout(timer); queued = undefined; plain(); const language = editorLanguage(path), text = input.value; element.dataset.language = language ?? 'plain'; if (composing || !text || !language || !canHighlight(text)) return; const id = revision; timer = setTimeout(() => { queued = { id, text, language }; send(); }, delay); } input.addEventListener('input', () => refresh(80)); input.addEventListener('compositionstart', () => { composing = true; refresh(); }); input.addEventListener('compositionend', () => { composing = false; refresh(80); }); input.addEventListener('scroll', syncScroll, { passive: true }); const resize = new ResizeObserver(syncScroll); resize.observe(input); return { setFile(nextPath, text, placeholder = nextPath ? 'Empty file.' : 'Select a file to inspect or edit it.') { input.placeholder = placeholder; if (path === nextPath && input.value === text) return; const changed = path !== nextPath; path = nextPath; if (input.value !== text) input.value = text; if (changed) { input.scrollTop = 0; input.scrollLeft = 0; } refresh(); }, dispose() { clearTimeout(timer); stopWorker(); resize.disconnect(); }, }; }