import { 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(null); const size = useResizeObserver(containerRef.current); const onPointerDown = (event: React.PointerEvent) => { 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 (
{renderNode(node.first)}
{renderNode(node.second)}
); } /** Region of a tile a dragged tab would land in, drawn as a translucent overlay. */ const ZONE_CLASS: Record = { 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 (
); } 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(null); const zoneFromEvent = (e: DragEvent): 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 (
{ 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' ? ( ) : ( )} {dropZone !== null ? (
{ 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); }} >
) : null}
); } 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( () => 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(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(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 ( 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 ( { 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 ( <>
{renderNode(tree)}
Active: {activeTileId}
); }