From d68b0775c2612272fd996d19cf5dfed257c8f0ae Mon Sep 17 00:00:00 2001 From: marc Date: Mon, 17 Aug 2026 00:23:26 +0200 Subject: [PATCH] One dialog per thing, and rows dragged into a chapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 33 +++ e2e/chapters.mjs | 202 +++++++++++++---- e2e/organise.mjs | 63 +++--- frontend/src/App.jsx | 188 +++++++--------- frontend/src/editor/editor.css | 14 +- frontend/src/editor/noteLink.js | 20 +- frontend/src/editor/useNoteEditor.jsx | 15 +- frontend/src/features/notes/EditorPane.jsx | 96 +++++---- frontend/src/features/notes/NoteEditor.jsx | 2 + frontend/src/features/notes/NoteList.jsx | 193 ++++------------- frontend/src/features/pdf/PdfWorkspace.jsx | 2 + frontend/src/hooks/useNoteData.ts | 11 +- frontend/src/hooks/useReorderDrag.ts | 53 ++++- frontend/src/syncEngine.ts | 18 +- frontend/src/ui/ChapterDialog.jsx | 218 +++++++++++++++++++ frontend/src/ui/IconChooser.jsx | 151 +++++++++++++ frontend/src/ui/NameDialog.jsx | 181 ---------------- frontend/src/ui/SeparatorDialog.jsx | 239 +++++++++++++++++++++ frontend/src/ui/separatorStyle.jsx | 37 ++++ readme.md | 43 ++-- 20 files changed, 1185 insertions(+), 594 deletions(-) create mode 100644 frontend/src/ui/ChapterDialog.jsx create mode 100644 frontend/src/ui/IconChooser.jsx delete mode 100644 frontend/src/ui/NameDialog.jsx create mode 100644 frontend/src/ui/SeparatorDialog.jsx create mode 100644 frontend/src/ui/separatorStyle.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c9cf061..5561d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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…` diff --git a/e2e/chapters.mjs b/e2e/chapters.mjs index 8cb9f70..d1e6580 100644 --- a/e2e/chapters.mjs +++ b/e2e/chapters.mjs @@ -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])); diff --git a/e2e/organise.mjs b/e2e/organise.mjs index ac79394..21b8a07 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -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( diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 97dcb09..7d1e292 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 && ( - setNaming(null)} + {editingChapter && ( + + editingChapter.id + ? data.editChapter(editingChapter.id, fields) + : data.createChapter(activeNotebook.id, fields.name, fields.color, fields.icon) + } + onClose={() => setEditingChapter(null)} + dark={dark} + /> + )} + {editingSeparator && ( + editSeparator(editingSeparator.id, changes)} + onClose={() => setEditingSeparator(null)} dark={dark} /> )} diff --git a/frontend/src/editor/editor.css b/frontend/src/editor/editor.css index da406dd..9aa920f 100644 --- a/frontend/src/editor/editor.css +++ b/frontend/src/editor/editor.css @@ -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 { diff --git a/frontend/src/editor/noteLink.js b/frontend/src/editor/noteLink.js index c52967b..fe86af4 100644 --- a/frontend/src/editor/noteLink.js +++ b/frontend/src/editor/noteLink.js @@ -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 ) => `` ) diff --git a/frontend/src/editor/useNoteEditor.jsx b/frontend/src/editor/useNoteEditor.jsx index 07c9b04..f6cad9b 100644 --- a/frontend/src/editor/useNoteEditor.jsx +++ b/frontend/src/editor/useNoteEditor.jsx @@ -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) => { diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx index d25203c..d572537 100644 --- a/frontend/src/features/notes/EditorPane.jsx +++ b/frontend/src/features/notes/EditorPane.jsx @@ -193,48 +193,60 @@ export function EditorPane({ }} > {isMobile && } - {/* 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. */} - - 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. */} +
+ + 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, + }} + /> +
diff --git a/frontend/src/features/notes/NoteEditor.jsx b/frontend/src/features/notes/NoteEditor.jsx index ac3096d..661943f 100644 --- a/frontend/src/features/notes/NoteEditor.jsx +++ b/frontend/src/features/notes/NoteEditor.jsx @@ -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} /> {/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */} diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 1768a0e..65ec8ec 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -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 ( -
- ); -} - /** * 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 (
{/* 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. */}
- {editing ? ( - 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", - }} - /> - ) : ( -
- {separator.label || "Unnamed"} -
- )} + {separator.label || "Unnamed"}
@@ -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({ ) )} - {/* 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 && ( - - )} + {/* 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 ( -
+
{showDropLine && }
{moving && } @@ -994,9 +894,6 @@ export function NoteList({ grip={moving ? : null} T={T} separator={entry.separator} - editing={renamingSeparator === entry.id} - onStartRename={onStartRenameSeparator} - onRename={onRenameSeparator} onContextMenu={onSeparatorContextMenu} />
diff --git a/frontend/src/features/pdf/PdfWorkspace.jsx b/frontend/src/features/pdf/PdfWorkspace.jsx index 0e52da2..6d3f0a9 100644 --- a/frontend/src/features/pdf/PdfWorkspace.jsx +++ b/frontend/src/features/pdf/PdfWorkspace.jsx @@ -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} diff --git a/frontend/src/hooks/useNoteData.ts b/frontend/src/hooks/useNoteData.ts index 5a804d9..ab43770 100644 --- a/frontend/src/hooks/useNoteData.ts +++ b/frontend/src/hooks/useNoteData.ts @@ -54,7 +54,12 @@ export interface NoteData { createNotebook: (fields: { name: string; cover: number }) => Promise; updateNotebook: (id: string, updates: Partial) => Promise; chapters: Chapter[]; - createChapter: (notebookId: string, name: string, color?: string | null) => Promise; + createChapter: ( + notebookId: string, + name: string, + color?: string | null, + icon?: string | null + ) => Promise; /** Name, colour, or both — one write for one decision. */ editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise; deleteChapter: (id: string) => Promise; @@ -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; }, diff --git a/frontend/src/hooks/useReorderDrag.ts b/frontend/src/hooks/useReorderDrag.ts index e2f8f0d..11574c7 100644 --- a/frontend/src/hooks/useReorderDrag.ts +++ b/frontend/src/hooks/useReorderDrag.ts @@ -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(null); + const [intoId, setIntoId] = useState(null); const [draggingId, setDraggingId] = useState(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(null); + const intoRef = useRef(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": "" }, }; } diff --git a/frontend/src/syncEngine.ts b/frontend/src/syncEngine.ts index 5fc1e49..9fa3c93 100644 --- a/frontend/src/syncEngine.ts +++ b/frontend/src/syncEngine.ts @@ -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 { 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 { 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 { export async function createChapter( notebookId: string, name: string, - color: string | null = null + color: string | null = null, + icon: string | null = null ): Promise { 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; diff --git a/frontend/src/ui/ChapterDialog.jsx b/frontend/src/ui/ChapterDialog.jsx new file mode 100644 index 0000000..783d9eb --- /dev/null +++ b/frontend/src/ui/ChapterDialog.jsx @@ -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 ( +
+
+

+ {existing ? "Chapter" : "New chapter"} +

+ + {/* The row as the list will draw it, changing as the choices change. */} +
+ + {icon || I.books} + + + {name || "Unnamed chapter"} + +
+ + 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", + }} + /> + +
Colour
+
+ {[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => { + const on = (c.value ?? null) === (colour ?? null); + return ( + + ); + })} +
+ +
Icon
+ + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/ui/IconChooser.jsx b/frontend/src/ui/IconChooser.jsx new file mode 100644 index 0000000..5bf6a3f --- /dev/null +++ b/frontend/src/ui/IconChooser.jsx @@ -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 ( + <> + 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", + }} + /> +
+ {/* "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. */} + + {shown.map((icon) => ( + + ))} + {shown.length === 0 && ( +
+ Nothing by that name. +
+ )} +
+ + ); +} diff --git a/frontend/src/ui/NameDialog.jsx b/frontend/src/ui/NameDialog.jsx deleted file mode 100644 index 9da4232..0000000 --- a/frontend/src/ui/NameDialog.jsx +++ /dev/null @@ -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 ( -
-
e.stopPropagation()} - style={{ - width: "100%", - maxWidth: 380, - background: T.bg, - border: `1px solid ${T.border}`, - borderRadius: 16, - padding: 22, - fontFamily: "'DM Sans',sans-serif", - }} - > -

{title}

- 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 && ( -
-
Colour
-
- {[{ label: "None", value: null }, ...colors].map((c) => { - const picked = (c.value ?? null) === (color ?? null); - return ( - - ); - })} -
-
- )} -
- - -
-
-
- ); -} diff --git a/frontend/src/ui/SeparatorDialog.jsx b/frontend/src/ui/SeparatorDialog.jsx new file mode 100644 index 0000000..ed3ebe7 --- /dev/null +++ b/frontend/src/ui/SeparatorDialog.jsx @@ -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 ( +
+
+

Separator

+ + {/* The line as the list will draw it, changing as the choices change. */} +
+ +
+ {label || "Unnamed"} +
+ +
+ + 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", + }} + /> + +
Line
+
+ {SEPARATOR_STYLES.map((s) => ( + + ))} +
+ +
Colour
+
+ {[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => { + const on = (c.value ?? null) === (colour ?? null); + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/ui/separatorStyle.jsx b/frontend/src/ui/separatorStyle.jsx new file mode 100644 index 0000000..df0d844 --- /dev/null +++ b/frontend/src/ui/separatorStyle.jsx @@ -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 ( +
+ ); +} diff --git a/readme.md b/readme.md index f94a3b7..b797974 100644 --- a/readme.md +++ b/readme.md @@ -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