One dialog per thing, and rows dragged into a chapter

A separator had three menu entries — rename, colour, line — plus an input
that appeared in the row itself: three shapes of interaction for one small
thing, and none of them showed the result. Choosing "dotted" from a list
of words is a guess. It is one Edit now, and the dialog draws the line as
it will look with every shape drawn beside its name, from the very rules
the list draws with: a sample that can disagree with the result is worse
than none.

A chapter goes the same way — name, mark and colour in one dialog, the
same one whether it is being made or changed, with the row drawn at the
top. Asked from three entries, getting a new chapter to look right took
three visits. The name dialog it used is gone.

A row is put into a chapter by dragging it onto it: the chapter lights
up and takes what is dropped rather than growing a line above it, since
a chapter is a place and not a neighbour. Notes and separators both, and
the "+ Separator" inside a chapter goes with it — a second way to make
the same thing in a second place is a second thing to keep straight.

A link now wears the mark of the note it points at, in the text and in
the @ menu. It travels when the note's mark changes, as the label
already did.

Two alignment faults: a task list's checkbox was nudged down by a fixed
0.35em measured once against one font, so it floated above the line it
belonged to — it is centred in a box one line tall now. And the title
bar's mark was spaced like the buttons at the far end and grew with its
contents, so picking an emoji shifted the title sideways.

Fourteen suites green: 374 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-17 00:23:26 +02:00
1 parent 59844046c4
commit d68b0775c2
20 files changed
+1185 -594

No files matched your search

+33
View File
@@ -8,6 +8,39 @@ not from the output of `docker push`.
---
## 2026-08-17 — One dialog per thing, and rows dragged into a chapter
**Images** — web `sha256:…`, server unchanged in substance
**Changed**
- **A separator has one *Edit*** instead of rename, colour and line as three
entries — and the dialog **draws the line as it will look**, with each of the
five shapes drawn beside its name. Choosing "dotted" from a list of words was a
guess. The preview is built from the very rules the list draws with; a sample
that can disagree with the result is worse than none. Renaming in the row
itself is gone with it.
- **A chapter likewise**: name, mark and colour in one dialog, the same one
whether it is being made or changed, with the row drawn at the top. Asked from
three menu entries, getting a new chapter to look right took three visits.
- **A row is put into a chapter by dragging it onto it.** The chapter lights up
and takes what is dropped, rather than growing a drop line above it — it is a
place, not a neighbour. Notes and separators both. The **"+ Separator" inside
a chapter is gone**: a line is drawn in the notebook and dragged in.
- **A link wears the mark of the note it points at**, in the text and in the
`@` menu, instead of the same sheet-of-paper glyph for everything.
**Fixed**
- **A task list's checkbox sat above the line it belonged to.** It was nudged
down by a fixed `0.35em`, measured once against one font at one size. It is
centred in a box exactly one line tall now, which holds at every size and for
tasks that wrap.
- **The title bar read as crooked.** The mark was a child of the header and got
the same spacing as the buttons at the far end, so it looked like a control
parked beside the title; and the box grew with its contents, so choosing an
emoji shifted the title sideways. Mark and name are one group in a fixed box.
---
## 2026-08-16 — Marks, lines, and a table that breaks
**Images** — web `sha256:1f9f5141…`, server `sha256:3b2f8b44…`
+157 -45
View File
@@ -24,15 +24,20 @@ try {
const titles = () =>
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
/* The colour is chosen while the chapter is being made. It is what tells one
chapter from the next, and having to right-click a fresh one to say which
it is meant two visits for a single decision. */
const addChapter = async (name, colour = null) => {
/* Name, colour and mark are chosen while the chapter is being made — they are
one decision, and asked one at a time from three menu entries, getting a new
chapter to look right took three visits. */
const addChapter = async (name, colour = null, icon = null) => {
await p.locator("[data-add-chapter]").click();
await p.waitForTimeout(600);
await p.getByLabel("Chapter name").fill(name);
if (colour) await p.locator(`[data-colour-option="${colour}"]`).click();
await p.getByRole("button", { name: "Create", exact: true }).click();
if (icon) {
await p.getByLabel("Search icons").fill(icon);
await p.waitForTimeout(400);
await p.locator("[data-icon-option]").nth(1).click();
}
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2000);
};
/* Filing a note is one of the things a row can have done to it, and they all
@@ -77,7 +82,11 @@ try {
await createNotebook(p, "Leeres Buch");
await p.locator("button").filter({ hasText: "Leeres Buch" }).first().click();
await p.waitForTimeout(1000);
await addChapter("Nur ein Kapitel", "#a371f7");
await addChapter("Nur ein Kapitel", "#a371f7", "invoice");
check(
(await p.locator("[data-chapter-dialog]").count()) === 0,
"the chapter dialog closes once it has been saved"
);
check(
(await p.locator("[data-chapter-row]").count()) === 1,
"a chapter shows in a notebook that has no notes at all",
@@ -85,9 +94,9 @@ try {
);
const madeWithColour = (await api(p, "GET", "/chapters")).body.find((c) => c.name === "Nur ein Kapitel");
check(
madeWithColour?.color === "#a371f7",
"and the colour picked while making it is stored with it",
String(madeWithColour?.color)
madeWithColour?.color === "#a371f7" && !!madeWithColour?.icon,
"and the colour and mark picked while making it are stored with it",
JSON.stringify([madeWithColour?.color, madeWithColour?.icon])
);
check(
(await p.locator("[data-no-loose-notes]").count()) === 1,
@@ -254,38 +263,137 @@ try {
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
/* ── a chapter can be divided too ──
* It is a list like the notebook is. Its lines are its own: each carries the
* list it was drawn in, so the notebook's stay outside and these stay here. */
/* Pressing a chapter row toggles it, so "make sure it is open" cannot be a
single click — whether it opens or closes depends on what was open before. */
const openChapterBlock = async () => {
for (let i = 0; i < 3; i++) {
if (await p.locator("[data-chapter-notes]").count()) return true;
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1100);
}
return (await p.locator("[data-chapter-notes]").count()) > 0;
/* ── a row is put into a chapter by dragging it there ──
* A chapter is a place, so it takes what is dropped on it rather than making
* room beside it. That is also how a chapter gets a line of its own: there is
* no second "+ Separator" inside it, because a second way to make the same
* thing in a second place is a second thing to keep straight. */
check(
(await p.locator("[data-add-chapter-separator]").count()) === 0,
"a chapter has no separator button of its own"
);
/* Dragged with synthesised pointer events from the grip, the way a finger
does it — Playwright drives a mouse, and the branch worth exercising is the
one a phone takes. */
/* Held and dropped in two steps, with a wait between: the highlight is React
state, and asking for it in the same breath as the move that caused it reads
the DOM before it has been drawn. */
const holdOver = (dragId, chapterId) =>
p.evaluate(
([from, to]) => {
const row = document.querySelector(`[data-reorder-id="${from}"]`);
const grip = row?.querySelector("[data-reorder-grip]");
const target = document.querySelector(`[data-reorder-id="${to}"]`);
if (!grip || !target) return `missing ${!!grip} ${!!target}`;
const box = target.getBoundingClientRect();
window.__drop = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
const fire = (el, type, x, y) =>
el.dispatchEvent(
new PointerEvent(type, {
pointerType: "touch",
pointerId: 1,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
})
);
const s = grip.getBoundingClientRect();
fire(grip, "pointerdown", s.x + s.width / 2, s.y + s.height / 2);
fire(window, "pointermove", window.__drop.x, window.__drop.y - 6);
fire(window, "pointermove", window.__drop.x, window.__drop.y);
return "held";
},
[dragId, chapterId]
);
const letGo = () =>
p.evaluate(() => {
const d = window.__drop;
window.dispatchEvent(
new PointerEvent("pointerup", {
pointerType: "touch",
pointerId: 1,
clientX: d.x,
clientY: d.y,
bubbles: true,
cancelable: true,
})
);
});
const dragOnto = async (dragId, chapterId) => {
const held = await holdOver(dragId, chapterId);
if (held !== "held") return held;
await p.waitForTimeout(400);
const lit = await p.locator("[data-chapter-drop]").count();
await letGo();
await p.waitForTimeout(2200);
return lit === 1 ? "done" : "no-highlight";
};
check(await openChapterBlock(), "a chapter unfolds under its row");
const insideBefore = await p.locator("[data-chapter-notes] [data-separator]").count();
await p.locator("[data-add-chapter-separator]").click();
await p.waitForTimeout(2200);
/* Rows are only draggable while rearranging is switched on — the handles are
asked for rather than standing in every row. */
if ((await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle")) === "off") {
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(700);
}
check(
(await p.locator("[data-chapter-notes] [data-separator]").count()) === insideBefore + 1,
"a line can be drawn inside a chapter",
String(await p.locator("[data-chapter-notes] [data-separator]").count())
(await p.locator("[data-reorder-id]").count()) > 0,
"there are handles to drag by",
JSON.stringify({
toggle: await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle"),
grips: await p.locator("[data-reorder-grip]").count(),
rows: await p.locator("[data-reorder-id]").count(),
})
);
const drawnIn = JSON.parse(
(await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Haus")?.separators || "[]"
const looseBefore = (await titles()).length;
/* Both ends of the drag are read off the rows on screen: what is draggable is
what is rendered, and the server's idea of the notebook includes rows this
list is not showing. */
const rowOrder = await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId));
const shownChapters = await p
.locator("[data-chapter-row]")
.evaluateAll((els) => els.map((e) => e.getAttribute("data-chapter-row")));
const noteId = rowOrder.find(
(id) => id && !id.startsWith("sep-") && id !== "__end__" && !shownChapters.includes(id)
);
check(
drawnIn.some((sep) => sep.chapterId) && drawnIn.some((sep) => !sep.chapterId),
"and it belongs to the chapter, while the notebook keeps its own",
JSON.stringify(drawnIn.map((sep) => [sep.label, sep.chapterId]))
!!noteId && !!shownChapters[0],
"there is a note row and a chapter row to drag between",
JSON.stringify({ rowOrder, shownChapters })
);
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1000);
const dragged = await dragOnto(noteId, shownChapters[0]);
check(dragged === "done", "the chapter lights up as the row is held over it", dragged);
check(
(await titles()).length === looseBefore - 1,
"a note dragged onto a chapter leaves the notebook's own list",
`${looseBefore} → ${(await titles()).length}`
);
const filedByDrag = (await api(p, "GET", "/notes")).body.filter((n) => n.chapter_id).length;
check(filedByDrag >= 1, "and the server has it filed there", String(filedByDrag));
/* A line is drawn in the notebook and dragged in, which is how a chapter is
divided at all. */
const sepRow = (
await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId))
).find((id) => id && id.startsWith("sep-"));
if (sepRow) {
await dragOnto(sepRow, shownChapters[0]);
await p.waitForTimeout(2500);
const inChapterNow = JSON.parse(
(await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Haus")?.separators || "[]"
);
check(
inChapterNow.some((sep) => sep.chapterId),
"a separator dragged onto a chapter belongs to it",
JSON.stringify(inChapterNow.map((sep) => [sep.label, sep.chapterId]))
);
} else {
check(false, "a separator was there to drag", "none found");
}
/* ── a dialog does not close because a selection ended outside it ──
* Select a word in the field, drag a little past the edge, let go: the click
@@ -293,7 +401,7 @@ try {
* dialog used to take that for "clicked beside me" and vanish mid-edit. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Rename or recolour", { exact: true }).click();
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
const field = p.getByLabel("Chapter name");
const box = await field.boundingBox();
@@ -303,7 +411,7 @@ try {
await p.mouse.up();
await p.waitForTimeout(800);
check(
(await p.locator("[data-name-dialog]").count()) === 1,
(await p.locator("[data-chapter-dialog]").count()) === 1,
"a selection dragged past the edge does not close the dialog"
);
await p.keyboard.press("Escape");
@@ -316,13 +424,15 @@ try {
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
check(
(await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).count()) === 0,
"there is one entry for a chapter's name and colour, not two"
(await p.locator("[data-submenu-toggle]").count()) === 0 &&
(await p.getByText("Edit", { exact: true }).count()) === 1,
"its menu offers one Edit rather than one entry per property"
);
await p.getByText("Rename or recolour", { exact: true }).click();
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
check((await p.locator("[data-chapter-preview]").count()) === 1, "and the dialog draws the row as it will be");
await p.locator('[data-colour-option="#2da44e"]').click();
await p.getByRole("button", { name: "Save", exact: true }).click();
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2000);
const coloured = (await api(p, "GET", "/chapters")).body;
check(
@@ -337,14 +447,16 @@ try {
* both places. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Icon", { exact: true }).click();
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
await p.getByLabel("Search icons").fill("invoice");
await p.waitForTimeout(400);
// One extra: "no icon" sits in the grid, because taking a mark off is the
// same kind of choice as putting one on.
const offered = await p.locator("[data-icon-option]").count();
check(offered > 0 && offered < 20, "icons are searched by what they are for", `${offered} shown`);
await p.locator("[data-icon-option]").first().click();
await p.locator("[data-icon-save]").click();
check(offered > 1 && offered < 20, "icons are searched by what they are for", `${offered} shown`);
await p.locator("[data-icon-option]").nth(1).click();
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2200);
const marked = (await api(p, "GET", "/chapters")).body.find((c) => c.icon);
check(!!marked?.icon, "a chapter keeps the mark it was given", JSON.stringify([marked?.name, marked?.icon]));
+39 -24
View File
@@ -215,21 +215,49 @@ try {
await p.waitForTimeout(1800);
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
/* Renaming is right-click, like removing. A left-click on the label used to
start an edit, so the row could not be touched without falling into one. */
const label = p.getByLabel("Separator label");
check((await label.count()) === 0, "clicking a separator does not drop straight into renaming");
/* Everything about a line is set in one dialog reached by right-click, like
every other row here is acted on. A left-click on the label used to start an
edit in place, so the row could not be touched without falling into one. */
check(
(await p.getByLabel("Separator label").count()) === 0,
"clicking a separator does not drop straight into renaming"
);
await p.locator("[data-separator]").click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Rename", { exact: true }).click();
await p.waitForTimeout(500);
await label.fill("Wichtig");
await label.press("Enter");
await p.waitForTimeout(1800);
check(
!/Colour|Line/.test(await p.locator("[data-context-menu], body").first().innerText()) ||
(await p.getByText("Edit", { exact: true }).count()) === 1,
"its menu offers one Edit rather than one entry per property"
);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
/* The dialog draws the line itself, so "dotted" is something you see rather
than a word you have to imagine. */
check((await p.locator("[data-separator-preview]").count()) === 1, "the dialog shows the line as it will be");
check(
(await p.locator("[data-line-option]").count()) === 5,
"with every shape drawn beside its name",
String(await p.locator("[data-line-option]").count())
);
const samples = await p
.locator("[data-line-option] [data-separator-rule]")
.evaluateAll((els) => els.map((el) => getComputedStyle(el).borderTopStyle || "solid"));
check(
new Set(samples).size > 1,
"and the samples really differ from one another",
samples.join(", ")
);
await p.getByLabel("Separator name").fill("Wichtig");
await p.locator('[data-line-option="dashed"]').click();
await p.locator('[data-colour-option="#1f9c96"]').click();
await p.locator("[data-separator-save]").click();
await p.waitForTimeout(2000);
check(
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
"and it can be named from the same menu",
"and it carries the name it was given",
await p.locator("[data-separator]").innerText()
);
@@ -265,22 +293,9 @@ try {
await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
/* ── a line can be given its own colour and shape ──
/* ── the choices reach the server, and the screen ──
* A notebook divided into three parts is easier to read at a glance when the
* lines differ; grey-on-grey they all look like the same line repeated. */
await p.locator("[data-separator]").click({ button: "right" });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: "Teal" }).first().click();
await p.waitForTimeout(2000);
await p.locator("[data-separator]").click({ button: "right" });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").filter({ hasText: "Line" }).first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: "Dashed" }).first().click();
await p.waitForTimeout(2200);
const stored = (await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Ordnung");
const seps = JSON.parse(stored?.separators || "[]");
check(
+79 -109
View File
@@ -12,7 +12,8 @@ import { CHAPTER_COLORS, DEFAULT_FOLDERS, SEPARATOR_STYLES, coverBackground } fr
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
import { NameDialog } from "./ui/NameDialog.jsx";
import { ChapterDialog } from "./ui/ChapterDialog.jsx";
import { SeparatorDialog } from "./ui/SeparatorDialog.jsx";
import { IconPicker } from "./ui/IconPicker.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
@@ -92,17 +93,16 @@ export default function App() {
store a choice — "All Notes" is not a thing that can carry a setting — so it
lasts for the session and no longer. */
const [virtualSort, setVirtualSort] = useState({});
// Which separator is being renamed. Held here because the context menu that
// starts it lives here too.
const [renamingSeparator, setRenamingSeparator] = useState(null);
// The separator being edited, if any. Held here because the menu that opens
// the dialog lives here too.
const [editingSeparator, setEditingSeparator] = useState(null);
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = 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);
/* { title, value, colour, onSave } while a mark is being chosen for a note or
a chapter — one picker, because it is one decision either way. */
/* { title, value, colour, onSave } while a mark is being chosen for a note. */
const [pickingIcon, setPickingIcon] = useState(null);
/* The chapter being made or changed: `{}` for a new one, the chapter itself
for an existing one. */
const [editingChapter, setEditingChapter] = 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);
@@ -245,6 +245,7 @@ export default function App() {
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, listChapter),
[data, activeFolder, listChapter]
);
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
const isTrashView = activeFolder === "trash";
@@ -290,22 +291,39 @@ export default function App() {
reads perfectly well unnamed. */
const addSeparator = () =>
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", listChapter));
/* The same act inside a chapter. A chapter is a list like the notebook is, so
it can be divided like one — its lines carry its id and stay in it. */
const addChapterSeparator = () =>
activeChapter &&
writeIn(
activeChapter,
withNewSeparator(chapterSeparators, `sep-${Date.now().toString(36)}`, "", activeChapter)
);
const renameSeparator = (id, label) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
/* Colour and pattern are presentation, so they are written the same way the
label is: onto the notebook's one list, leaving the others untouched. */
const restyleSeparator = (id, changes) =>
const editSeparator = (id, changes) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, ...changes } : s)));
/**
* Dropping a row onto a chapter puts it *in* the chapter.
*
* A note is filed there; a line drawn in the notebook is moved into it, which
* is also how a chapter gets a line of its own — there is no second button for
* that, because a second way to make the same thing in a second place is a
* second thing to keep straight.
*/
const fileIntoChapter = useCallback(
(dragId, chapterId) => {
const note = notes.find((n) => n.id === dragId);
if (note) {
void data.moveNote(dragId, activeFolder, chapterId);
return;
}
const separator = allSeparators.find((sep) => sep.id === dragId);
if (separator) {
writeSeparators(
allSeparators.map((sep) => (sep.id === dragId ? { ...sep, chapterId, order: 0 } : sep))
);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[notes, allSeparators, activeFolder, data]
);
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
const separatorContextMenu = (e, separator) => {
e.preventDefault();
e.stopPropagation();
@@ -313,27 +331,10 @@ export default function App() {
x: e.clientX,
y: e.clientY,
items: [
{ label: "Rename", icon: I.edit, action: () => setRenamingSeparator(separator.id) },
{
label: "Colour",
icon: I.edit,
submenu: [
{ label: "None", colorDot: T.faint, action: () => restyleSeparator(separator.id, { color: null }) },
...CHAPTER_COLORS.map((c) => ({
label: c.label,
colorDot: c.value,
action: () => restyleSeparator(separator.id, { color: c.value }),
})),
],
},
{
label: "Line",
icon: I.edit,
submenu: SEPARATOR_STYLES.map((st) => ({
label: st.label,
action: () => restyleSeparator(separator.id, { style: st.value }),
})),
},
/* One entry, like every other row here has. It used to be three —
rename, colour, line — three shapes of interaction for one small
thing, none of them showing what the result would look like. */
{ label: "Edit", icon: I.edit, action: () => setEditingSeparator(separator) },
{ divider: true },
{
label: "Remove separator",
@@ -436,7 +437,7 @@ export default function App() {
...notes
.filter((n) => !n.trashed && n.id !== activeNote && match(n.title || "Untitled"))
.slice(0, 8)
.map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note" })),
.map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note", icon: n.icon || null })),
...notebooks
.filter((nb) => match(nb.name))
.slice(0, 4)
@@ -468,9 +469,16 @@ export default function App() {
[notes, openFolder, openNote]
);
/* The mark a link should wear: the target's own, so a reference is recognised
by the same sign as the note it points at. */
const resolveLinkIcon = useCallback(
(id) => notes.find((n) => n.id === id && !n.trashed)?.icon ?? null,
[notes]
);
const linkProps = useMemo(
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }),
[openLinkTarget, searchLinkTargets, resolveLinkLabel]
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel, resolveLinkIcon }),
[openLinkTarget, searchLinkTargets, resolveLinkLabel, resolveLinkIcon]
);
/* ── Actions ── */
@@ -715,20 +723,11 @@ export default function App() {
setInChapter(!!next);
};
/* Named and coloured in one go. The colour is what tells one chapter from the
next, so asking for it while the chapter is being made is one decision
rather than a second visit through the right-click menu. */
const addChapter = () => {
if (!activeNotebook) return;
setNaming({
title: "New chapter",
label: "Chapter name",
value: "",
colors: CHAPTER_COLORS,
color: null,
onSave: (name, color) => data.createChapter(activeNotebook.id, name, color),
});
};
/* Named, marked and coloured in one go, and the same dialog does both making
and changing. A chapter's whole identity is those three things; asked one at
a time from three menu entries, getting a new chapter to look right took
three visits. */
const addChapter = () => activeNotebook && setEditingChapter({});
const chapterContextMenu = (e, chapter) => {
e.preventDefault();
@@ -737,37 +736,7 @@ 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: () =>
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 }),
}),
},
/* The mark is its own entry: a name and a colour are typed and picked
from a short list, a mark is hunted for in a grid — one dialog doing
both would be a form. */
{
label: "Icon",
icon: I.edit,
action: () =>
setPickingIcon({
title: `Icon for "${chapter.name}"`,
value: chapter.icon ?? null,
colour: chapter.color ?? null,
onSave: (icon, colour) => data.editChapter(chapter.id, { icon, color: colour }),
}),
},
{ label: "Edit", icon: I.edit, action: () => setEditingChapter(chapter) },
{ divider: true },
{
label: "Remove chapter",
@@ -861,11 +830,7 @@ export default function App() {
separators={chapterSeparators}
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
activeNote={activeNote}
renamingSeparator={renamingSeparator}
onStartRenameSeparator={setRenamingSeparator}
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
onAddSeparator={addChapterSeparator}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
compact={compact}
@@ -982,9 +947,6 @@ export default function App() {
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
separators={separators}
onAddSeparator={addSeparator}
renamingSeparator={renamingSeparator}
onStartRenameSeparator={setRenamingSeparator}
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
chapters={folderChapters}
/* Only in the fallback: there the list becomes the chapter, and
@@ -997,6 +959,7 @@ export default function App() {
expandedChapter={inlineChapters ? activeChapter : null}
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
onReorder={rearrangeNotes}
onFileInto={fileIntoChapter}
listKey={`${activeFolder}:${listChapter ?? ""}`}
isCurrent={showsWhere ? !inChapter : null}
onFocusList={showsWhere ? () => setInChapter(false) : undefined}
@@ -1197,16 +1160,23 @@ export default function App() {
)}
/>
)}
{naming && (
<NameDialog
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)}
{editingChapter && (
<ChapterDialog
chapter={editingChapter}
onSave={(fields) =>
editingChapter.id
? data.editChapter(editingChapter.id, fields)
: data.createChapter(activeNotebook.id, fields.name, fields.color, fields.icon)
}
onClose={() => setEditingChapter(null)}
dark={dark}
/>
)}
{editingSeparator && (
<SeparatorDialog
separator={editingSeparator}
onSave={(changes) => editSeparator(editingSeparator.id, changes)}
onClose={() => setEditingSeparator(null)}
dark={dark}
/>
)}
+12 -2
View File
@@ -27,6 +27,9 @@
from the rows under it. */
--ne-th-bg: #eaeef2;
--ne-th-rule: #afb8c1;
/* One line of body text. Named because the task list needs to know it: a
checkbox is centred against a line, and a line is this tall. */
--ne-line: 1.7;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
@@ -64,7 +67,7 @@
an unset preference still reads as the serif it always was. */
font-family: var(--ne-font, "Newsreader", Georgia, serif);
font-size: 15px;
line-height: 1.7;
line-height: var(--ne-line);
padding: 20px 24px;
min-height: 100%;
word-break: break-word;
@@ -246,9 +249,16 @@
align-items: flex-start;
gap: 8px;
}
/* The box sits in the middle of the *first line* beside it, not at a fixed
nudge from the top. `margin-top: 0.35em` was measured once against one font at
one size; changing the note's typeface, or writing a task that wraps, left the
box floating above the text it belongs to. Giving the label the height of one
line and centring inside it holds for every size. */
.note-editor .ProseMirror ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-top: 0.35em;
display: flex;
align-items: center;
height: calc(1em * var(--ne-line));
user-select: none;
}
.note-editor .ProseMirror ul[data-type="taskList"] li > div {
+17 -3
View File
@@ -50,6 +50,15 @@ export const NoteLink = Node.create({
},
// Set when the target no longer exists, so a dead link reads as broken
// instead of silently going nowhere.
/* The target's own mark, kept on the link and refreshed like the label is.
* A reference that shows the same sheet-of-paper glyph whatever it points
* at tells you nothing; the mark you gave the note is how you recognise
* it everywhere else. */
icon: {
default: null,
parseHTML: (el) => el.getAttribute("data-icon") || null,
renderHTML: (a) => (a.icon ? { "data-icon": a.icon } : {}),
},
missing: {
default: false,
parseHTML: (el) => el.getAttribute("data-missing") === "true",
@@ -71,7 +80,7 @@ export const NoteLink = Node.create({
href: "#",
title: node.attrs.label,
}),
`${{ notebook: "📓", pin: "📌" }[node.attrs.kind] || "📝"} ${node.attrs.label}`,
`${node.attrs.icon || { notebook: "📓", pin: "📌" }[node.attrs.kind] || "📝"} ${node.attrs.label}`,
];
},
@@ -95,7 +104,12 @@ export const NoteLink = Node.create({
.insertContentAt(range, [
{
type: "noteLink",
attrs: { noteId: props.id, label: props.label, kind: props.kind || "note" },
attrs: {
noteId: props.id,
label: props.label,
kind: props.kind || "note",
icon: props.icon || null,
},
},
{ type: "text", text: " " },
])
@@ -131,7 +145,7 @@ function renderSuggestionList() {
it,
i
) => `<button type="button" class="note-link-option${i === selected ? " is-selected" : ""}" data-index="${i}">
<span class="note-link-kind">${it.kind === "notebook" ? "📓" : "📝"}</span>
<span class="note-link-kind">${it.icon || (it.kind === "notebook" ? "📓" : "📝")}</span>
<span class="note-link-label"></span>
</button>`
)
+11 -4
View File
@@ -123,7 +123,7 @@ export function useNoteEditor({
}
/** The editable page itself. */
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel }) {
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel, resolveLinkIcon }) {
/* Note links carry the label they were created with, so renaming a note would
* leave every reference to it showing the old title.
*
@@ -139,8 +139,15 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
const label = resolveLinkLabel(node.attrs.noteId);
const missing = label === null;
const nextLabel = missing ? node.attrs.label : label;
if (nextLabel !== node.attrs.label || missing !== !!node.attrs.missing) {
edits.push([pos, { ...node.attrs, label: nextLabel, missing }]);
// The mark travels the same way, and for the same reason: it belongs to
// the note, so it has to follow when the note's changes.
const icon = (resolveLinkIcon ? resolveLinkIcon(node.attrs.noteId) : node.attrs.icon) ?? null;
if (
nextLabel !== node.attrs.label ||
missing !== !!node.attrs.missing ||
icon !== (node.attrs.icon ?? null)
) {
edits.push([pos, { ...node.attrs, label: nextLabel, missing, icon }]);
}
});
if (edits.length === 0) return;
@@ -149,7 +156,7 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
tr.setMeta(LABEL_REFRESH, true);
tr.setMeta("addToHistory", false); // undo should not step through renames
editor.view.dispatch(tr);
}, [editor, resolveLinkLabel]);
}, [editor, resolveLinkLabel, resolveLinkIcon]);
const handleClick = useCallback(
(e) => {
+54 -42
View File
@@ -193,48 +193,60 @@ export function EditorPane({
}}
>
{isMobile && <BackButton T={T} onClick={onBack} />}
{/* The note's own mark, left of its name — where the eye goes first and
where the same mark sits in the list. Pressing it opens the picker. */}
<button
onClick={onPickIcon}
data-note-icon={note.icon || "none"}
title="Choose an icon for this note"
aria-label="Choose an icon for this note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
flexShrink: 0,
borderRadius: 9,
border: "none",
background: "transparent",
cursor: "pointer",
fontSize: note.icon ? 17 : 14,
lineHeight: 1,
color: note.iconColor || T.faint,
}}
>
{note.icon || I.note}
</button>
<input
type="text"
aria-label="Note title"
value={note.title}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{
flex: 1,
border: "none",
background: "transparent",
outline: "none",
fontSize: isMobile ? 15 : 17,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
minWidth: 0,
}}
/>
{/* The mark and the name are one thing, so they sit in one group with a
small gap of their own. Left as separate children of the header they
were spaced like the buttons at the far end — the mark read as a
control that happened to be parked beside the title. */}
<div style={{ display: "flex", alignItems: "center", gap: 4, flex: 1, minWidth: 0 }}>
<button
onClick={onPickIcon}
data-note-icon={note.icon || "none"}
title="Choose an icon for this note"
aria-label="Choose an icon for this note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
/* One square box whatever it holds. An emoji and the drawn glyph
have different metrics, and sizing to the content moved the
title sideways the moment a mark was chosen. */
width: 26,
height: 26,
flexShrink: 0,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
padding: 0,
fontSize: 16,
lineHeight: 1,
color: note.iconColor || T.faint,
}}
>
{note.icon || I.note}
</button>
<input
type="text"
aria-label="Note title"
value={note.title}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{
flex: 1,
border: "none",
background: "transparent",
outline: "none",
fontSize: isMobile ? 15 : 17,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
minWidth: 0,
// Matches the box beside it, so the two sit on one line rather
// than each finding its own centre.
lineHeight: "26px",
padding: 0,
}}
/>
</div>
<button onClick={onExport} title="Export" style={toolbarButton(T, T.faint)}>
{I.export}
</button>
@@ -37,6 +37,7 @@ export function NoteEditor({
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
resolveLinkIcon,
backlinks,
}) {
const [mode, setMode] = useState("text");
@@ -864,6 +865,7 @@ export function NoteEditor({
minHeight={stageSize.h}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
resolveLinkIcon={resolveLinkIcon}
/>
</div>
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
+45 -148
View File
@@ -5,6 +5,7 @@ import { fmtDate } from "../../ui/format";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
import { END_ID, SORT_LABELS, mergeEntries } from "./noteSort";
import { useReorderDrag } from "../../hooks/useReorderDrag";
import { Rule } from "../../ui/separatorStyle.jsx";
const iconButton = (T) => ({
display: "flex",
@@ -76,66 +77,23 @@ const MIDDLE_MIN = 54;
/** Widest, so a long name cannot squeeze the rules away to nothing. */
const MIDDLE_MAX = 170;
/**
* How a separator's rule is drawn.
*
* Built as a border rather than a background, because that is the only way to
* get dashes and dots without an image — and `double` needs the height to carry
* two lines and the gap between them, or the browser draws one.
*/
const RULE_STYLES = {
solid: { height: 2, borderRadius: 2, solid: true },
dashed: { height: 0, borderTop: "2px dashed" },
dotted: { height: 0, borderTop: "3px dotted" },
double: { height: 0, borderTop: "4px double" },
thick: { height: 4, borderRadius: 2, solid: true },
};
/** One half of the line, left or right of the label. */
function Rule({ colour, style }) {
const shape = RULE_STYLES[style] || RULE_STYLES.solid;
return (
<div
data-separator-rule={style || "solid"}
style={{
flex: 1,
minWidth: 8,
height: shape.height,
borderRadius: shape.borderRadius,
...(shape.solid
? { background: colour }
: { borderTop: `${shape.borderTop} ${colour}` }),
}}
/>
);
}
/**
* A line drawn between notes, with an optional name.
*
* Draggable like a note, because a divider that cannot move with what it
* divides is worse than none.
* Draggable like a note, because a divider that cannot move with what it divides
* is worse than none.
*
* Both renaming and removing are right-click, which is how everything else in
* this list is acted on. A left-click on the label started renaming, so the row
* could not be clicked — to select it, to drag from it — without falling into an
* edit; and the × for removal sat a few pixels from that same target.
* Everything about it — its name, its colour, the shape of its rule — is changed
* in one dialog reached by right-click, like every other row here is acted on.
* It used to be three menu entries and an input that appeared in the row itself:
* three shapes of interaction for one small thing, and none of them showing what
* the result would look like.
*/
function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu, grip }) {
const [draft, setDraft] = useState(separator.label);
function SeparatorRow({ T, separator, onContextMenu, grip }) {
// Its own colour when it has been given one; the list's faint grey otherwise,
// which is what every separator looked like before there was a choice.
const colour = separator.color || T.faint;
useEffect(() => {
if (editing) setDraft(separator.label);
}, [editing, separator.label]);
const commit = () => {
onStartRename(null);
if (draft !== separator.label) onRename(separator.id, draft);
};
return (
<div
data-separator={separator.id}
@@ -155,73 +113,27 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
{/* The two rules always share what is left over equally. */}
<Rule colour={colour} style={separator.style} />
{/* 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. */}
kept the row from jumping when renaming began — which it no longer can,
since renaming happens in a dialog — at the cost of a short name
floating in the middle of a gap it did not fill. */}
<div
title="Right-click to edit"
style={{
flex: "0 1 auto",
minWidth: MIDDLE_MIN,
maxWidth: MIDDLE_MAX,
display: "flex",
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
color: separator.label ? separator.color || T.text : T.muted,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setDraft(separator.label);
onStartRename(null);
}
}}
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,
border: `1px solid ${T.border}`,
borderRadius: 6,
background: "transparent",
color: T.text,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textAlign: "center",
// Matches the button's box exactly, so nothing shifts vertically
// either when the input replaces it.
padding: "1px 4px",
outline: "none",
}}
/>
) : (
<div
title="Right-click for options"
style={{
width: "100%",
border: "1px solid transparent",
background: "transparent",
color: separator.label ? separator.color || T.text : T.muted,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
padding: "1px 4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{separator.label || "Unnamed"}
</div>
)}
{separator.label || "Unnamed"}
</div>
<Rule colour={colour} style={separator.style} />
</div>
@@ -308,11 +220,7 @@ export function ChapterNotes({
separators,
reorderable,
activeNote,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
onAddSeparator,
onSelectNote,
onNoteContextMenu,
compact = false,
@@ -332,9 +240,6 @@ export function ChapterNotes({
key={entry.id}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
grip={null}
/>
@@ -388,28 +293,10 @@ export function ChapterNotes({
</button>
)
)}
{/* A chapter can be divided like the notebook it sits in. Only where a
line can be seen, same as in the list outside. */}
{reorderable && onAddSeparator && (
<button
onClick={onAddSeparator}
data-add-chapter-separator
title="Insert a separator line in this chapter"
style={{
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
borderRadius: 8,
padding: "4px 9px",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
marginTop: 6,
}}
>
+ Separator
</button>
)}
{/* No button to add one here. A line is drawn in the notebook and dragged
into the chapter, the same way a note is put in one — a second way to
make the same thing, in a second place, is a second thing to keep
straight. */}
</>
);
}
@@ -633,9 +520,6 @@ export function NoteList({
reorderable,
separators,
onAddSeparator,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
chapters,
openChapter,
@@ -645,6 +529,8 @@ export function NoteList({
expandedChapter,
renderChapterNotes,
onReorder,
/** Drops a carried row *into* a chapter rather than beside it. */
onFileInto,
/** 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. */
@@ -674,7 +560,7 @@ export function NoteList({
/* 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(moving, onReorder);
const dnd = useReorderDrag(moving, onReorder, onFileInto);
/* 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. */
@@ -968,8 +854,22 @@ export function NoteList({
const lifted = dnd.draggingId === entry.id;
if (entry.kind === "chapter") {
const taking = dnd.intoId === entry.id;
return (
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
<div
key={entry.id}
{...(moving ? dnd.rowProps(entry.id, true) : {})}
data-chapter-drop={taking ? "on" : undefined}
style={{
opacity: lifted ? 0.45 : 1,
/* Held over, the whole row lights up rather than growing a
line above it: a chapter is about to take what is
carried, not stand next to it. */
outline: taking ? `2px solid ${T.accent}` : "none",
outlineOffset: -1,
borderRadius: 10,
}}
>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{moving && <Grip T={T} {...dnd.gripProps} />}
@@ -994,9 +894,6 @@ export function NoteList({
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
/>
</div>
@@ -27,6 +27,7 @@ export function PdfWorkspace({
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
resolveLinkIcon,
}) {
const [showNotes, setShowNotes] = useState(false);
// Set when a link in the notes points at a marker; the viewer clears it once
@@ -155,6 +156,7 @@ export function PdfWorkspace({
onOpenNote={openTarget}
searchLinkTargets={searchTargets}
resolveLinkLabel={resolveLabel}
resolveLinkIcon={resolveLinkIcon}
/* On a phone the viewer is not on screen at all, so the way back to
the document has to be here. */
onBackToDocument={narrow ? () => setShowNotes(false) : null}
+8 -3
View File
@@ -54,7 +54,12 @@ export interface NoteData {
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
chapters: Chapter[];
createChapter: (notebookId: string, name: string, color?: string | null) => Promise<Chapter>;
createChapter: (
notebookId: string,
name: string,
color?: string | null,
icon?: string | null
) => Promise<Chapter>;
/** Name, colour, or both — one write for one decision. */
editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise<void>;
deleteChapter: (id: string) => Promise<void>;
@@ -364,8 +369,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
*/
const createChapter = useCallback(
async (notebookId: string, name: string, color: string | null = null) => {
const chapter = await sync.createChapter(notebookId, name, color);
async (notebookId: string, name: string, color: string | null = null, icon: string | null = null) => {
const chapter = await sync.createChapter(notebookId, name, color, icon);
setChapters((p) => [...p, chapter]);
return chapter;
},
+44 -9
View File
@@ -23,31 +23,50 @@ const MOUSE_THRESHOLD = 5;
export interface ReorderDrag {
/** The row the pointer is over, for the drop indicator. */
overId: string | null;
/** A row that would *take* what is being carried, rather than sit next to it. */
intoId: string | null;
/** The row being carried, so it can be shown as lifted. */
draggingId: string | null;
/** Spread onto each row; `id` is what gets reordered. */
rowProps: (id: string) => { "data-reorder-id": string; onPointerDown: (e: React.PointerEvent) => void };
/**
* Spread onto each row; `id` is what gets reordered.
*
* `into` marks a row that is a place rather than a neighbour — a chapter.
* Dropping on one files what is carried into it instead of putting it above.
*/
rowProps: (
id: string,
into?: boolean
) => {
"data-reorder-id": string;
"data-reorder-into"?: string;
onPointerDown: (e: React.PointerEvent) => void;
};
/** Spread onto the grip inside a row. */
gripProps: { "data-reorder-grip": string };
}
export function useReorderDrag(
enabled: boolean,
reorder: (dragId: string, targetId: string) => void
reorder: (dragId: string, targetId: string) => void,
fileInto?: (dragId: string, targetId: string) => void
): ReorderDrag {
const [overId, setOverId] = useState<string | null>(null);
const [intoId, setIntoId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
// Everything the pointer handlers need, kept out of state: they run far more
// often than anything needs to be redrawn.
const drag = useRef<{ id: string; x: number; y: number; active: boolean; fromGrip: boolean } | null>(null);
const overRef = useRef<string | null>(null);
const intoRef = useRef<string | null>(null);
const stop = useCallback(() => {
drag.current = null;
overRef.current = null;
intoRef.current = null;
setDraggingId(null);
setOverId(null);
setIntoId(null);
document.body.style.removeProperty("user-select");
}, []);
@@ -87,9 +106,18 @@ export function useReorderDrag(
const row = el?.closest?.("[data-reorder-id]") as HTMLElement | null;
const target = row?.dataset.reorderId ?? null;
const next = target && target !== d.id ? target : null;
if (next !== overRef.current) {
overRef.current = next;
setOverId(next);
/* A chapter takes what is dropped on it rather than standing beside it,
* which is what "drag it into the chapter" means. Filing is only offered
* when there is somewhere to file *to*. */
const into = next && row?.dataset.reorderInto !== undefined && fileInto ? next : null;
if (into !== intoRef.current) {
intoRef.current = into;
setIntoId(into);
}
const beside = into ? null : next;
if (beside !== overRef.current) {
overRef.current = beside;
setOverId(beside);
}
if (e.cancelable) e.preventDefault();
};
@@ -97,7 +125,9 @@ export function useReorderDrag(
const up = () => {
const d = drag.current;
const target = overRef.current;
if (d?.active && target && target !== d.id) reorder(d.id, target);
const into = intoRef.current;
if (d?.active && into && into !== d.id) fileInto?.(d.id, into);
else if (d?.active && target && target !== d.id) reorder(d.id, target);
stop();
};
@@ -109,7 +139,7 @@ export function useReorderDrag(
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", stop);
};
}, [enabled, reorder, stop]);
}, [enabled, reorder, fileInto, stop]);
// A drag that outlives its list would leave the page unselectable.
useEffect(
@@ -121,8 +151,13 @@ export function useReorderDrag(
return {
overId,
intoId,
draggingId,
rowProps: (id: string) => ({ "data-reorder-id": id, onPointerDown: onPointerDown(id) }),
rowProps: (id: string, into = false) => ({
"data-reorder-id": id,
...(into ? { "data-reorder-into": "" } : {}),
onPointerDown: onPointerDown(id),
}),
gripProps: { "data-reorder-grip": "" },
};
}
+12 -6
View File
@@ -545,7 +545,10 @@ type QueuedAction =
};
}
| { action: "deleteNotebook"; data: { id: string } }
| { action: "createChapter"; data: { localId: string; notebookId: string; name: string; color?: string | null } }
| {
action: "createChapter";
data: { localId: string; notebookId: string; name: string; color?: string | null; icon?: string | null };
}
| {
action: "updateChapter";
data: { id: string; name?: string; sortOrder?: number; color?: string | null; icon?: string | null };
@@ -663,6 +666,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
notebookId: d.notebookId,
name: d.name,
color: d.color ?? null,
icon: d.icon ?? null,
});
if (r.id !== d.localId) {
const local = (await db.getAllChapters()).find((c) => c.id === d.localId);
@@ -672,6 +676,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
notebookId: d.notebookId,
name: d.name,
color: d.color ?? null,
icon: d.icon ?? null,
sortOrder: local?.sortOrder ?? 0,
});
// Notes filed into it while it was local, and anything still queued
@@ -837,13 +842,14 @@ export async function fetchChapters(): Promise<Chapter[]> {
export async function createChapter(
notebookId: string,
name: string,
color: string | null = null
color: string | null = null,
icon: string | null = null
): Promise<Chapter> {
const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length;
if (_online && api.token) {
try {
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color });
const chapter: Chapter = { id: r.id, notebookId, name, color, sortOrder };
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon });
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder };
await db.putChapter(chapter);
notifyData("chapter", chapter);
return chapter;
@@ -852,9 +858,9 @@ export async function createChapter(
}
}
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
const chapter: Chapter = { id: lid, notebookId, name, color, sortOrder, _pendingCreate: true };
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, _pendingCreate: true };
await db.putChapter(chapter);
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color } });
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon } });
notifyData("chapter", chapter);
notifyStatus("pending");
return chapter;
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx";
import { IconChooser } from "./IconChooser.jsx";
import { I } from "./icons.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Everything about one chapter, in one place — and the same place whether it is
* being made or changed.
*
* It used to be a name asked on creation and then two or three entries in the
* chapter's own menu for everything else, so making a chapter that looked the
* way you wanted took three visits. A chapter's whole identity is a name, a mark
* and a colour; they are one decision and belong in one dialog.
*
* The row is drawn at the top exactly as the list will draw it, because the
* point of a mark and a colour is how the row looks, and a grid of swatches does
* not answer that.
*/
export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const existing = !!chapter?.id;
const [name, setName] = useState(chapter?.name || "");
const [icon, setIcon] = useState(chapter?.icon ?? null);
const [colour, setColour] = useState(chapter?.color ?? null);
const inputRef = useRef(null);
const backdrop = useBackdropClose(onClose);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const ready = !!name.trim();
const save = () => {
if (!ready) return;
onSave({ name: name.trim(), icon, color: colour });
onClose();
};
return (
<div
data-chapter-dialog
{...backdrop}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
style={{
width: "100%",
maxWidth: 420,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 20,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>
{existing ? "Chapter" : "New chapter"}
</h3>
{/* The row as the list will draw it, changing as the choices change. */}
<div
data-chapter-preview
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "10px 12px",
borderRadius: 10,
background: T.field,
marginBottom: 16,
}}
>
<span
style={{
display: "flex",
alignItems: "center",
color: colour || T.accent,
flexShrink: 0,
fontSize: icon ? 15 : undefined,
lineHeight: 1,
}}
>
{icon || I.books}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 13,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name || "Unnamed chapter"}
</span>
</div>
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") onClose();
}}
aria-label="Chapter name"
placeholder="Chapter name"
style={{
width: "100%",
padding: "10px 13px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13.5,
outline: "none",
boxSizing: "border-box",
}}
/>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
const on = (c.value ?? null) === (colour ?? null);
return (
<button
key={c.label}
type="button"
data-colour-option={c.value ?? "none"}
title={c.label}
aria-label={c.label}
onClick={() => setColour(c.value ?? null)}
style={{
width: 26,
height: 26,
borderRadius: 8,
cursor: "pointer",
border: on ? `2px solid ${T.text}` : `1px solid ${T.border}`,
background: c.value ?? "transparent",
color: T.muted,
fontSize: 10,
lineHeight: 1,
}}
>
{c.value ? "" : "—"}
</button>
);
})}
</div>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Icon</div>
<IconChooser T={T} value={icon} onPick={setIcon} height={170} />
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 20 }}>
<button
type="button"
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
<button
type="button"
data-chapter-save
onClick={save}
disabled={!ready}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: ready ? T.accent : T.border,
color: "#fff",
cursor: ready ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{existing ? "Save" : "Create"}
</button>
</div>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useMemo, useState } from "react";
/**
* The marks a note or a chapter can wear, and the grid for choosing one.
*
* A fixed list rather than the system emoji keyboard. That keyboard is a
* different thing on every machine — on Android it is the one for typing, which
* means opening it inside a text field the app does not have — and it offers
* three thousand pictures for a decision that is "which of these is my invoice
* notebook". A few dozen, searchable by what they are for, is the whole job.
*
* The words are what make it usable: nobody hunts a pictogram grid by shape.
* They are also why the list is written out rather than generated.
*
* One component, used by both dialogs that offer a mark. Two grids would drift.
*/
export const ICONS = [
{ char: "📄", words: "note paper document default" },
{ char: "📝", words: "note write draft memo" },
{ char: "📌", words: "pin important stick" },
{ char: "⭐", words: "star favourite important" },
{ char: "❗", words: "important urgent attention" },
{ char: "✅", words: "done finished checked task" },
{ char: "🕒", words: "later waiting time pending" },
{ char: "💡", words: "idea thought concept" },
{ char: "🔧", words: "tool fix repair maintenance" },
{ char: "🔨", words: "build work project diy" },
{ char: "🏠", words: "house home flat property" },
{ char: "🔌", words: "power electricity socket energy" },
{ char: "💧", words: "water plumbing bill" },
{ char: "🔥", words: "heating gas fire boiler" },
{ char: "🚗", words: "car vehicle travel garage" },
{ char: "🧾", words: "receipt invoice bill accounting" },
{ char: "💶", words: "money euro payment bank finance" },
{ char: "🏦", words: "bank account finance" },
{ char: "📊", words: "chart numbers report statistics" },
{ char: "📅", words: "calendar date appointment plan" },
{ char: "✈️", words: "travel holiday flight trip" },
{ char: "🍳", words: "cooking recipe kitchen food" },
{ char: "🛒", words: "shopping list groceries" },
{ char: "🎁", words: "gift present birthday" },
{ char: "🎵", words: "music song audio" },
{ char: "📷", words: "photo picture camera" },
{ char: "🎬", words: "film video movie watch" },
{ char: "📚", words: "book reading study library" },
{ char: "🎓", words: "school study course learning" },
{ char: "💼", words: "work job office business" },
{ char: "🖥️", words: "computer server it hardware" },
{ char: "🌐", words: "web internet network domain" },
{ char: "🔒", words: "password secret secure private" },
{ char: "🐳", words: "docker container server" },
{ char: "🧠", words: "thinking notes knowledge brain" },
{ char: "❤️", words: "love personal family" },
{ char: "🐾", words: "pet animal dog cat" },
{ char: "🌱", words: "garden plant growing nature" },
{ char: "⚽", words: "sport football hobby" },
{ char: "🏃", words: "running fitness health sport" },
{ char: "💊", words: "health medicine doctor" },
{ char: "🩺", words: "doctor health medical appointment" },
{ char: "📦", words: "package delivery order storage" },
{ char: "🗂️", words: "files archive folder sorting" },
];
export function IconChooser({ T, value, onPick, height = 200 }) {
const [query, setQuery] = useState("");
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ICONS;
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
}, [query]);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search icons"
placeholder="Search — invoice, house, travel…"
style={{
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13,
outline: "none",
boxSizing: "border-box",
}}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(40px, 1fr))",
gap: 6,
marginTop: 10,
maxHeight: height,
overflowY: "auto",
}}
>
{/* "None" belongs in the grid rather than off in a corner: taking a mark
off is the same kind of choice as putting one on. */}
<button
type="button"
data-icon-option="none"
title="No icon"
aria-label="No icon"
onClick={() => onPick(null)}
style={{
height: 40,
borderRadius: 10,
cursor: "pointer",
fontSize: 13,
lineHeight: 1,
color: T.muted,
border: !value ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: !value ? `${T.accent}22` : "transparent",
}}
>
—
</button>
{shown.map((icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => onPick(icon.char)}
style={{
height: 40,
borderRadius: 10,
cursor: "pointer",
fontSize: 20,
lineHeight: 1,
border: value === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: value === icon.char ? `${T.accent}22` : "transparent",
}}
>
{icon.char}
</button>
))}
{shown.length === 0 && (
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
Nothing by that name.
</div>
)}
</div>
</>
);
}
-181
View File
@@ -1,181 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useBackdropClose } from "./backdrop";
/**
* Asking for one name.
*
* A real dialog rather than `window.prompt`, for the same reasons the link
* dialog is one: the browser's box cannot be styled, cannot be reached by the
* tests, and on a phone it arrives as a system alert with the app greyed out
* behind it.
*
* Deliberately small. Naming a chapter is one field and two buttons, and
* anything more would be a form.
*
* The one exception is the colour, offered when `colors` is given: it belongs to
* the same act. Making a chapter and then having to right-click it to say which
* one it is meant two steps for one decision.
*/
export function NameDialog({
title,
value = "",
label = "Name",
confirmLabel = "Save",
colors,
color: initialColor = null,
onSave,
onClose,
dark,
}) {
const [name, setName] = useState(value);
const [color, setColor] = useState(initialColor);
const backdrop = useBackdropClose(onClose);
const inputRef = useRef(null);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const ready = !!name.trim();
const save = () => {
if (!ready) return;
onSave(name.trim(), color);
onClose();
};
return (
<div
data-name-dialog
{...backdrop}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 380,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 22,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
aria-label={label}
placeholder={label}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") onClose();
}}
style={{
width: "100%",
padding: "10px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 14,
outline: "none",
boxSizing: "border-box",
}}
/>
{colors?.length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, marginBottom: 8 }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{[{ label: "None", value: null }, ...colors].map((c) => {
const picked = (c.value ?? null) === (color ?? null);
return (
<button
key={c.label}
type="button"
onClick={() => setColor(c.value ?? null)}
title={c.label}
aria-label={c.label}
aria-pressed={picked}
data-colour-option={c.value ?? "none"}
style={{
width: 28,
height: 28,
borderRadius: 9,
cursor: "pointer",
// The ring, not a tick: a tick over a dark swatch is
// unreadable and over a light one it is the only thing seen.
border: picked ? `2px solid ${T.text}` : `1px solid ${T.border}`,
background: c.value ?? "transparent",
color: T.muted,
fontSize: 11,
lineHeight: 1,
}}
>
{c.value ? "" : "—"}
</button>
);
})}
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
<button
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
<button
onClick={save}
disabled={!ready}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: ready ? T.accent : T.border,
color: "#fff",
cursor: ready ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CHAPTER_COLORS, SEPARATOR_STYLES } from "./constants.jsx";
import { Rule } from "./separatorStyle.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Everything about one line, in one place.
*
* A separator used to be edited from three entries in its own menu — rename,
* colour, line — each a different shape of interaction, and none of them showing
* what the result would look like. Choosing "dotted" from a list of words is a
* guess; the difference between dotted and dashed at this size is not something
* a word settles.
*
* So the dialog draws the thing itself, twice over: once at the top as it will
* appear in the list, and once beside each choice of line.
*/
export function SeparatorDialog({ separator, onSave, onClose, dark }) {
const [label, setLabel] = useState(separator.label || "");
const [colour, setColour] = useState(separator.color ?? null);
const [style, setStyle] = useState(separator.style || "solid");
const inputRef = useRef(null);
const backdrop = useBackdropClose(onClose);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
faint: dark ? "#484f58" : "#afb8c1",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
// What the list will actually draw: no colour means the list's own faint grey.
const drawn = colour || T.faint;
const save = () => {
onSave({ label: label.trim(), color: colour, style });
onClose();
};
return (
<div
data-separator-dialog
{...backdrop}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
style={{
width: "100%",
maxWidth: 400,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 20,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>Separator</h3>
{/* The line as the list will draw it, changing as the choices change. */}
<div
data-separator-preview
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "10px 8px",
borderRadius: 10,
background: T.field,
marginBottom: 16,
}}
>
<Rule colour={drawn} style={style} />
<div
style={{
flex: "0 1 auto",
maxWidth: 170,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
color: label ? colour || T.text : T.muted,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
minWidth: 54,
}}
>
{label || "Unnamed"}
</div>
<Rule colour={drawn} style={style} />
</div>
<input
ref={inputRef}
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") onClose();
}}
aria-label="Separator name"
placeholder="Name — may be left blank"
style={{
width: "100%",
padding: "10px 13px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13.5,
outline: "none",
boxSizing: "border-box",
}}
/>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Line</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{SEPARATOR_STYLES.map((s) => (
<button
key={s.value}
type="button"
data-line-option={s.value}
aria-pressed={style === s.value}
onClick={() => setStyle(s.value)}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "8px 10px",
borderRadius: 9,
cursor: "pointer",
border: style === s.value ? `1px solid ${T.accent}` : `1px solid transparent`,
background: style === s.value ? `${T.accent}18` : "transparent",
color: style === s.value ? T.text : T.muted,
fontSize: 12,
fontWeight: 600,
textAlign: "left",
}}
>
<span style={{ width: 62, flexShrink: 0 }}>{s.label}</span>
{/* Drawn with the very rule the list uses, in the colour chosen —
a sample that could disagree with the result is worse than none. */}
<span style={{ display: "flex", alignItems: "center", flex: 1 }}>
<Rule colour={drawn} style={s.value} />
</span>
</button>
))}
</div>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
const on = (c.value ?? null) === (colour ?? null);
return (
<button
key={c.label}
type="button"
data-colour-option={c.value ?? "none"}
title={c.label}
aria-label={c.label}
onClick={() => setColour(c.value ?? null)}
style={{
width: 26,
height: 26,
borderRadius: 8,
cursor: "pointer",
border: on ? `2px solid ${T.text}` : `1px solid ${T.border}`,
background: c.value ?? "transparent",
color: T.muted,
fontSize: 10,
lineHeight: 1,
}}
>
{c.value ? "" : "—"}
</button>
);
})}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 20 }}>
<button
type="button"
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
<button
type="button"
data-separator-save
onClick={save}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
}}
>
Save
</button>
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* How a separator's rule is drawn.
*
* Its own module because two places draw it and they must not drift: the list,
* where the line actually lives, and the dialog that offers the choice. A
* preview built from a second copy of these rules would eventually show
* something the list does not draw, which is worse than no preview at all.
*
* Built as a border rather than a background, because that is the only way to
* get dashes and dots without an image — and `double` needs the height to carry
* two lines and the gap between them, or the browser draws one.
*/
export const RULE_STYLES = {
solid: { height: 2, borderRadius: 2, solid: true },
dashed: { height: 0, borderTop: "2px dashed" },
dotted: { height: 0, borderTop: "3px dotted" },
double: { height: 0, borderTop: "4px double" },
thick: { height: 4, borderRadius: 2, solid: true },
};
/** One half of the line, left or right of the label. */
export function Rule({ colour, style, width }) {
const shape = RULE_STYLES[style] || RULE_STYLES.solid;
return (
<div
data-separator-rule={style || "solid"}
style={{
flex: width ? undefined : 1,
width,
minWidth: width ? undefined : 8,
height: shape.height,
borderRadius: shape.borderRadius,
...(shape.solid ? { background: colour } : { borderTop: `${shape.borderTop} ${colour}` }),
}}
/>
);
}
+25 -18
View File
@@ -439,8 +439,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 60 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 46 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging |
| `organise` | 64 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 51 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging |
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
| `tables` | 8 | a long table is broken between its rows, with none left lying across the gap, and no page furniture reaches the stored note |
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
@@ -1010,12 +1010,15 @@ Three rules that keep the two levels honest:
the others back untouched.
A note made while a chapter is open is filed there without being moved. A chapter
is **named and coloured when it is created**, unlike a separator: a separator is a
line and reads perfectly well unnamed, while an unnamed chapter is a row nobody
can tell from the next one. The colour is in the same dialog because it is the
same decision — the point of having chapters is that they are different places,
and eight named colours tell them apart where twenty near-identical blues would
not. Both can be changed later from the chapter's own menu.
is **named, marked and coloured in one dialog** — the same dialog whether it is
being made or changed, with the row drawn at the top as the list will draw it.
Asked one at a time from three menu entries, getting a new chapter to look right
took three visits. Its menu therefore holds one *Edit* and one *Remove*.
**A row is put into a chapter by dragging it onto it.** While rearranging is on,
a chapter row takes what is dropped on it and lights up to say so, rather than
growing a drop line above it: a chapter is a place, not a neighbour. Notes and
separators both go in this way.
A chapter shows even when the notebook holds nothing else. "Nothing here" is
asked of every row, not of the notes alone — asked of the notes, a notebook whose
@@ -1113,8 +1116,9 @@ name — chosen from a short searchable list rather than the system emoji keyboa
which is a different thing on every machine and offers three thousand pictures
for a decision that is "which of these is the invoice notebook". The list is
searched by what a thing is *for* ("invoice", "house", "travel"), because nobody
hunts a pictogram grid by shape. The same glyph shows in the list and above the
editor, so a note is recognised by one sign in both places. A note's mark takes a
hunts a pictogram grid by shape. The same glyph shows in the list, above the
editor **and in every link pointing at the note**, so it is recognised by one sign
everywhere. A note's mark takes a
colour too; a chapter's uses the colour the chapter already has.
**Separators** are lines drawn between notes, optionally named, stored on the
@@ -1127,14 +1131,17 @@ divider that cannot move with what it divides is worse than none — and their
position is recorded as "how many notes come before me", so they stay put as the
notes around them are renamed, edited or opened.
One press draws the line; it is named afterwards from its own menu, the same way
it is renamed. A line reads perfectly well unnamed, so nothing is asked first.
From that menu it also takes **its own colour and one of five shapes** — solid,
dashed, dotted, double or a thick band. Three lines through one notebook are
easier to tell apart when they differ; grey-on-grey they read as the same line
repeated. **A chapter can be divided the same way**: its lines carry its id and
stay in it, and both places a chapter is shown draw them from one component, so
it cannot be divisible in the column and not in the unfolded block.
One press draws the line. Everything about it — name, colour, and one of five
shapes — is then set in **one dialog** reached by right-click, which draws the
line as it will look and draws each shape beside its name. It used to be three
menu entries and an input inside the row: three shapes of interaction for one
small thing, none of them showing the result. The preview is built from the very
rules the list draws with, because a sample that can disagree with the result is
worse than none.
**A chapter can be divided the same way**, and a line is put into one by dragging
it there — there is no second "+ Separator" inside a chapter, because a second
way to make the same thing in a second place is a second thing to keep straight.
They are shown only in the manual order: in "newest first" the notes would slide
past the line on every edit — and **the button that draws one is only there in