Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
16 kB · 506 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507import { createContext, useContext, useEffect, useCallback, useRef, useState,} from 'react';import type { FileSystemTreeNode } from '../filesystem/types';import type { ContextMenuItem } from '../platform/contextMenu';
/** * Shared drag-and-drop state for the tree. While a file is dragged, exactly one * directory is the drop target (the deepest folder under the cursor); it * highlights its whole subtree. */type TreeDnd = { targetDirId: string | null; setTargetDirId: (id: string | null) => void;};const TreeDndContext = createContext<TreeDnd | null>(null);
export type InlineCreateState = { parentId?: string; type: 'file' | 'folder'; name: string; error: string | null;};
export type FileSystemTreeProps = { nodes: FileSystemTreeNode[]; /** Id of the vault root, so files can be dropped at the top level. */ rootId?: string; onSelect?: (node: FileSystemTreeNode) => void; onContextMenu?: (node: FileSystemTreeNode, items: ContextMenuItem[]) => void; /** Move `entryId` into the directory `targetDirId` (file drag-and-drop). */ onMove?: (entryId: string, targetDirId: string) => void; selectedId?: string; renameNodeId?: string; renameValue?: string; renameExt?: string; renameError?: string | null; onRenameValueChange?: (value: string) => void; onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void; inlineCreate?: InlineCreateState; onInlineCreateChange?: (name: string) => void; onInlineCreateSubmit?: (name: string) => void; onInlineCreateCancel?: () => void;};
/** Shared row style for the active (highlighted) item in the tree. */const ACTIVE_ROW = 'bg-accent/10 text-foreground';const INACTIVE_ROW = 'text-foreground/70 hover:bg-border/35';/** Tint applied to a directory's whole subtree while it is the drop target. */const DROP_TARGET_REGION = 'rounded-sm bg-accent/15';
/** Drag payload for moving a file: carries the entry id. */const FILE_DND_MIME = 'application/x-textile-file';
/** Build an independent, off-screen drag image labelled with the file name. */function makeFileDragImage(name: string): HTMLElement { const ghost = document.createElement('div'); ghost.textContent = name; Object.assign(ghost.style, { position: 'fixed', top: '-1000px', left: '0', padding: '2px 8px', fontSize: '12px', borderRadius: '6px', background: 'var(--color-background)', color: 'var(--color-foreground)', border: '1px solid var(--color-border)', boxShadow: '0 2px 8px rgb(0 0 0 / 0.25)', whiteSpace: 'nowrap', pointerEvents: 'none', }); document.body.appendChild(ghost); return ghost;}
function TriangleRight({ className }: { className?: string }) { return ( <svg className={className} viewBox="0 0 3 4" fill="currentColor" aria-hidden > <polygon points="0,0 3,2 0,4" /> </svg> );}
function TriangleDown({ className }: { className?: string }) { return ( <svg className={className} viewBox="0 0 4 3" fill="currentColor" aria-hidden > <polygon points="0,0 4,0 2,3" /> </svg> );}
function InlineCreateInput({ depth, type, name, error, onChange, onSubmit, onCancel,}: { depth: number; type: 'file' | 'folder'; name: string; error: string | null; onChange: (value: string) => void; onSubmit: (value: string) => void; onCancel: () => void;}) { const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { inputRef.current?.focus(); inputRef.current?.select(); }, []);
return ( <div className={`flex w-full items-center gap-1 px-2 py-0.5 text-left text-xs ${ACTIVE_ROW}`} style={{ paddingLeft: `${depth * 12}px` }} > <span className="w-1.5 shrink-0" /> <div className="flex min-w-0 flex-1 flex-col"> <input ref={inputRef} type="text" value={name} placeholder={type === 'file' ? 'Untitled.md' : 'New Folder'} onChange={(e) => onChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onSubmit(e.currentTarget.value.trim()); } else if (e.key === 'Escape') { onCancel(); } }} onBlur={() => onCancel()} className="text-foreground w-full min-w-0 border-0 bg-transparent p-0 text-xs outline-none" /> {error && <span className="text-destructive text-[10px]">{error}</span>} </div> </div> );}
function TreeNode({ node, depth, parentDirId, selectedId, onSelect, onContextMenu, renameNodeId, renameValue, renameExt, renameError, onRenameValueChange, onRenameSubmit, onRenameCancel, inlineCreate, onInlineCreateChange, onInlineCreateSubmit, onInlineCreateCancel,}: { node: FileSystemTreeNode; depth: number; /** Id of the directory containing this node (undefined at the vault root). */ parentDirId?: string; selectedId?: string; onSelect?: (node: FileSystemTreeNode) => void; onContextMenu?: (node: FileSystemTreeNode, items: ContextMenuItem[]) => void; renameNodeId?: string; renameValue?: string; renameExt?: string; renameError?: string | null; onRenameValueChange?: (value: string) => void; onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void; inlineCreate?: InlineCreateState; onInlineCreateChange?: (name: string) => void; onInlineCreateSubmit?: (name: string) => void; onInlineCreateCancel?: () => void;}) { const [expanded, setExpanded] = useState(depth < 2); const dnd = useContext(TreeDndContext); const isActive = node.id === renameNodeId || (node.id === selectedId && !inlineCreate); const hasChildren = node.children && node.children.length > 0; const isRenaming = node.id === renameNodeId; const isCreatingHere = inlineCreate?.parentId === node.id; const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (isRenaming) { inputRef.current?.focus(); inputRef.current?.select(); } }, [isRenaming]);
useEffect(() => { if (isCreatingHere) { setExpanded(true); } }, [isCreatingHere]);
const handleClick = useCallback(() => { if (node.type === 'folder') { setExpanded((e) => !e); } onSelect?.(node); }, [node, onSelect]);
const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!onContextMenu) return; const items: ContextMenuItem[] = node.type === 'folder' ? [ { id: 'new-note', label: 'New Note' }, { id: 'new-folder', label: 'New Folder' }, { id: 'copy', label: 'Copy' }, { id: 'copy-path', label: 'Copy Path' }, { id: 'rename', label: 'Rename' }, { id: 'delete', label: 'Delete' }, ] : [ { id: 'open-right', label: 'Open in new tile to the right' }, { id: 'open-below', label: 'Open in new tile below' }, { id: 'open-sep', label: '', type: 'separator' }, { id: 'copy', label: 'Copy' }, { id: 'rename', label: 'Rename' }, { id: 'delete', label: 'Delete' }, ]; onContextMenu(node, items); }, [node, onContextMenu], );
const isFile = node.type === 'file'; const isFolder = node.type === 'folder'; // The directory a drop here lands in: this folder itself, or — for a file — // the folder that contains it. Undefined for items at the vault root. const enclosingDirId = isFolder ? node.id : parentDirId; const isDropTarget = isFolder && dnd?.targetDirId === node.id;
const handleDragStart = (e: React.DragEvent) => { e.dataTransfer.setData(FILE_DND_MIME, node.id); e.dataTransfer.effectAllowed = 'move'; const ghost = makeFileDragImage(node.name); e.dataTransfer.setDragImage(ghost, 8, ghost.offsetHeight / 2); window.setTimeout(() => ghost.remove(), 0); };
return ( <div className={isDropTarget ? DROP_TARGET_REGION : undefined}> <div className={`flex w-full items-center gap-1 px-2 py-0.5 text-left text-xs ${ isActive ? ACTIVE_ROW : INACTIVE_ROW }`} style={{ paddingLeft: `${depth * 12}px` }} // The directory this row drops into; the tree container resolves the // target via the nearest ancestor carrying this attribute. data-drop-dir={enclosingDirId} draggable={isFile} onDragStart={isFile ? handleDragStart : undefined} onContextMenu={handleContextMenu} > {node.type === 'folder' && ( <button type="button" onClick={() => setExpanded((e) => !e)} className="text-foreground/40 flex h-1.5 w-1.5 shrink-0 cursor-pointer items-center justify-center" > {expanded ? ( <TriangleDown className="h-1.5 w-1.5" /> ) : ( <TriangleRight className="h-1.5 w-1.5" /> )} </button> )} {node.type === 'file' && <span className="w-1.5" />} {isRenaming ? ( <div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 items-center"> <input ref={inputRef} type="text" value={renameValue ?? ''} onChange={(e) => onRenameValueChange?.(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onRenameSubmit?.(e.currentTarget.value.trim()); } else if (e.key === 'Escape') { onRenameCancel?.(); } }} onBlur={() => onRenameCancel?.()} className="text-foreground min-w-0 border-0 bg-transparent p-0 text-xs outline-none" /> {renameExt && ( <span className="text-foreground/40 shrink-0 text-xs"> {renameExt} </span> )} </div> {renameError && ( <span className="text-destructive text-[10px]"> {renameError} </span> )} </div> ) : ( <button type="button" onClick={handleClick} className="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left" > <span className="truncate">{node.name}</span> </button> )} </div> {hasChildren && expanded && ( <div> {isCreatingHere && ( <InlineCreateInput depth={depth + 1} type={inlineCreate!.type} name={inlineCreate!.name} error={inlineCreate!.error} onChange={onInlineCreateChange!} onSubmit={onInlineCreateSubmit!} onCancel={onInlineCreateCancel!} /> )} {node.children!.map((child) => ( <TreeNode key={child.id} node={child} depth={depth + 1} parentDirId={node.id} selectedId={selectedId} onSelect={onSelect} onContextMenu={onContextMenu} renameNodeId={renameNodeId} renameValue={renameValue} renameExt={renameExt} renameError={renameError} onRenameValueChange={onRenameValueChange} onRenameSubmit={onRenameSubmit} onRenameCancel={onRenameCancel} inlineCreate={inlineCreate} onInlineCreateChange={onInlineCreateChange} onInlineCreateSubmit={onInlineCreateSubmit} onInlineCreateCancel={onInlineCreateCancel} /> ))} </div> )} {node.type === 'folder' && !hasChildren && isCreatingHere && ( <div> <InlineCreateInput depth={depth + 1} type={inlineCreate!.type} name={inlineCreate!.name} error={inlineCreate!.error} onChange={onInlineCreateChange!} onSubmit={onInlineCreateSubmit!} onCancel={onInlineCreateCancel!} /> </div> )} </div> );}
export function FileSystemTree({ nodes, rootId, onSelect, onContextMenu, onMove, selectedId, renameNodeId, renameValue, renameExt, renameError, onRenameValueChange, onRenameSubmit, onRenameCancel, inlineCreate, onInlineCreateChange, onInlineCreateSubmit, onInlineCreateCancel,}: FileSystemTreeProps) { const isCreatingAtRoot = inlineCreate && inlineCreate.parentId === undefined; const [targetDirId, setTargetDirId] = useState<string | null>(null);
// Clear the drop highlight whenever a drag ends anywhere (drop or cancel). useEffect(() => { if (targetDirId === null) return; const clear = () => setTargetDirId(null); window.addEventListener('dragend', clear); return () => window.removeEventListener('dragend', clear); }, [targetDirId]);
// Resolve the directory the cursor is over from the nearest row carrying a // `data-drop-dir` (a folder, or a file's enclosing folder). null over a // root-level file or empty space. const dirUnderCursor = (e: React.DragEvent): string | null => (e.target as HTMLElement) .closest('[data-drop-dir]') ?.getAttribute('data-drop-dir') ?? null;
return ( <TreeDndContext.Provider value={{ targetDirId, setTargetDirId }}> <div // The container is the vault root's drop target: rows below it carry // their own `data-drop-dir`, so `closest` resolves to the deepest // folder, and falls through to the root over empty space / root files. data-drop-dir={rootId} className={`flex h-full flex-col ${ rootId && targetDirId === rootId ? DROP_TARGET_REGION : '' }`} onDragOver={(e) => { if (!onMove || !e.dataTransfer.types.includes(FILE_DND_MIME)) return; const dirId = dirUnderCursor(e); setTargetDirId(dirId); // Only allow a drop (and show the move cursor) over a directory. if (dirId) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} onDrop={(e) => { if (!onMove || !e.dataTransfer.types.includes(FILE_DND_MIME)) return; const dirId = dirUnderCursor(e); setTargetDirId(null); if (!dirId) return; e.preventDefault(); const entryId = e.dataTransfer.getData(FILE_DND_MIME); if (entryId) onMove(entryId, dirId); }} onDragLeave={(e) => { // Clear when the drag leaves the tree entirely (not when moving // between rows inside it). if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setTargetDirId(null); } }} > {isCreatingAtRoot && ( <InlineCreateInput depth={0} type={inlineCreate!.type} name={inlineCreate!.name} error={inlineCreate!.error} onChange={onInlineCreateChange!} onSubmit={onInlineCreateSubmit!} onCancel={onInlineCreateCancel!} /> )} {nodes.map((node) => ( <TreeNode key={node.id} node={node} depth={0} selectedId={selectedId} onSelect={onSelect} onContextMenu={onContextMenu} renameNodeId={renameNodeId} renameValue={renameValue} renameExt={renameExt} renameError={renameError} onRenameValueChange={onRenameValueChange} onRenameSubmit={onRenameSubmit} onRenameCancel={onRenameCancel} inlineCreate={inlineCreate} onInlineCreateChange={onInlineCreateChange} onInlineCreateSubmit={onInlineCreateSubmit} onInlineCreateCancel={onInlineCreateCancel} /> ))} </div> </TreeDndContext.Provider> );}