Chapters as rows, in two views, in their own colours
Three things Marc asked for, and one of them turned out to be the hinge
the other two hang from.
**A chapter is a row like any other.** Same grip, same drag, same place
in a hand-made arrangement as a note or a separator. That needed notes
and chapters numbered from *one* counter: numbered separately they are
two sequences that cannot be interleaved again, and a chapter dragged
between two notes springs back to the top. Positions therefore have to be
named rather than read off an array index, so POST /notes/reorder now
takes `positions: [{id, order}]` beside the older `ids`, and chapters
have a reorder route of their own.
**Two ways for a chapter to open**, chosen per account beside the
typeface, for the same reason that one lives there: it is a choice about
how your own notes are laid out and it should follow you to the phone.
Beside the list, its notes in a further column the way OneNote lays out
pages — the notebook's own list carrying on to the left, chapter rows and
all. Or unfolded under the chapter's own row, indented against a rule in
its colour. A phone has room for neither and fills the list instead, with
a way back, which is what it did before there was a choice.
**Its own colour**, from eight named ones rather than the full palette:
the point of chapters is that they are different places, and twenty
near-identical blues do not help anyone tell them apart.
The colour is validated on the way in — it is written by a browser, so
only a plain #rrggbb gets through and anything else becomes no colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6cad632a0c
commit
d74fea4905
12 files changed
+640
-88
No files matched your search
@@ -19,8 +19,9 @@ const iconButton = (T) => ({
|
||||
color: T.muted,
|
||||
});
|
||||
|
||||
/** A note rendered outside the manual order, wrapped to look like an entry. */
|
||||
/** A row rendered outside the manual order, wrapped to look like an entry. */
|
||||
const asNoteEntry = (note) => ({ kind: "note", id: note.id, note });
|
||||
const asChapterEntry = (chapter) => ({ kind: "chapter", id: chapter.id, chapter });
|
||||
|
||||
/**
|
||||
* Where the dragged row will land.
|
||||
@@ -211,7 +212,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
* place, and the notes are what is in this one. The count is what makes it worth
|
||||
* opening — a row saying only "Invoices" tells you nothing about whether to.
|
||||
*/
|
||||
function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
|
||||
function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
|
||||
return (
|
||||
<button
|
||||
data-chapter-row={chapter.id}
|
||||
@@ -221,17 +222,20 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.borderLight}`,
|
||||
background: "transparent",
|
||||
border: `1px solid ${open ? T.accent + "55" : T.borderLight}`,
|
||||
background: open ? T.accentSoft : "transparent",
|
||||
cursor: "pointer",
|
||||
marginBottom: 4,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: T.accent, flexShrink: 0 }}>{I.books}</span>
|
||||
{/* Its own colour, so a shelf of chapters can be told apart at a glance
|
||||
— the point of having them is that they are different places. */}
|
||||
<span style={{ display: "flex", color: chapter.color || T.accent, flexShrink: 0 }}>{I.books}</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -247,7 +251,7 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
|
||||
{chapter.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{chapter.noteCount}</span>
|
||||
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>›</span>
|
||||
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{open ? "▾" : "›"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -453,6 +457,8 @@ export function NoteList({
|
||||
onOpenChapter,
|
||||
onAddChapter,
|
||||
onChapterContextMenu,
|
||||
expandedChapter,
|
||||
renderChapterNotes,
|
||||
onMoveNote,
|
||||
onReorder,
|
||||
onToggleSidebar,
|
||||
@@ -471,6 +477,9 @@ export function NoteList({
|
||||
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. */
|
||||
const canAddChapter = !!onAddChapter && !openChapter;
|
||||
/* `openChapter` means this list *is* the chapter — the fallback where there is
|
||||
no room beside or below, so the chapter fills the list and the header offers
|
||||
a way back. In the other two views the chapter rows stay where they are. */
|
||||
const shownChapters = openChapter ? [] : chapters || [];
|
||||
|
||||
return (
|
||||
@@ -705,19 +714,10 @@ export function NoteList({
|
||||
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. */}
|
||||
{shownChapters.map((chapter) => (
|
||||
<ChapterRow
|
||||
key={chapter.id}
|
||||
T={T}
|
||||
chapter={chapter}
|
||||
onOpen={onOpenChapter}
|
||||
onContextMenu={onChapterContextMenu}
|
||||
/>
|
||||
))}
|
||||
{shownChapters.length > 0 && notes.length > 0 && (
|
||||
<div style={{ height: 1, background: T.borderLight, margin: "8px 4px 10px" }} />
|
||||
)}
|
||||
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
|
||||
{(reorderable
|
||||
? mergeEntries(notes, separators, shownChapters)
|
||||
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)]
|
||||
).map((entry) => {
|
||||
const showDropLine = reorderable && dnd.overId === entry.id;
|
||||
/* The whole row, indicator included, is the drop target.
|
||||
*
|
||||
@@ -728,6 +728,25 @@ export function NoteList({
|
||||
const rowProps = reorderable ? dnd.rowProps(entry.id) : {};
|
||||
const lifted = dnd.draggingId === entry.id;
|
||||
|
||||
if (entry.kind === "chapter") {
|
||||
return (
|
||||
<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} />}
|
||||
<ChapterRow
|
||||
T={T}
|
||||
chapter={entry.chapter}
|
||||
open={expandedChapter === entry.id}
|
||||
onOpen={onOpenChapter}
|
||||
onContextMenu={onChapterContextMenu}
|
||||
/>
|
||||
</div>
|
||||
{expandedChapter === entry.id && renderChapterNotes?.(entry.chapter)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "separator") {
|
||||
return (
|
||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* scramble everything that was not being looked at.
|
||||
*/
|
||||
|
||||
import type { NoteMeta, Separator, SortMode } from "../../types";
|
||||
import type { Chapter, NoteMeta, Separator, SortMode } from "../../types";
|
||||
|
||||
export const DEFAULT_SORT: SortMode = "updated";
|
||||
|
||||
@@ -86,6 +86,7 @@ export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): Not
|
||||
*/
|
||||
export type Entry =
|
||||
| { kind: "note"; id: string; order: number; note: NoteMeta }
|
||||
| { kind: "chapter"; id: string; order: number; chapter: Chapter }
|
||||
| { kind: "separator"; id: string; order: number; separator: Separator };
|
||||
|
||||
/**
|
||||
@@ -94,51 +95,90 @@ export type Entry =
|
||||
* 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,
|
||||
}));
|
||||
export function mergeEntries(
|
||||
notes: NoteMeta[],
|
||||
separators: Separator[] = [],
|
||||
chapters: Chapter[] = []
|
||||
): Entry[] {
|
||||
/* Notes and chapters share one sequence.
|
||||
*
|
||||
* A chapter is a row in the list like a note is, and can be dragged among
|
||||
* them, so they cannot each be numbered from zero — they are ranked together
|
||||
* by the position each carries.
|
||||
*
|
||||
* Ranked, not placed by the stored number: rows that have never been dragged
|
||||
* all carry 0, and using the raw numbers would pile them at position 0 and
|
||||
* push every separator behind the lot. */
|
||||
const ranked = sortNotes(notes, "manual");
|
||||
const rows = [
|
||||
...chapters.map((chapter, i) => ({
|
||||
kind: "chapter" as const,
|
||||
id: chapter.id,
|
||||
chapter,
|
||||
sort: chapter.sortOrder ?? 0,
|
||||
// Keeps the order within one kind settled when several share a position,
|
||||
// which every row does until it has been dragged once.
|
||||
tie: i,
|
||||
})),
|
||||
...ranked.map((note, i) => ({ kind: "note" as const, id: note.id, note, sort: note.sortOrder ?? 0, tie: i })),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
a.sort - b.sort ||
|
||||
// A chapter shares a position with a note only before either has been
|
||||
// moved; putting chapters first there matches where they are created.
|
||||
(a.kind === b.kind ? a.tie - b.tie : a.kind === "chapter" ? -1 : 1)
|
||||
);
|
||||
|
||||
const rowEntries: Entry[] = rows.map((row, rank) =>
|
||||
row.kind === "chapter"
|
||||
? { kind: "chapter", id: row.id, order: rank, chapter: row.chapter }
|
||||
: { kind: "note", id: row.id, order: rank, note: row.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)),
|
||||
order: Math.max(0, Math.min(separator.order, rowEntries.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.
|
||||
return [...rowEntries, ...separatorEntries].sort(
|
||||
// A separator sitting at the same position as a row comes first, so
|
||||
// "a line above this one" 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. */
|
||||
/** The result of a drag: what to write back for each kind of row. */
|
||||
export interface Arrangement {
|
||||
noteIds: string[];
|
||||
/** Notes in their new order, with the position each should carry. */
|
||||
notes: Array<{ id: string; order: number }>;
|
||||
/** Chapters likewise — numbered from the *same* counter as the notes. */
|
||||
chapters: Array<{ id: string; order: number }>;
|
||||
separators: Separator[];
|
||||
/** The note ids alone, for callers that only reorder notes. */
|
||||
noteIds: string[];
|
||||
}
|
||||
|
||||
/** Turns an ordered entry list back into storable positions. */
|
||||
export function toArrangement(entries: Entry[]): Arrangement {
|
||||
const noteIds: string[] = [];
|
||||
const notes: Array<{ id: string; order: number }> = [];
|
||||
const chapters: Array<{ id: string; order: number }> = [];
|
||||
const separators: Separator[] = [];
|
||||
/* One counter for notes and chapters together. Numbering them separately
|
||||
* would leave two sequences that cannot be interleaved again, and a chapter
|
||||
* dragged between two notes would spring back. */
|
||||
let position = 0;
|
||||
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 });
|
||||
if (entry.kind === "note") notes.push({ id: entry.id, order: position++ });
|
||||
else if (entry.kind === "chapter") chapters.push({ id: entry.id, order: position++ });
|
||||
// A separator's position is "how many rows come before me". Expressed that
|
||||
// way it stays put as the rows around it are renamed, edited or opened.
|
||||
else separators.push({ ...entry.separator, order: position });
|
||||
}
|
||||
return { noteIds, separators };
|
||||
return { notes, chapters, separators, noteIds: notes.map((n) => n.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,10 +191,11 @@ export function reindexEntries(
|
||||
notes: NoteMeta[],
|
||||
separators: Separator[],
|
||||
dragId: string,
|
||||
targetId: string
|
||||
targetId: string,
|
||||
chapters: Chapter[] = []
|
||||
): Arrangement | null {
|
||||
if (dragId === targetId) return null;
|
||||
const entries = mergeEntries(notes, separators);
|
||||
const entries = mergeEntries(notes, separators, chapters);
|
||||
const from = entries.findIndex((e) => e.id === dragId);
|
||||
if (from < 0) return null;
|
||||
|
||||
|
||||
Reference in new issue
Block a user