Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
17 kB · 553 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554import { ReactNode, useEffect, useRef, useState, type DragEvent } from 'react';
import { DocumentSlotView } from '../../editors/DocumentSlotView';import type { DocumentSlotState } from '../../documents/useDocumentSlot';import { clampRatioForAxis, createInitialTree, DEFAULT_SPLIT_RATIO, listTileIds, planTileRemoval, removeLeaf, splitLeaf, SplitNode, SplitOrientation, TilingNode, updateSplitRatio,} from '../../tiling/model';import { useResizeObserver } from '../../tiling/useResizeObserver';import { WorkspaceBarSlot } from '../barSlot';import type { OpenDocRequest, WorkspaceProps } from '../workspace';import { useTilingDocumentSlots } from './useTilingDocumentSlots';import { TileTabStrip, type TabAction } from './TileTabStrip';import { TileStartView } from './TileStartView';import type { TileTab } from './tabsModel';import { computeDropZone, hasTabDrag, readTabDrag, type DropZone,} from './tabDnd';import { loadLayout, maxIdSuffix, saveLayoutJson, serializeLayout,} from './tilingPersistence';import type { TilingHandle } from '../../actions';
type SplitterProps = { node: SplitNode; onRatioChange: (splitId: string, nextRatio: number) => void; renderNode: (node: TilingNode) => ReactNode;};
function SplitterLayout({ node, onRatioChange, renderNode }: SplitterProps) { const containerRef = useRef<HTMLDivElement>(null); const size = useResizeObserver(containerRef.current);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { const startAxis = node.orientation === 'row' ? event.clientX : event.clientY; const startRatio = node.ratio; const axisSize = node.orientation === 'row' ? size.width : size.height; if (axisSize <= 0) return;
const target = event.currentTarget; target.setPointerCapture(event.pointerId);
const handleMove = (moveEvent: PointerEvent) => { const nextAxis = node.orientation === 'row' ? moveEvent.clientX : moveEvent.clientY; const ratioDelta = (nextAxis - startAxis) / axisSize; const unclamped = startRatio + ratioDelta; const nextRatio = clampRatioForAxis( unclamped, node.orientation, axisSize, ); onRatioChange(node.id, nextRatio); };
const handleUp = () => { target.removeEventListener('pointermove', handleMove); target.removeEventListener('pointerup', handleUp); target.removeEventListener('pointercancel', handleUp); try { target.releasePointerCapture(event.pointerId); } catch { // no-op when pointer capture was already released. } };
target.addEventListener('pointermove', handleMove); target.addEventListener('pointerup', handleUp); target.addEventListener('pointercancel', handleUp); };
const isRow = node.orientation === 'row';
return ( <div ref={containerRef} className={`flex h-full min-h-0 w-full min-w-0 ${ isRow ? 'flex-row' : 'flex-col' }`} > <div className="h-full min-h-0 w-full min-w-0" // Panes grow to share the space the fixed-size splitter leaves, in // ratio : (1 - ratio) proportion. Using flex-grow (not a fixed-% basis) // keeps panes + divider at exactly 100% without hardcoding the splitter // width, so changing the divider can't make tiles overlap. style={{ flex: `${Math.max(0, Math.min(1, node.ratio))} 1 0%` }} > {renderNode(node.first)} </div> <div role="separator" aria-orientation={isRow ? 'vertical' : 'horizontal'} onPointerDown={onPointerDown} className={`bg-border/80 hover:bg-accent/50 active:bg-accent/70 relative z-10 shrink-0 ${ isRow ? 'cursor-col-resize px-[2px] py-0' : 'cursor-row-resize px-0 py-[2px]' }`} /> <div className="h-full min-h-0 w-full min-w-0" style={{ flex: `${Math.max(0, Math.min(1, 1 - node.ratio))} 1 0%` }} > {renderNode(node.second)} </div> </div> );}
/** Region of a tile a dragged tab would land in, drawn as a translucent overlay. */const ZONE_CLASS: Record<DropZone, string> = { center: 'inset-0', left: 'inset-y-0 left-0 w-1/2', right: 'inset-y-0 right-0 w-1/2', top: 'inset-x-0 top-0 h-1/2', bottom: 'inset-x-0 bottom-0 h-1/2',};
function ZoneOverlay({ zone }: { zone: DropZone }) { return ( <div className={`bg-accent/10 pointer-events-none absolute z-10 rounded-sm ${ZONE_CLASS[zone]}`} /> );}
type TilingTileProps = { tileId: string; isActive: boolean; slot: DocumentSlotState; tabs: readonly TileTab[]; activeTabId: string | null; onActivate: () => void; onSelectTab: (tabId: string) => void; onTabAction: (action: TabAction, tabId: string) => void; onMoveTab: ( fromTileId: string, tabId: string, beforeTabId: string | null, ) => void; onDropInZone: (fromTileId: string, tabId: string, zone: DropZone) => void; onNewTab: () => void; onNewFile?: () => void;};
function TilingTile({ tileId, isActive, slot, tabs, activeTabId, onActivate, onSelectTab, onTabAction, onMoveTab, onDropInZone, onNewTab, onNewFile,}: TilingTileProps) { // Zone shown while a tab is dragged over this tile's body (null = no drag). const [dropZone, setDropZone] = useState<DropZone | null>(null);
const zoneFromEvent = (e: DragEvent<HTMLDivElement>): DropZone => { const r = e.currentTarget.getBoundingClientRect(); return computeDropZone( e.clientX - r.left, e.clientY - r.top, r.width, r.height, ); };
// Clear the overlay if the drag ends anywhere (drop elsewhere or cancel). useEffect(() => { if (dropZone === null) return; const clear = () => setDropZone(null); window.addEventListener('dragend', clear); return () => window.removeEventListener('dragend', clear); }, [dropZone]);
return ( <div onMouseDown={onActivate} className={`bg-background border-border h-full min-h-0 w-full overflow-hidden rounded-md border p-0 ${ isActive ? 'ring-accent ring-2 ring-inset' : 'hover:border-accent/50 border-dashed' }`} > <div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden p-2"> <TileTabStrip tileId={tileId} tabs={tabs} activeTabId={activeTabId} onSelect={onSelectTab} onTabAction={onTabAction} onMoveTab={onMoveTab} onNew={onNewTab} /> <div className="relative min-h-0 flex-1" // Entering the body during a tab drag raises the capture overlay, // which then owns dragover/drop so the editor never swallows them. onDragEnter={(e) => { if (!hasTabDrag(e.dataTransfer)) return; e.preventDefault(); // Compute synchronously: React nulls `currentTarget` after the // handler returns, so we must not read it inside the updater. const zone = zoneFromEvent(e); setDropZone((z) => z ?? zone); }} > {slot.kind === 'empty' ? ( <TileStartView onNewFile={onNewFile} className="min-h-0 flex-1" /> ) : ( <DocumentSlotView state={slot} className="min-h-0 flex-1" /> )} {dropZone !== null ? ( <div className="absolute inset-0 z-20" onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDropZone(zoneFromEvent(e)); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setDropZone(null); } }} onDrop={(e) => { e.preventDefault(); const zone = zoneFromEvent(e); const payload = readTabDrag(e.dataTransfer); setDropZone(null); if (payload) onDropInZone(payload.tileId, payload.tabId, zone); }} > <ZoneOverlay zone={dropZone} /> </div> ) : null} </div> </div> </div> );}
export function TilingWorkspace({ active, instanceKey, openRequestId, openRequest, resolveProvider, tilingHandleRef, onNewFile,}: WorkspaceProps) { // Restore this vault's persisted layout once (null when there is none). const [persisted] = useState(() => loadLayout(instanceKey));
// Seed the tile/split id counter past any persisted id to avoid collisions. const [initialIdCounter] = useState(() => persisted ? maxIdSuffix(persisted.tree) + 1 : 2, ); const nextIdRef = useRef(initialIdCounter); const makeId = (prefix: string) => `${prefix}-${nextIdRef.current++}`;
const [tree, setTree] = useState<TilingNode>( () => persisted?.tree ?? createInitialTree('tile-1'), ); const [activeTileId, setActiveTileId] = useState( () => persisted?.activeTileId ?? 'tile-1', );
const { tileTabs, getSlot, getTabs, getActiveTabId, ensureTile, setActiveTab, cycleTab, newTab, closeTab, closeOtherTabs, closeTabsToRight, closeTabsToLeft, closeAllOfFile, moveTab, } = useTilingDocumentSlots({ active, openRequestId, openRequest, resolveProvider, activeTileId, initialTiles: persisted?.tiles ?? null, });
// Persist layout changes for this vault (skips no-op rewrites). const lastSavedRef = useRef<string | null>(null); useEffect(() => { const json = JSON.stringify(serializeLayout(tree, activeTileId, tileTabs)); if (json === lastSavedRef.current) return; lastSavedRef.current = json; saveLayoutJson(instanceKey, json); }, [instanceKey, tree, activeTileId, tileTabs]);
/** * Close a tab. When it was the tile's last tab, collapse the split (or, for * the sole remaining tile, reseed it as an empty tile) and move focus. */ const handleCloseTab = (tileId: string, tabId: string) => { const { tileEmptied } = closeTab(tileId, tabId); if (!tileEmptied) return; const plan = planTileRemoval(tree, tileId, activeTileId); if (plan.kind === 'reseed') { ensureTile(tileId); return; } setTree(plan.nextTree); setActiveTileId(plan.nextActiveTileId); };
/** * Close every tab showing a document across all tiles, collapsing any tile * that loses its last tab (or reseeding the sole remaining tile). */ const handleCloseAllOfFile = (request: OpenDocRequest) => { const { emptiedTileIds } = closeAllOfFile(request); if (emptiedTileIds.length === 0) return; let nextTree = tree; for (const id of emptiedTileIds) { nextTree = removeLeaf(nextTree, id); } setTree(nextTree); const survivors = listTileIds(nextTree); // removeLeaf cannot remove the sole tile; reseed it if it was emptied. for (const id of survivors) { if (emptiedTileIds.includes(id)) ensureTile(id); } if (!survivors.includes(activeTileId)) { setActiveTileId( survivors.find((id) => !emptiedTileIds.includes(id)) ?? survivors[0], ); } };
/** * Move a tab (drag-and-drop) into `toTileId`. When the source tile loses its * last tab, collapse it in the split tree. Focus follows the moved tab. */ const handleMoveTab = ( fromTileId: string, tabId: string, toTileId: string, beforeTabId: string | null, ) => { const { sourceEmptied } = moveTab(fromTileId, tabId, toTileId, beforeTabId); setActiveTileId(toTileId); if (sourceEmptied) { // Cross-tile move emptied the source: the destination tile still exists, // so collapsing the source never removes the last tile. setTree(removeLeaf(tree, fromTileId)); } };
/** * Handle a tab dropped onto a tile's body. Center moves it into the tile; * an edge splits `targetTileId` (new tile on that side) and moves it there. */ const handleDropInZone = ( fromTileId: string, tabId: string, targetTileId: string, zone: DropZone, ) => { if (zone === 'center') { handleMoveTab(fromTileId, tabId, targetTileId, null); return; } const orientation: SplitOrientation = zone === 'left' || zone === 'right' ? 'row' : 'column'; const before = zone === 'left' || zone === 'top'; const split = splitLeaf( tree, targetTileId, orientation, { splitId: makeId('split'), tileId: makeId('tile') }, before, ); if (!split) return; const { sourceEmptied } = moveTab(fromTileId, tabId, split.newTileId, null); let nextTree = split.nextTree; if (sourceEmptied) nextTree = removeLeaf(nextTree, fromTileId); setTree(nextTree); setActiveTileId(split.newTileId); };
/** Dispatch a tab context-menu / close action for a tile. */ const handleTabAction = ( tileId: string, action: TabAction, tabId: string, ) => { switch (action) { case 'close': handleCloseTab(tileId, tabId); break; case 'close-others': closeOtherTabs(tileId, tabId); break; case 'close-right': closeTabsToRight(tileId, tabId); break; case 'close-left': closeTabsToLeft(tileId, tabId); break; case 'close-all-of-file': { const slot = getTabs(tileId).find((t) => t.tabId === tabId)?.slot; if (slot && slot.kind !== 'empty') handleCloseAllOfFile(slot.request); break; } } };
const applySplit = (orientation: SplitOrientation) => { const splitId = makeId('split'); const tileId = makeId('tile'); const result = splitLeaf(tree, activeTileId, orientation, { splitId, tileId, }); if (!result) return; setTree(result.nextTree); setActiveTileId(result.newTileId); ensureTile(result.newTileId); };
// Latest action implementations, refreshed every render so the imperative // handle never closes over a stale tree / activeTileId / tab state. const handleImplRef = useRef<TilingHandle | null>(null); handleImplRef.current = { splitRight: () => applySplit('row'), splitDown: () => applySplit('column'), newTab: () => newTab(activeTileId), closeActiveTab: () => { const tabId = getActiveTabId(activeTileId); if (tabId) handleCloseTab(activeTileId, tabId); }, nextTab: () => cycleTab(activeTileId, 1), prevTab: () => cycleTab(activeTileId, -1), };
useEffect(() => { // Only the shown tiling workspace exposes its handle, so tiling/tab // commands never act on a hidden workspace. The installed handle delegates // to handleImplRef so it always runs the latest implementation. if (!tilingHandleRef || !active) return; const handle: TilingHandle = { splitRight: () => handleImplRef.current?.splitRight(), splitDown: () => handleImplRef.current?.splitDown(), newTab: () => handleImplRef.current?.newTab(), closeActiveTab: () => handleImplRef.current?.closeActiveTab(), nextTab: () => handleImplRef.current?.nextTab(), prevTab: () => handleImplRef.current?.prevTab(), }; tilingHandleRef.current = handle; return () => { tilingHandleRef.current = null; }; }, [tilingHandleRef, active]);
const renderNode = (node: TilingNode): ReactNode => { if (node.kind === 'tile') { const tileId = node.id; return ( <TilingTile key={tileId} tileId={tileId} isActive={tileId === activeTileId} slot={getSlot(tileId)} tabs={getTabs(tileId)} activeTabId={getActiveTabId(tileId)} onActivate={() => setActiveTileId(tileId)} onSelectTab={(tabId) => setActiveTab(tileId, tabId)} onTabAction={(action, tabId) => handleTabAction(tileId, action, tabId) } onMoveTab={(fromTileId, tabId, beforeTabId) => handleMoveTab(fromTileId, tabId, tileId, beforeTabId) } onDropInZone={(fromTileId, tabId, zone) => handleDropInZone(fromTileId, tabId, tileId, zone) } onNewTab={() => { setActiveTileId(tileId); newTab(tileId); }} onNewFile={ onNewFile ? () => { setActiveTileId(tileId); onNewFile(); } : undefined } /> ); }
return ( <SplitterLayout node={node} onRatioChange={(splitId, nextRatio) => { const ratio = Number.isFinite(nextRatio) ? Math.max(0.05, Math.min(0.95, nextRatio)) : DEFAULT_SPLIT_RATIO; setTree((prev) => updateSplitRatio(prev, splitId, ratio)); }} renderNode={renderNode} /> ); };
return ( <> <div className="h-full min-h-0 w-full overflow-hidden p-2"> {renderNode(tree)} </div> <WorkspaceBarSlot> <div className="text-foreground/60 min-w-0 flex-1 truncate text-xs"> Active: {activeTileId} </div> </WorkspaceBarSlot> </> );}