Fix a permanent black line in the note list, and add separators

The drop indicator was a `borderTop` set in the same style object as a `border`
shorthand. Clearing the longhand resets its width and colour to the defaults
while the shorthand's `solid` stays, so a row kept a 2px black line for good —
measured at rest, before any drag. It is now its own element, which also stops
the list twitching as a row grows and shrinks by a pixel.

That element then turned out to be a dead strip of its own: 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. The drag handlers moved to the whole
row and the indicator no longer takes pointer events. A strip below the last row
means "put it at the end", which otherwise needs a drop onto the final note.

Separators are lines drawn between notes, optionally named, stored on the
notebook beside its sort mode. They drag like notes do, and their position is
"how many notes come before me", so they hold their place as the notes around
them are renamed or edited. Shown only in the manual order — in "newest first"
the notes would slide past the line on every edit.

"All Notes" and "Starred" no longer offer a hand-made order: they are questions
asked of every notebook, so there is nothing for an arrangement to belong to.

199 unit tests, 190 browser checks across eleven suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-10 01:45:38 +02:00
1 parent 0a0864c58b
commit 2cee00e38e
10 files changed
+731 -125

No files matched your search

+123 -14
View File
@@ -12,7 +12,7 @@
* scramble everything that was not being looked at.
*/
import type { NoteMeta, SortMode } from "../../types";
import type { NoteMeta, Separator, SortMode } from "../../types";
export const DEFAULT_SORT: SortMode = "updated";
@@ -26,6 +26,17 @@ export const SORT_LABELS: Record<SortMode, string> = {
export const SORT_MODES = Object.keys(SORT_LABELS) as SortMode[];
/**
* The orders a view can be put in.
*
* "All Notes" and "Starred" are not places, they are questions asked of every
* notebook at once — so there is nothing for a hand-made arrangement to belong
* to, and nowhere to keep it. Offering it there would be offering a setting that
* cannot be honoured.
*/
export const sortModesFor = (isNotebook: boolean): SortMode[] =>
isNotebook ? SORT_MODES : SORT_MODES.filter((m) => m !== "manual");
const time = (value: string | undefined) => {
const t = Date.parse(value ?? "");
return Number.isNaN(t) ? 0 : t;
@@ -59,24 +70,122 @@ export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): Not
return [...notes].sort(compare[mode] ?? compare[DEFAULT_SORT]);
}
/* ── separators ── */
/**
* A line drawn between notes to group them.
*
* Separators live in the same order as the notes and are dragged the same way,
* because a divider that cannot be moved with what it divides is worse than
* none. They belong to the notebook rather than to any note: they are part of
* the arrangement, and the arrangement is the notebook's.
*
* They exist only in the manual order. In "newest first" there is nothing for a
* line to mean — the notes would slide past it on every edit — so the other
* orders simply do not show them, and they are still there on the way back.
*/
export type Entry =
| { kind: "note"; id: string; order: number; note: NoteMeta }
| { kind: "separator"; id: string; order: number; separator: Separator };
/**
* Notes and separators, interleaved into the one order the user arranged.
*
* A separator sitting at the same position as a note comes first, so "insert a
* line above this note" lands where it was asked for rather than one row late.
*/
export function mergeEntries(notes: NoteMeta[], separators: Separator[] = []): Entry[] {
/* Notes are placed by their rank, not by their stored number. Notes that have
* never been dragged all carry 0, and sorting has already broken that tie —
* using the stored numbers here would pile them all at position 0 and push
* every separator behind the lot. */
const noteEntries: Entry[] = sortNotes(notes, "manual").map((note, rank) => ({
kind: "note" as const,
id: note.id,
order: rank,
note,
}));
const separatorEntries: Entry[] = separators.map((separator) => ({
kind: "separator" as const,
id: separator.id,
// Clamped, so a separator left over from a notebook that has since shrunk
// still appears — at the end — rather than vanishing with its label.
order: Math.max(0, Math.min(separator.order, noteEntries.length)),
separator,
}));
return [...noteEntries, ...separatorEntries].sort(
// A separator sitting at the same position as a note comes first, so
// "a line above this note" lands where it was asked for.
(a, b) => a.order - b.order || (a.kind === "separator" ? -1 : 1)
);
}
/** The result of a drag: what to write back for notes and for separators. */
export interface Arrangement {
noteIds: string[];
separators: Separator[];
}
/** Turns an ordered entry list back into storable positions. */
export function toArrangement(entries: Entry[]): Arrangement {
const noteIds: string[] = [];
const separators: Separator[] = [];
for (const entry of entries) {
if (entry.kind === "note") noteIds.push(entry.id);
// A separator's position is "how many notes come before me". Expressed that
// way it stays put as the notes around it are renamed, edited or opened.
else separators.push({ ...entry.separator, order: noteIds.length });
}
return { noteIds, separators };
}
/**
* The whole notebook rearranged after something was dragged onto something else.
*
* `notes` must be every note in the notebook, not the filtered view. Returns
* null when the move changes nothing, which spares the caller a pointless write.
*/
export function reindexEntries(
notes: NoteMeta[],
separators: Separator[],
dragId: string,
targetId: string
): Arrangement | null {
if (dragId === targetId) return null;
const entries = mergeEntries(notes, separators);
const from = entries.findIndex((e) => e.id === dragId);
if (from < 0) return null;
// The strip below the last row means "put it at the end" — otherwise moving
// something past the final note needs a drop onto the note itself, which puts
// it above.
const to = targetId === END_ID ? entries.length - 1 : entries.findIndex((e) => e.id === targetId);
if (to < 0) return null;
const [moved] = entries.splice(from, 1);
if (!moved) return null;
entries.splice(to, 0, moved);
return toArrangement(entries);
}
/** Drop target standing for "after everything". */
export const END_ID = "__end__";
/** A new separator, added at the top where it can be seen and then dragged. */
export const withNewSeparator = (separators: Separator[], id: string, label = ""): Separator[] => [
{ id, label, order: 0 },
...separators,
];
/**
* The full notebook renumbered after a note was dragged onto another.
*
* `all` must be every note in the notebook, not the filtered view. Returns the
* ids in their new order, or null when the move changes nothing — which spares
* the caller a pointless write.
* Kept for the case with no separators; `reindexEntries` is the general one.
*/
export function reindex(all: NoteMeta[], dragId: string, targetId: string): string[] | null {
if (dragId === targetId) return null;
const ordered = sortNotes(all, "manual");
const from = ordered.findIndex((n) => n.id === dragId);
const to = ordered.findIndex((n) => n.id === targetId);
if (from < 0 || to < 0) return null;
const [moved] = ordered.splice(from, 1);
if (!moved) return null;
ordered.splice(to, 0, moved);
return ordered.map((n) => n.id);
return reindexEntries(all, [], dragId, targetId)?.noteIds ?? null;
}
/**