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
+203
-4
@@ -8,7 +8,7 @@ import { usePwaInstall } from "./hooks/usePwaInstall";
|
||||
import { useNotebookDnd } from "./hooks/useNotebookDnd";
|
||||
import { useNavigationHistory } from "./hooks/useNavigationHistory";
|
||||
|
||||
import { DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
|
||||
import { CHAPTER_COLORS, DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
|
||||
import { I } from "./ui/icons.jsx";
|
||||
import { makeTheme, FONT_UI } from "./ui/theme";
|
||||
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
|
||||
@@ -103,6 +103,12 @@ export default function App() {
|
||||
|
||||
// Presentation only, and it lives on the account so it follows to the phone.
|
||||
const noteFont = user?.preferences?.noteFont || "serif";
|
||||
/* How a chapter opens: a further column beside the list, or unfolded under
|
||||
its own row. A phone has room for neither, so there it fills the screen —
|
||||
which is what "column" already did before there was a choice. */
|
||||
const chapterView = user?.preferences?.chapterView || "column";
|
||||
const inlineChapters = chapterView === "inline" && !isMobile;
|
||||
const columnChapters = chapterView === "column" && !isMobile;
|
||||
|
||||
const data = useNoteData(user, activeNote);
|
||||
const { notes, notebooks, noteContents, backlinks } = data;
|
||||
@@ -287,7 +293,8 @@ export default function App() {
|
||||
if (activeFolder === "trash") list = trashedNotes;
|
||||
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
|
||||
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
|
||||
else if (activeChapter) list = activeNotes.filter((n) => n.chapterId === activeChapter);
|
||||
else if (activeChapter && !inlineChapters && !columnChapters)
|
||||
list = activeNotes.filter((n) => n.chapterId === activeChapter);
|
||||
/* In the notebook itself, a note filed into a chapter is *in* that chapter
|
||||
and not loose here — otherwise it would appear twice and moving it would
|
||||
look like it had done nothing. */
|
||||
@@ -305,7 +312,24 @@ export default function App() {
|
||||
return list.filter((n) => n.title.toLowerCase().includes(q));
|
||||
}
|
||||
return sortNotes(list, sortMode);
|
||||
}, [activeFolder, activeChapter, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
|
||||
}, [
|
||||
activeFolder,
|
||||
activeChapter,
|
||||
inlineChapters,
|
||||
columnChapters,
|
||||
searchQuery,
|
||||
search.results,
|
||||
activeNotes,
|
||||
trashedNotes,
|
||||
sortMode,
|
||||
]);
|
||||
|
||||
/* The notes of the chapter being looked into, in the same order they would
|
||||
have in a list of their own. */
|
||||
const chapterNotes = useMemo(
|
||||
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []),
|
||||
[activeChapter, activeNotes, sortMode]
|
||||
);
|
||||
|
||||
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
|
||||
// The editor opens as soon as the metadata is known; the body fills in when
|
||||
@@ -603,6 +627,18 @@ export default function App() {
|
||||
onSave: (name) => data.renameChapter(chapter.id, name),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Colour",
|
||||
icon: I.edit,
|
||||
submenu: [
|
||||
{ label: "None", colorDot: T.faint, action: () => data.recolourChapter(chapter.id, null) },
|
||||
...CHAPTER_COLORS.map((c) => ({
|
||||
label: c.label,
|
||||
colorDot: c.value,
|
||||
action: () => data.recolourChapter(chapter.id, c.value),
|
||||
})),
|
||||
],
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Remove chapter",
|
||||
@@ -680,6 +716,58 @@ export default function App() {
|
||||
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
|
||||
|
||||
// On a phone the list and the editor take turns; on a desktop both are shown.
|
||||
/* A chapter's notes, unfolded under its own row. Indented and against a rule,
|
||||
so it reads as "inside this" rather than as more of the same list. */
|
||||
const renderChapterNotes = (chapter) => (
|
||||
<div
|
||||
data-chapter-notes={chapter.id}
|
||||
style={{ margin: "2px 0 8px 22px", borderLeft: `2px solid ${chapter.color || T.accent}55`, paddingLeft: 8 }}
|
||||
>
|
||||
{chapterNotes.length === 0 && (
|
||||
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
|
||||
)}
|
||||
{chapterNotes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
data-chapter-note={n.id}
|
||||
onClick={() => openNote(n.id)}
|
||||
onContextMenu={(e) => noteContextMenu(e, n)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
|
||||
background: activeNote === n.id ? T.accentSoft : "transparent",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
|
||||
{n.type === "pdf" ? I.pdf : I.note}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{n.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const showList = !(isMobile && mobileView === "editor" && activeNote);
|
||||
const showEditor = !isMobile || mobileView === "editor";
|
||||
|
||||
@@ -775,10 +863,15 @@ export default function App() {
|
||||
onRenameSeparator={renameSeparator}
|
||||
onSeparatorContextMenu={separatorContextMenu}
|
||||
chapters={folderChapters}
|
||||
openChapter={openChapter}
|
||||
/* Only in the fallback: there the list becomes the chapter, and
|
||||
its header is the way back. Beside or below, the notebook's own
|
||||
list carries on and keeps its chapter rows. */
|
||||
openChapter={inlineChapters || columnChapters ? null : openChapter}
|
||||
onOpenChapter={setActiveChapter}
|
||||
onAddChapter={activeNotebook ? addChapter : null}
|
||||
onChapterContextMenu={chapterContextMenu}
|
||||
expandedChapter={inlineChapters ? activeChapter : null}
|
||||
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
|
||||
onMoveNote={setMovingNote}
|
||||
onReorder={rearrangeNotes}
|
||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||
@@ -790,6 +883,112 @@ export default function App() {
|
||||
onEmptyTrash={emptyTrash}
|
||||
/>
|
||||
)}
|
||||
{/* The chapter's own column, beside the list — the way OneNote lays
|
||||
out its pages. Only where there is room: on a phone the chapter
|
||||
fills the screen instead, with a way back. */}
|
||||
{columnChapters && openChapter && (
|
||||
<div
|
||||
data-chapter-column
|
||||
style={{
|
||||
width: 268,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRight: `1px solid ${T.borderLight}`,
|
||||
background: T.panel || "transparent",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "14px 14px 10px",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: openChapter.color || T.accent, flexShrink: 0 }}>
|
||||
{I.books}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{openChapter.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setActiveChapter(null)}
|
||||
title="Close chapter"
|
||||
aria-label="Close chapter"
|
||||
data-chapter-close
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: T.muted,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{I.x}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
|
||||
{chapterNotes.length === 0 && (
|
||||
<div style={{ padding: "12px 10px", fontSize: 12, color: T.muted }}>
|
||||
Nothing in here yet.
|
||||
</div>
|
||||
)}
|
||||
{chapterNotes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
data-chapter-note={n.id}
|
||||
onClick={() => openNote(n.id)}
|
||||
onContextMenu={(e) => noteContextMenu(e, n)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
width: "100%",
|
||||
padding: "9px 10px",
|
||||
borderRadius: 9,
|
||||
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
|
||||
background: activeNote === n.id ? T.accentSoft : "transparent",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
|
||||
{n.type === "pdf" ? I.pdf : I.note}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{n.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showEditor && (
|
||||
<EditorPane
|
||||
T={T}
|
||||
|
||||
Reference in new issue
Block a user