A notebook opens in its own order
The hand-made order is where a notebook's own arrangement lives: separators show, rows can be put where they belong, nothing slides about by itself. Starting in "last changed" made both of those look broken until the sort menu was found — pressing "+ Separator" there drew a line the order cannot display, so the button appeared dead. A notebook nobody has arranged still reads newest-first, because untouched rows all carry position 0, so nothing is lost by starting there. The views keep "last changed": they are questions asked of every notebook at once and have nowhere to keep an arrangement. The button that draws a line is now only offered in the order that can show one. Removing a chapter left you stuck in the confirmation. Every destructive action goes through one helper that takes the question down again; this one set the dialog directly and bypassed it, so the chapter was removed and the prompt stayed over the app. Closing now happens where the dialog is rendered, so it happens whoever asked and however they asked. The suites did not catch that, and the reason is worth keeping: they read the list straight through the overlay, which is in the DOM the whole time, and passed while the screen was in fact stuck. Both now assert the question is gone afterwards. The separator is no wider than its name. It sat in a fixed 132px slot — chosen so the row would not jump when renaming began — with a short name floating in a gap it did not fill. Thirteen suites green: 345 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc0b58dd2f
commit
c96885d5b3
9 files changed
+223
-69
No files matched your search
+51
-40
@@ -25,6 +25,7 @@ import { Sidebar } from "./ui/Sidebar.jsx";
|
||||
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
|
||||
import { NoteList } from "./features/notes/NoteList.jsx";
|
||||
import {
|
||||
DEFAULT_NOTEBOOK_SORT,
|
||||
DEFAULT_SORT,
|
||||
canReorder,
|
||||
sortModesFor,
|
||||
@@ -95,8 +96,9 @@ export default function App() {
|
||||
const [renamingSeparator, setRenamingSeparator] = useState(null);
|
||||
const [historyFor, setHistoryFor] = useState(null);
|
||||
const [ctxMenu, setCtxMenu] = useState(null);
|
||||
// { title, value, onSave } while a chapter is being named or renamed.
|
||||
const [namingChapter, setNamingChapter] = useState(null);
|
||||
/* The one dialog that asks for a name — a chapter's, with its colour, or a
|
||||
separator's. Held here because both are started from menus that live here. */
|
||||
const [naming, setNaming] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||
// Bumped on a version restore to force a fresh editor on the restored body.
|
||||
const [restoreEpoch, setRestoreEpoch] = useState(0);
|
||||
@@ -246,7 +248,7 @@ export default function App() {
|
||||
/* A real notebook remembers its order on the server; a view keeps one for the
|
||||
session. Either way the note list is told a single mode. */
|
||||
const rawSortMode = activeNotebook
|
||||
? activeNotebook.sortMode || DEFAULT_SORT
|
||||
? activeNotebook.sortMode || DEFAULT_NOTEBOOK_SORT
|
||||
: virtualSort[activeFolder] || DEFAULT_SORT;
|
||||
// A view has nowhere to keep a hand-made arrangement, so it cannot be in one —
|
||||
// even if an older version of the app left "manual" behind on a notebook that
|
||||
@@ -278,6 +280,9 @@ export default function App() {
|
||||
const writeVisible = (visible) =>
|
||||
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== listChapter), ...visible]);
|
||||
|
||||
/* One press draws the line; it is named afterwards from its own menu, like it
|
||||
is renamed. A dialog in front of it asked a question about something that
|
||||
reads perfectly well unnamed. */
|
||||
const addSeparator = () =>
|
||||
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", listChapter));
|
||||
const renameSeparator = (id, label) =>
|
||||
@@ -496,15 +501,16 @@ export default function App() {
|
||||
openNote(note.id);
|
||||
});
|
||||
|
||||
/** Runs a destructive action behind a confirmation prompt. */
|
||||
const confirm = (dialog) =>
|
||||
setConfirmDialog({
|
||||
...dialog,
|
||||
onConfirm: async () => {
|
||||
await dialog.onConfirm();
|
||||
setConfirmDialog(null);
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Runs a destructive action behind a confirmation prompt.
|
||||
*
|
||||
* Only sets the question. Closing it again is done where the dialog is
|
||||
* rendered, so it happens whoever asked and however they asked: this used to
|
||||
* be wrapped here instead, and the one caller that set the dialog directly —
|
||||
* removing a chapter — did its work and then left the prompt standing over
|
||||
* the app with nothing but a reload to get out of.
|
||||
*/
|
||||
const confirm = (dialog) => setConfirmDialog(dialog);
|
||||
|
||||
const trashNote = (id) =>
|
||||
confirm({
|
||||
@@ -645,9 +651,11 @@ export default function App() {
|
||||
rather than a second visit through the right-click menu. */
|
||||
const addChapter = () => {
|
||||
if (!activeNotebook) return;
|
||||
setNamingChapter({
|
||||
setNaming({
|
||||
title: "New chapter",
|
||||
label: "Chapter name",
|
||||
value: "",
|
||||
colors: CHAPTER_COLORS,
|
||||
color: null,
|
||||
onSave: (name, color) => data.createChapter(activeNotebook.id, name, color),
|
||||
});
|
||||
@@ -660,45 +668,42 @@ export default function App() {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
items: [
|
||||
/* One entry, not two. The name and the colour are asked for together
|
||||
when a chapter is made, so changing them is the same dialog again —
|
||||
a second "Colour" submenu beside it offered half of what this does
|
||||
and read as a different thing. */
|
||||
{
|
||||
label: "Rename or recolour",
|
||||
icon: I.edit,
|
||||
action: () =>
|
||||
setNamingChapter({
|
||||
setNaming({
|
||||
title: "Chapter",
|
||||
label: "Chapter name",
|
||||
value: chapter.name,
|
||||
colors: CHAPTER_COLORS,
|
||||
color: chapter.color ?? null,
|
||||
onSave: (name, color) => data.editChapter(chapter.id, { name, color }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Colour",
|
||||
icon: I.edit,
|
||||
submenu: [
|
||||
{ label: "None", colorDot: T.faint, action: () => data.editChapter(chapter.id, { color: null }) },
|
||||
...CHAPTER_COLORS.map((c) => ({
|
||||
label: c.label,
|
||||
colorDot: c.value,
|
||||
action: () => data.editChapter(chapter.id, { color: c.value }),
|
||||
})),
|
||||
],
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Remove chapter",
|
||||
icon: I.trash,
|
||||
danger: true,
|
||||
action: () =>
|
||||
setConfirmDialog({
|
||||
confirm({
|
||||
title: "Remove chapter?",
|
||||
message: `"${chapter.name}" is removed. Its ${chapter.noteCount} note${
|
||||
chapter.noteCount === 1 ? "" : "s"
|
||||
} stay — they go back to the notebook.`,
|
||||
confirmLabel: "Remove",
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
if (activeChapter === chapter.id) setActiveChapter(null);
|
||||
void data.deleteChapter(chapter.id);
|
||||
onConfirm: async () => {
|
||||
if (activeChapter === chapter.id) {
|
||||
setActiveChapter(null);
|
||||
setInChapter(false);
|
||||
}
|
||||
await data.deleteChapter(chapter.id);
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -1170,16 +1175,16 @@ export default function App() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{namingChapter && (
|
||||
{naming && (
|
||||
<NameDialog
|
||||
title={namingChapter.title}
|
||||
value={namingChapter.value}
|
||||
label="Chapter name"
|
||||
colors={CHAPTER_COLORS}
|
||||
color={namingChapter.color}
|
||||
confirmLabel={namingChapter.value ? "Save" : "Create"}
|
||||
onSave={namingChapter.onSave}
|
||||
onClose={() => setNamingChapter(null)}
|
||||
title={naming.title}
|
||||
value={naming.value}
|
||||
label={naming.label ?? "Chapter name"}
|
||||
colors={naming.colors}
|
||||
color={naming.color}
|
||||
confirmLabel={naming.confirmLabel ?? (naming.value ? "Save" : "Create")}
|
||||
onSave={naming.onSave}
|
||||
onClose={() => setNaming(null)}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
@@ -1188,7 +1193,13 @@ export default function App() {
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
confirmLabel={confirmDialog.confirmLabel}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
/* The one place the question is taken down, whatever it asked and
|
||||
whoever set it. Leaving that to each caller left one of them
|
||||
holding the screen hostage after doing its work. */
|
||||
onConfirm={async () => {
|
||||
await confirmDialog.onConfirm?.();
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
dark={dark}
|
||||
/>
|
||||
|
||||
@@ -70,8 +70,11 @@ const Grip = ({ T, ...rest }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Width the separator's label keeps in both states, so renaming moves nothing. */
|
||||
const MIDDLE_W = 132;
|
||||
/** Narrowest the label's box gets, so an unnamed line still has something to hit. */
|
||||
const MIDDLE_MIN = 54;
|
||||
|
||||
/** Widest, so a long name cannot squeeze the rules away to nothing. */
|
||||
const MIDDLE_MAX = 170;
|
||||
|
||||
/**
|
||||
* A line drawn between notes, with an optional name.
|
||||
@@ -103,10 +106,11 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
/* 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",
|
||||
gap: 7,
|
||||
/* A little room above, so it reads as the end of what came before rather
|
||||
than as decoration attached to the next note — but only a little: it
|
||||
is a line between two rows, not a heading of its own. */
|
||||
padding: "9px 8px 5px",
|
||||
cursor: grip ? "grab" : "default",
|
||||
}}
|
||||
>
|
||||
@@ -116,7 +120,17 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
its contents made the whole row jump sideways the moment renaming
|
||||
started, which is exactly when the eye is on it. */}
|
||||
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
|
||||
<div style={{ flex: "0 0 auto", width: MIDDLE_W, display: "flex" }}>
|
||||
{/* Sized to the name it holds, between a floor and a ceiling. A fixed slot
|
||||
kept the row from jumping when renaming began, at the cost of a short
|
||||
name floating in the middle of a gap it did not fill. */}
|
||||
<div
|
||||
style={{
|
||||
flex: "0 1 auto",
|
||||
minWidth: MIDDLE_MIN,
|
||||
maxWidth: MIDDLE_MAX,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
@@ -132,6 +146,9 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
}}
|
||||
aria-label="Separator label"
|
||||
placeholder="Section"
|
||||
// Follows what is being typed, so the row grows with the name
|
||||
// instead of standing in a slot sized for a name nobody wrote.
|
||||
size={Math.max(6, draft.length + 1)}
|
||||
style={{
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
@@ -139,9 +156,9 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
fontSize: 12.5,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.6,
|
||||
textAlign: "center",
|
||||
// Matches the button's box exactly, so nothing shifts vertically
|
||||
// either when the input replaces it.
|
||||
@@ -157,9 +174,9 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
border: "1px solid transparent",
|
||||
background: "transparent",
|
||||
color: separator.label ? T.text : T.muted,
|
||||
fontSize: 12.5,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: "uppercase",
|
||||
textAlign: "center",
|
||||
padding: "1px 4px",
|
||||
@@ -686,6 +703,11 @@ export function NoteList({
|
||||
+ Chapter
|
||||
</button>
|
||||
)}
|
||||
{/* Only where a line can be seen. A separator belongs to the hand-made
|
||||
order and is hidden in every other one, so the button stood in
|
||||
orders where pressing it produced nothing at all — which reads
|
||||
exactly like a button that is broken. */}
|
||||
{reorderable && (
|
||||
<button
|
||||
onClick={onAddSeparator}
|
||||
data-add-separator
|
||||
@@ -704,6 +726,7 @@ export function NoteList({
|
||||
>
|
||||
+ Separator
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,23 @@
|
||||
|
||||
import type { Chapter, NoteMeta, Separator, SortMode } from "../../types";
|
||||
|
||||
/**
|
||||
* What a notebook is in until it is told otherwise.
|
||||
*
|
||||
* The hand-made order, because that is where a notebook's own arrangement lives:
|
||||
* separators show, rows can be dragged into place, and nothing slides about on
|
||||
* its own. A notebook that has never been arranged loses nothing by it —
|
||||
* untouched rows all carry position 0 and fall back to newest first, which is
|
||||
* what the old default showed.
|
||||
*/
|
||||
export const DEFAULT_NOTEBOOK_SORT: SortMode = "manual";
|
||||
|
||||
/**
|
||||
* What a *view* is in — "All Notes", "Starred", the bin.
|
||||
*
|
||||
* Not the hand-made one: those are questions asked of every notebook at once, so
|
||||
* there is nothing for an arrangement to belong to and nowhere to keep it.
|
||||
*/
|
||||
export const DEFAULT_SORT: SortMode = "updated";
|
||||
|
||||
export const SORT_LABELS: Record<SortMode, string> = {
|
||||
|
||||
@@ -14,6 +14,10 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
|
||||
);
|
||||
return (
|
||||
<div
|
||||
// Named so a test can say "and the question is gone afterwards". Without
|
||||
// that, a check reads the list straight through the overlay and passes
|
||||
// while the screen is in fact stuck behind it.
|
||||
data-confirm-dialog
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
|
||||
@@ -47,10 +47,11 @@ export function NameDialog({
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const ready = !!name.trim();
|
||||
|
||||
const save = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
onSave(trimmed, color);
|
||||
if (!ready) return;
|
||||
onSave(name.trim(), color);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -157,14 +158,14 @@ export function NameDialog({
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!name.trim()}
|
||||
disabled={!ready}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: name.trim() ? T.accent : T.border,
|
||||
background: ready ? T.accent : T.border,
|
||||
color: "#fff",
|
||||
cursor: name.trim() ? "pointer" : "default",
|
||||
cursor: ready ? "pointer" : "default",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user