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 (
);
}
/** 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 (
{/* 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 && (
)}
{/* 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 (
);
})}
{/* Dropping below the last row moves something to the end, which
otherwise needs an awkward drop onto the very last note. */}
{reorderable && (