1/** 2 * File tab strip above the editor. 3 * 4 * One `.tab` per open file, showing filename, optional dirty dot, and a 5 * close button. Tabs: click to activate, middle-click / à to close (with 6 * confirm if dirty), drag to reorder, Ctrl+Tab / Ctrl+Shift+Tab cycles. 7 * Active tab scrolls into view when the strip overflows. 8 */ 9 10import { Store, activateFile, closeFile, reorderTabs, createFile, uniqueFileName } from './projects.js'; 11import { escapeHtml, cssEscape } from './utils.js'; 12 13let els = {}; 14let hooks = { 15 onActivate: () => {}, 16 onClose: () => {}, 17 onCreate: () => {}, 18}; 19 20export function initTabs(callbacks = {}) { 21 hooks = { ...hooks, ...callbacks }; 22 els = { 23 scroll: document.getElementById('tabs-scroll'), 24 add: document.getElementById('btn-tabs-add'), 25 }; 26 27 els.add?.addEventListener('click', async () => { 28 const active = Store.focusedProject(); 29 if (!active) return; 30 const name = uniqueFileName(active.id, 'Untitled.md'); 31 const f = await createFile({ projectId: active.id, name, content: '' }); 32 hooks.onActivate(f.id); 33 hooks.onCreate(f.id); 34 }); 35 36 els.scroll?.addEventListener('wheel', (e) => { 37 if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { 38 els.scroll.scrollLeft += e.deltaY; 39 e.preventDefault(); 40 } 41 }, { passive: false }); 42 43 document.addEventListener('dragend', () => { 44 els.scroll?.querySelectorAll('.tab').forEach(el => { 45 el.classList.remove('is-drag-over', 'is-drop-before', 'is-drop-after'); 46 }); 47 }); 48 49 Store.on((kind, payload) => { 50 if (kind === 'file:renamed') { 51 const t = els.scroll?.querySelector(`.tab[data-file-id="${cssEscape(payload.file.id)}"] .tab__name`); 52 if (t) t.textContent = payload.file.name; 53 return; 54 } 55 if (kind === 'file:dirty' || kind === 'file:saved') { 56 renderDirtyState(); 57 return; 58 } 59 render(); 60 }); 61 62 render(); 63} 64 65function render() { 66 if (!els.scroll) return; 67 const open = Store.openFiles(); 68 if (open.length === 0) { 69 els.scroll.innerHTML = `<div class="tabs__empty">No files open · pick one from the sidebar</div>`; 70 return; 71 } 72 const frag = document.createDocumentFragment(); 73 open.forEach(f => frag.appendChild(renderTab(f))); 74 els.scroll.replaceChildren(frag); 75 scrollActiveIntoView(); 76} 77 78function renderTab(file) { 79 const el = document.createElement('button'); 80 el.type = 'button'; 81 el.className = 'tab'; 82 if (Store.activeId === file.id) el.classList.add('is-active'); 83 el.dataset.fileId = file.id; 84 el.setAttribute('role', 'tab'); 85 el.setAttribute('aria-selected', String(Store.activeId === file.id)); 86 el.setAttribute('draggable', 'true'); 87 88 el.innerHTML = ` 89 <svg class="tab__icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 90 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> 91 <polyline points="14 2 14 8 20 8"/> 92 </svg> 93 <span class="tab__name" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span> 94 ${Store.dirty.has(file.id) ? '<span class="tab__dirty" aria-label="Unsaved changes"></span>' : ''} 95 <button type="button" class="tab__close" aria-label="Close ${escapeHtml(file.name)}" title="Close (middle-click / Ctrl/Cmd + W)">
96 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 97 </button> 98 `; 99 100 el.addEventListener('click', (e) => { 101 if (e.target.closest('.tab__close')) { 102 e.preventDefault(); 103 handleClose(file.id); 104 return; 105 } 106 hooks.onActivate(file.id); 107 }); 108 109 el.addEventListener('auxclick', (e) => { 110 if (e.button === 1) { e.preventDefault(); handleClose(file.id); } 111 }); 112 113 el.addEventListener('dragstart', (e) => { 114 e.dataTransfer.setData('text/mdlab-tab', file.id); 115 e.dataTransfer.effectAllowed = 'move'; 116 }); 117 let tabEnterCount = 0; 118 el.addEventListener('dragenter', (e) => { 119 if (!e.dataTransfer.types.includes('text/mdlab-tab')) return; 120 tabEnterCount++; 121 el.classList.add('is-drag-over'); 122 }); 123 el.addEventListener('dragover', (e) => { 124 if (!e.dataTransfer.types.includes('text/mdlab-tab')) return; 125 e.preventDefault(); 126 e.dataTransfer.dropEffect = 'move'; 127 const rect = el.getBoundingClientRect(); 128 const afterMid = e.clientX > rect.left + rect.width / 2; 129 el.classList.toggle('is-drop-after', afterMid); 130 el.classList.toggle('is-drop-before', !afterMid); 131 }); 132 el.addEventListener('dragleave', () => { 133 tabEnterCount = Math.max(0, tabEnterCount - 1); 134 if (tabEnterCount === 0) { 135 el.classList.remove('is-drag-over', 'is-drop-before', 'is-drop-after'); 136 } 137 }); 138 el.addEventListener('drop', async (e) => { 139 tabEnterCount = 0; 140 const rect = el.getBoundingClientRect(); 141 const afterMid = e.clientX > rect.left + rect.width / 2; 142 el.classList.remove('is-drag-over', 'is-drop-before', 'is-drop-after'); 143 const dragged = e.dataTransfer.getData('text/mdlab-tab'); 144 if (!dragged || dragged === file.id) return; 145 e.preventDefault(); 146 const order = Store.openIds.filter(id => id !== dragged); 147 const idx = order.indexOf(file.id); 148 const insertAt = afterMid ? idx + 1 : idx; 149 order.splice(insertAt, 0, dragged); 150 await reorderTabs(order); 151 }); 152 153 return el; 154} 155 156function handleClose(id) { 157 if (Store.dirty.has(id)) { 158 const file = Store.files.get(id); 159 if (!confirm(`"${file?.name || 'This file'}" has unsaved changes. Close anyway?`)) return; 160 } 161 closeFile(id).then(() => hooks.onClose(id)); 162} 163 164function renderDirtyState() { 165 if (!els.scroll) return; 166 els.scroll.querySelectorAll('.tab').forEach(row => { 167 const id = row.dataset.fileId; 168 const has = !!row.querySelector('.tab__dirty'); 169 const should = Store.dirty.has(id); 170 if (has === should) return; 171 if (should) { 172 const dot = document.createElement('span'); 173 dot.className = 'tab__dirty'; 174 dot.setAttribute('aria-label', 'Unsaved changes'); 175 row.insertBefore(dot, row.querySelector('.tab__close')); 176 } else { 177 row.querySelector('.tab__dirty')?.remove(); 178 } 179 }); 180} 181 182function scrollActiveIntoView() { 183 const active = els.scroll?.querySelector('.tab.is-active'); 184 if (!active) return; 185 const cR = els.scroll.getBoundingClientRect(); 186 const aR = active.getBoundingClientRect(); 187 if (aR.left < cR.left) els.scroll.scrollBy({ left: aR.left - cR.left - 12, behavior: 'smooth' }); 188 else if (aR.right > cR.right) els.scroll.scrollBy({ left: aR.right - cR.right + 12, behavior: 'smooth' }); 189} 190 191export function cycleTab(dir) { 192 const ids = Store.openIds; 193 if (ids.length < 2) return; 194 const idx = ids.indexOf(Store.activeId); 195 const next = ids[(idx + dir + ids.length) % ids.length]; 196 activateFile(next); 197}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.