A filed note stays filed, and the handles are asked for

Three things that all came from chapters being thought through too late.

A note put into a chapter came back out of it a few seconds later. The
server had it right the whole time; every sync rebuilds the local copy
from the server's answer, and the function doing that never carried
chapter_id — so each sync handed back a note belonging to no chapter and
the notebook took it in again. The bin's query left the column out too,
which would have unfiled anything restored from it.

A separator drawn in a notebook vanished when a chapter was opened.
"Which chapter is open" and "which list is on screen" were one value.
Opened beside or below, the list still shows the notebook — but
everything around it had switched to the chapter, so the notebook's own
separators were filtered away and a drag among its rows was measured
against the chapter's notes, found nothing, and did nothing.

And where a new note lands is now visible and yours to choose: pressing
in either place makes it the one being worked in, marked with a line
along its top and the word "here", and the + button names it.

Rearranging becomes a mode, switched on beside the sort menu — until
then no row wears a grip and none can be dragged. Filing a note is back
in the row's own menu, where starring and exporting already are; the
button that had appeared in every row is gone with its dialog. The
account panel closes with Escape.

Thirteen suites green: 335 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 16:59:29 +02:00
1 parent 4bb86bc5ac
commit b2a5f051fe
12 files changed
+406 -291

No files matched your search

+91 -49
View File
@@ -42,9 +42,11 @@ const DropLine = ({ T }) => (
*
* A mouse can drag a row from anywhere, and did. A finger cannot: pressing the
* row has to keep scrolling the list, and holding it has to keep opening the
* menu. So touch gets its own target, wide enough to hit and only there while a
* hand-made arrangement is in effect — which is the only time moving a row means
* anything.
* menu. So touch gets its own target, wide enough to hit.
*
* Only there while rearranging is switched on. Standing in every row all the
* time it was six dots of clutter beside every title, and a row could be pulled
* out of place by a press that was meant to open it.
*/
const Grip = ({ T, ...rest }) => (
<div
@@ -68,36 +70,6 @@ const Grip = ({ T, ...rest }) => (
</div>
);
/**
* Moving a note, in one press.
*
* It used to be a right-click into a submenu — two steps before the list of
* places appeared at all, and on a phone a long press followed by a flyout.
* Sitting on the row beside the grip, it says what it does and takes one tap.
*/
const MoveButton = ({ T, onClick }) => (
<button
data-move-note
onClick={onClick}
title="Move to another notebook or chapter"
aria-label="Move note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
flexShrink: 0,
border: "none",
background: "transparent",
color: T.faint,
cursor: "pointer",
padding: 0,
}}
>
{I.move}
</button>
);
/** Width the separator's label keeps in both states, so renaming moves nothing. */
const MIDDLE_W = 132;
@@ -135,7 +107,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
/* Room above it, so it reads as the end of what came before rather than
as decoration attached to the next note. */
padding: "14px 8px 8px",
cursor: "grab",
cursor: grip ? "grab" : "default",
}}
>
{grip}
@@ -343,7 +315,7 @@ function SortMenu({ T, mode, modes, onChange, disabled }) {
* 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 }) {
function AddMenu({ T, place, onCreateNote, onImportPdf, onImportMarkdown }) {
const [open, setOpen] = useState(false);
const box = useRef(null);
@@ -388,7 +360,8 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
<div style={{ position: "relative" }} ref={box}>
<button
onClick={() => setOpen((v) => !v)}
title="New Note"
// Names the place, so it is clear before pressing where the note lands.
title={place ? `New note in ${place}` : "New Note"}
data-add-menu={open ? "on" : "off"}
style={{
display: "flex",
@@ -430,6 +403,32 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
);
}
/**
* Switches rearranging on.
*
* Rows are dragged rarely and read constantly, so the handles are not there by
* default: the list stays a list, and taking hold of a row is something asked
* for. While it is on, and only then, every row shows its grip and can be
* carried; pressing a row does not open it by accident.
*/
const ReorderToggle = ({ T, on, onToggle }) => (
<button
onClick={onToggle}
data-reorder-toggle={on ? "on" : "off"}
title={on ? "Done rearranging" : "Rearrange the rows"}
aria-label="Rearrange the rows"
aria-pressed={on}
style={{
...iconButton(T),
color: on ? "#fff" : T.muted,
background: on ? T.accent : "transparent",
borderColor: on ? T.accent : T.border,
}}
>
{on ? I.check : I.grip}
</button>
);
/** The middle column: every note in the selected folder, in the chosen order. */
export function NoteList({
T,
@@ -459,8 +458,14 @@ export function NoteList({
onChapterContextMenu,
expandedChapter,
renderChapterNotes,
onMoveNote,
onReorder,
/** Identifies the list on screen, so rearranging does not stay on across a move to another. */
listKey,
/** Whether this pane is the one being worked in; null when there is nothing to tell apart. */
isCurrent,
onFocusList,
/** The place a new note would land in, named on the + button. */
newNotePlace,
onToggleSidebar,
onSelectNote,
onNoteContextMenu,
@@ -469,10 +474,21 @@ export function NoteList({
onImportMarkdown,
onEmptyTrash,
}) {
/* Rearranging is a mode, switched on for as long as it is being done.
*
* It ends by itself when the list is left: coming back to a notebook to find
* every row wearing a handle, because it was rearranged last week, is not
* where anybody left off. */
const [reordering, setReordering] = useState(false);
// Off again on the way to another list, and whenever a hand-made arrangement
// stops being in effect at all — an order the app chose cannot be dragged.
useEffect(() => setReordering(false), [listKey, reorderable]);
const moving = !!reorderable && reordering;
/* Reordering by pointer rather than by the HTML5 drag API, which mobile
browsers never fire from touch — on a phone the arrangement could not be
changed at all, with nothing on screen to say why. */
const dnd = useReorderDrag(!!reorderable, onReorder);
const dnd = useReorderDrag(moving, onReorder);
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
or the trash, which are views over notebooks rather than a place to file
anything, and not inside a chapter, because a chapter holds notes. */
@@ -484,6 +500,7 @@ export function NoteList({
return (
<div
data-list-current={isCurrent === null || isCurrent === undefined ? undefined : isCurrent ? "on" : "off"}
style={{
width: isMobile ? "100%" : 300,
minWidth: isMobile ? 0 : 300,
@@ -493,6 +510,8 @@ export function NoteList({
flexDirection: "column",
background: T.bg,
fontFamily: FONT_UI,
// A line along the top of whichever pane is being worked in.
boxShadow: isCurrent ? `inset 0 2px 0 ${T.accent}` : undefined,
}}
>
<div
@@ -541,12 +560,21 @@ export function NoteList({
>
{openChapter ? openChapter.name : title}
</h2>
{/* Said in words as well as in the line above, because a coloured edge
is only a hint until you know what it means. */}
{isCurrent && (
<span style={{ fontSize: 10, color: T.accent, fontWeight: 700, flexShrink: 0 }}>· here</span>
)}
</div>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{reorderable && (
<ReorderToggle T={T} on={reordering} onToggle={() => setReordering((v) => !v)} />
)}
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
{!isTrashView && (
<AddMenu
T={T}
place={newNotePlace}
onCreateNote={onCreateNote}
onImportPdf={onImportPdf}
onImportMarkdown={onImportMarkdown}
@@ -615,12 +643,20 @@ export function NoteList({
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 6,
padding: "5px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
{/* Only while it is on, and then it says what to do rather than
explaining the order the sort menu already names. */}
{moving && (
<span style={{ flex: 1, minWidth: 0, fontSize: 11, color: T.accent, fontWeight: 600 }}>
Drag the rows into place
</span>
)}
{/* A chapter divides the notebook; a separator divides a list. They
sit together because both are "add something that is not a note",
and apart from the + button, which makes notes. */}
@@ -666,7 +702,16 @@ export function NoteList({
</div>
)}
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
{/* Pressing in the list itself — on a note or on the empty space below
them — says "I am in the notebook", and a new note goes there.
Deliberately not the whole pane: the + button sits in the header, and
taking the focus back on the way to pressing it would mean a note
could never be made in the open chapter at all. */}
<div
data-note-list-body
onPointerDown={onFocusList}
style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}
>
{notes.length === 0 ? (
<div
style={{
@@ -718,14 +763,14 @@ export function NoteList({
? mergeEntries(notes, separators, shownChapters)
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)]
).map((entry) => {
const showDropLine = reorderable && dnd.overId === entry.id;
const showDropLine = moving && 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 ? dnd.rowProps(entry.id) : {};
const rowProps = moving ? dnd.rowProps(entry.id) : {};
const lifted = dnd.draggingId === entry.id;
if (entry.kind === "chapter") {
@@ -733,7 +778,7 @@ export function NoteList({
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{reorderable && <Grip T={T} {...dnd.gripProps} />}
{moving && <Grip T={T} {...dnd.gripProps} />}
<ChapterRow
T={T}
chapter={entry.chapter}
@@ -752,7 +797,7 @@ export function NoteList({
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<SeparatorRow
grip={<Grip T={T} {...dnd.gripProps} />}
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
@@ -770,10 +815,7 @@ export function NoteList({
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{reorderable && <Grip T={T} {...dnd.gripProps} />}
{onMoveNote && !note.trashed && (
<MoveButton T={T} onClick={() => onMoveNote(note)} />
)}
{moving && <Grip T={T} {...dnd.gripProps} />}
<button
data-note-row={note.id}
onClick={() => onSelectNote(note.id)}
@@ -787,7 +829,7 @@ export function NoteList({
borderRadius: 10,
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
background: activeNote === note.id ? T.accentSoft : "transparent",
cursor: reorderable ? "grab" : "pointer",
cursor: moving ? "grab" : "pointer",
marginBottom: 2,
textAlign: "left",
}}
@@ -848,7 +890,7 @@ export function NoteList({
})}
{/* Dropping below the last row moves something to the end, which
otherwise needs an awkward drop onto the very last note. */}
{reorderable && (
{moving && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 56 }}>
{dnd.overId === END_ID && <DropLine T={T} />}
</div>