import { useEffect, useRef, useState } from "react"; import { I } from "../../ui/icons.jsx"; import { fmtDate } from "../../ui/format"; import { FONT_SERIF, FONT_UI } from "../../ui/theme"; import { END_ID, SORT_LABELS, mergeEntries } from "./noteSort"; const iconButton = (T) => ({ display: "flex", alignItems: "center", justifyContent: "center", width: 32, height: 32, borderRadius: 10, border: `1px solid ${T.border}`, background: "transparent", cursor: "pointer", color: T.muted, }); /** A note rendered outside the manual order, wrapped to look like an entry. */ const asNoteEntry = (note) => ({ kind: "note", id: note.id, note }); /** * Where the dragged row will land. * * A real element rather than a border on the row above. A border that appears * and disappears changes that row's height, so the list twitched under the * cursor — and setting `borderTop` in the same style object as a `border` * shorthand left a permanent 2px black line behind once it was cleared, because * clearing the longhand resets its width and colour to the defaults while the * shorthand's `solid` stays. */ const DropLine = ({ T }) => (
); /** Width the separator's label keeps in both states, so renaming moves nothing. */ const MIDDLE_W = 132; /** * A line drawn between notes, with an optional name. * * Draggable like a note, because a divider that cannot move with what it * divides is worse than none. * * Both renaming and removing are right-click, which is how everything else in * this list is acted on. A left-click on the label started renaming, so the row * could not be clicked — to select it, to drag from it — without falling into an * edit; and the × for removal sat a few pixels from that same target. */ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu }) { const [draft, setDraft] = useState(separator.label); useEffect(() => { if (editing) setDraft(separator.label); }, [editing, separator.label]); const commit = () => { onStartRename(null); if (draft !== separator.label) onRename(separator.id, draft); }; return (
onContextMenu(e, separator)} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 8px 6px", cursor: "grab", }} > {/* The two rules always share what is left over equally, and the middle keeps one width whether it is showing a label or an input. Sizing it to its contents made the whole row jump sideways the moment renaming started, which is exactly when the eye is on it. */}
{editing ? ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setDraft(separator.label); onStartRename(null); } }} aria-label="Separator label" placeholder="Section" style={{ width: "100%", minWidth: 0, border: `1px solid ${T.border}`, borderRadius: 6, background: "transparent", color: T.textSec, fontSize: 10.5, fontWeight: 700, letterSpacing: 0.6, textAlign: "center", // Matches the button's box exactly, so nothing shifts vertically // either when the input replaces it. padding: "1px 4px", outline: "none", }} /> ) : (
{separator.label || "Unnamed"}
)}
); } /** * How this notebook's notes are ordered. * * The chosen order is stored with the notebook, so it is the same on every * device and still there next week — a reference book stays alphabetical, a * journal stays newest-first, without either being re-chosen on each visit. */ function SortMenu({ T, mode, modes, onChange, disabled }) { const [open, setOpen] = useState(false); const box = useRef(null); useEffect(() => { if (!open) return; const close = (e) => { if (!box.current?.contains(e.target)) setOpen(false); }; document.addEventListener("pointerdown", close); return () => document.removeEventListener("pointerdown", close); }, [open]); return (
{open && (
{modes.map((m) => ( ))}
)}
); } /** * One way into the notebook. * * Making a note, importing a PDF and importing Markdown are the same act — * putting something new in here — and they had grown into three buttons side by * side, two of which were icons you had to hover to identify. */ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) { const [open, setOpen] = useState(false); const box = useRef(null); useEffect(() => { if (!open) return; const close = (e) => { if (!box.current?.contains(e.target)) setOpen(false); }; document.addEventListener("pointerdown", close); return () => document.removeEventListener("pointerdown", close); }, [open]); const item = (label, icon, action) => ( ); return (
{open && (
{item("New note", I.plus, onCreateNote)} {item("Upload PDF", I.pdf, onImportPdf)} {item("Import Markdown", I.import, onImportMarkdown)}
)}
); } /** The middle column: every note in the selected folder, in the chosen order. */ export function NoteList({ T, isMobile, title, notes, activeNote, isTrashView, trashedCount, snippetFor, sortMode, sortModes, onSortModeChange, query, onQueryChange, reorderable, separators, onAddSeparator, renamingSeparator, onStartRenameSeparator, onRenameSeparator, onSeparatorContextMenu, dnd, onToggleSidebar, onSelectNote, onNoteContextMenu, onCreateNote, onImportPdf, onImportMarkdown, onEmptyTrash, }) { // Opened by the button and kept open while there is something typed in it, so // a search never disappears because the box collapsed under it. const [searching, setSearching] = useState(false); return (

{title}

{!isTrashView && } {!isTrashView && ( )} {!isTrashView && ( )} {isTrashView && trashedCount > 0 && ( )}
{/* The search box lives with the list it narrows, not in the sidebar. Searching has always been scoped to the notebook you are in; from the sidebar it read as searching everything, which it never did. */} {(searching || query) && !isTrashView && (
onQueryChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { onQueryChange(""); setSearching(false); } }} placeholder={`Search in ${title}...`} aria-label="Search in this notebook" style={{ width: "100%", padding: "7px 10px", borderRadius: 9, border: `1px solid ${T.border}`, background: T.inputBg, color: T.text, fontSize: 12.5, outline: "none", }} />
)} {/* Just the one control. The sentence that used to sit here explained the order the sort menu already names, on every visit, forever. */} {reorderable && (
)}
{notes.length === 0 ? (
{isTrashView ? I.trash : I.note}
{isTrashView ? "Trash is empty" : "No notes yet"}
{!isTrashView && ( )}
) : ( <> {/* Separators are part of the manual arrangement and only mean something there: in "newest first" the notes would slide past a line on every edit. They are not shown in the other orders, and are still in place on the way back. */} {(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => { const showDropLine = reorderable && dnd?.overId === entry.id; /* The whole row, indicator included, is the drop target. * * Putting these on the inner element left the indicator itself as * a dead strip: it pushes the row down as it appears, so the * pointer ended up over the line rather than over the row, and * releasing there dropped on nothing. */ const rowProps = reorderable ? { draggable: true, onDragStart: (e) => dnd.onDragStart(e, entry.id), onDragOver: (e) => dnd.onDragOver(e, entry.id), onDrop: (e) => dnd.onDrop(e, entry.id), onDragEnd: dnd.onDragEnd, } : {}; if (entry.kind === "separator") { return (
{showDropLine && }
); } const note = entry.note; const snippet = snippetFor(note.id); return (
{showDropLine && }
); })} {/* Dropping below the last row moves something to the end, which otherwise needs an awkward drop onto the very last note. */} {reorderable && (
dnd.onDragOver(e, END_ID)} onDrop={(e) => dnd.onDrop(e, END_ID)} style={{ minHeight: 40 }} > {dnd?.overId === END_ID && }
)} )}
); }