Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
9.6 kB · 269 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270import { useEffect, useRef, useState, type DragEvent, type MouseEvent,} from 'react';
import { PlusIcon, XIcon } from '../../icons';import type { DocumentSlotState } from '../../documents/useDocumentSlot';import { useContextMenu, type ContextMenuItem,} from '../../platform/contextMenu';import type { TileTab } from './tabsModel';import { hasTabDrag, readTabDrag, setTabDrag } from './tabDnd';import './TileTabStrip.css';
export type TabAction = | 'close' | 'close-others' | 'close-right' | 'close-left' | 'close-all-of-file';
function tabTitle(slot: DocumentSlotState): string { // Empty tabs are never rendered as chips (see below), so this only ever // formats a tab that holds a document request. return slot.kind === 'empty' ? '' : slot.request.title;}
type TileTabStripProps = { tileId: string; tabs: readonly TileTab[]; activeTabId: string | null; onSelect: (tabId: string) => void; onTabAction: (action: TabAction, tabId: string) => void; /** Drop a tab (from `fromTileId`) into this tile before `beforeTabId`. */ onMoveTab: ( fromTileId: string, tabId: string, beforeTabId: string | null, ) => void; onNew: () => void;};
/** * Horizontal tab strip for a single tile. Empty tabs are the tile's start page, * not chips, so only documents get a tab. Chips can be dragged to reorder or to * move between tiles; right-click opens a native context menu of tab actions. */export function TileTabStrip({ tileId, tabs, activeTabId, onSelect, onTabAction, onMoveTab, onNew,}: TileTabStripProps) { const showContextMenu = useContextMenu();
// The horizontal scroll container. A ref (not just the class) so we can attach // a non-passive wheel listener — React registers `onWheel` as passive, which // silently drops preventDefault(). const stripRef = useRef<HTMLDivElement>(null);
// Let a plain vertical mouse wheel scroll the tab strip sideways while the // pointer is over it. Trackpads already emit deltaX for horizontal gestures, // so we drive scrollLeft from whichever axis the user pushed hardest. useEffect(() => { const el = stripRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { // Nothing overflows horizontally — leave the event alone so it can scroll // an ancestor as usual. if (el.scrollWidth <= el.clientWidth) return; const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; if (delta === 0) return; el.scrollLeft += delta; e.preventDefault(); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []);
// Insertion point shown while dragging: undefined = no drag in progress, // string = before that tab, null = at the end. const [dropBefore, setDropBefore] = useState<string | null | undefined>( undefined, );
// Empty tabs are the tile's start page, not their own chip. const documentTabs = tabs.filter((tab) => tab.slot.kind !== 'empty');
const isTabDrag = (e: DragEvent) => hasTabDrag(e.dataTransfer);
const handleAuxClose = (event: MouseEvent, tabId: string) => { // Middle-click closes the tab. if (event.button === 1) { event.preventDefault(); onTabAction('close', tabId); } };
const handleContextMenu = async (event: MouseEvent, tabId: string) => { event.preventDefault(); // Index/length use the full tab list to match the model's close ops. const index = tabs.findIndex((t) => t.tabId === tabId); const items: ContextMenuItem[] = [{ id: 'close', label: 'Close' }]; if (tabs.length > 1) items.push({ id: 'close-others', label: 'Close others' }); if (index < tabs.length - 1) { items.push({ id: 'close-right', label: 'Close to the right' }); } if (index > 0) items.push({ id: 'close-left', label: 'Close to the left' }); items.push({ id: 'sep', label: '', type: 'separator' }); items.push({ id: 'close-all-of-file', label: 'Close all of this file' });
const action = await showContextMenu(items); if (action && action !== 'sep') { onTabAction(action as TabAction, tabId); } };
const handleDragStart = (e: DragEvent, tabId: string) => { setTabDrag(e.dataTransfer, { tileId, tabId }); // Snapshotting the live chip is unreliable: it can be clipped by the tile's // overflow/border and pull in neighbouring strip content. Instead render an // independent, unclipped clone off-screen and use that as the drag image. const chip = e.currentTarget as HTMLElement; const rect = chip.getBoundingClientRect(); const ghost = chip.cloneNode(true) as HTMLElement; Object.assign(ghost.style, { position: 'fixed', top: '-1000px', left: '0', width: `${rect.width}px`, height: `${rect.height}px`, pointerEvents: 'none', backgroundColor: 'var(--color-background)', border: '1px solid var(--color-border)', borderRadius: '6px', boxShadow: '0 2px 8px rgb(0 0 0 / 0.25)', opacity: '0.95', }); document.body.appendChild(ghost); e.dataTransfer.setDragImage( ghost, e.clientX - rect.left, e.clientY - rect.top, ); // Remove after the browser has captured the image (next task). window.setTimeout(() => ghost.remove(), 0); };
// Drop position when hovering a chip: before it (left half) or before the // next chip (right half). const chipBeforeId = (e: DragEvent, chipIndex: number): string | null => { const rect = e.currentTarget.getBoundingClientRect(); const onLeftHalf = e.clientX < rect.left + rect.width / 2; return onLeftHalf ? documentTabs[chipIndex].tabId : (documentTabs[chipIndex + 1]?.tabId ?? null); };
const performDrop = (e: DragEvent, beforeTabId: string | null) => { setDropBefore(undefined); const payload = readTabDrag(e.dataTransfer); if (payload) onMoveTab(payload.tileId, payload.tabId, beforeTabId); };
const marker = ( <div className="bg-accent my-0.5 w-0.5 shrink-0 self-stretch rounded" /> );
return ( <div ref={stripRef} role="tablist" aria-label="Open documents" className="tab-strip-scroll border-border mb-2 flex shrink-0 items-center gap-1 overflow-x-auto border-b pb-1" onDragOver={(e) => { if (!isTabDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDropBefore(null); // background = append (chips override below) }} onDragLeave={(e) => { // Clear the marker only when the drag actually leaves this strip — not // when moving onto one of its own chips (dragleave bubbles from them). if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setDropBefore(undefined); } }} onDrop={(e) => { if (!isTabDrag(e)) return; e.preventDefault(); performDrop(e, null); }} > {documentTabs.map((tab, i) => { const isActive = tab.tabId === activeTabId; const title = tabTitle(tab.slot); return ( <div key={tab.tabId} className="flex shrink-0 items-center gap-1"> {dropBefore === tab.tabId ? marker : null} <div role="tab" aria-selected={isActive} aria-label={title} tabIndex={isActive ? 0 : -1} draggable onDragStart={(e) => handleDragStart(e, tab.tabId)} onDragEnd={() => setDropBefore(undefined)} onDragOver={(e) => { if (!isTabDrag(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; setDropBefore(chipBeforeId(e, i)); }} onDrop={(e) => { if (!isTabDrag(e)) return; e.preventDefault(); e.stopPropagation(); performDrop(e, chipBeforeId(e, i)); }} onMouseDown={(e) => handleAuxClose(e, tab.tabId)} onClick={() => onSelect(tab.tabId)} onContextMenu={(e) => handleContextMenu(e, tab.tabId)} className={`group flex shrink-0 cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs transition-none ${ isActive ? 'bg-accent/15 text-foreground' : 'text-foreground/60 hover:bg-border/30' }`} > <span className="max-w-[12rem] truncate">{title}</span> <button type="button" tabIndex={-1} aria-label={`Close ${title}`} title={`Close ${title}`} onClick={(e) => { e.stopPropagation(); onTabAction('close', tab.tabId); }} className="text-foreground/40 hover:text-foreground hover:bg-border/50 flex cursor-pointer items-center rounded p-0.5" > <XIcon className="h-3 w-3" /> </button> </div> </div> ); })} {dropBefore === null ? marker : null} <button type="button" aria-label="New tab" title="New tab" onClick={onNew} className="text-foreground/50 hover:text-foreground hover:bg-border/30 ml-auto flex shrink-0 cursor-pointer items-center rounded p-1" > <PlusIcon className="h-3.5 w-3.5" /> </button> </div> );}