PageSourceSearch

https://markdownlab.vercel.app/js/tabs.js

js markdownlab.vercel.app collected 2026-10-03 07:16:51 UTC 6,913 bytes, 197 lines download raw bytes

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.