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