Move a note in one press, and stop Draw inventing a page

Moving lived under a right-click, behind a submenu: two steps before the
list of places appeared at all, and on a phone a long press followed by a
flyout. There is a button on the row now, and it opens one screen with
the chapters of this notebook and the other notebooks together, marking
where the note already is.

The spare sheet in Draw mode was drawn as solid paper, so pressing Draw
on a one-page note made a second page appear from nowhere while the count
still said "of 1". It exists for a good reason — pages otherwise come
only from how far the writing runs, so drawing on page two would mean
writing two pages first — but it should look like the offer it is. Dashed
outline, half opacity, and it says what it is for. It counts once
something is on it.

And the two buttons above the list did not match: the one I added last
round was larger and bolder than the one it sat beside, and the older
one's target was nineteen pixels tall, which a finger misses. Both are
one shape now. A separator's label matches a chapter's name in size for
the same reason — two type treatments in one list is noise, and the
difference was not saying anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 13:43:51 +02:00
1 parent 9d5ac1db27
commit 632ea5dcd9
7 files changed
+329 -29

No files matched your search

+21
View File
@@ -8,6 +8,27 @@ not from the output of `docker push`.
---
## Unreleased
**Added**
- **A move button on every note row.** Moving used to be a right-click into a
submenu — two steps before the list of places appeared, and on a phone a long
press followed by a flyout. One press now opens a dialog with the chapters of
this notebook and the other notebooks on one screen, marking where the note
is now.
**Changed**
- **The spare page in Draw mode reads as an offer**, not as a page. A second
sheet appeared the moment Draw was pressed, drawn as solid paper, while the
count still said "of 1" — which looks like the app inventing a page. It is a
dashed outline saying "Draw here to add a page", and it counts only once
something is on it.
- *+ Chapter* and *+ Separator* are the same shape. The new one had been built
without matching the old, and the old one's 19px target was one a finger
misses. Separator labels also match a chapter's name in size now.
---
## 2026-08-12 — Chapters
**Images** — web `sha256:790ff73a…`, server `sha256:181d77be…`
+12 -5
View File
@@ -31,12 +31,14 @@ try {
await p.getByRole("button", { name: "Create", exact: true }).click();
await p.waitForTimeout(2000);
};
/* One press of the button on the row, then choose. It used to be a
right-click into a submenu — two steps before the places even appeared, and
on a phone a long press followed by a flyout. */
const moveTo = async (noteTitle, target) => {
await p.locator("[data-note-row]").filter({ hasText: noteTitle }).first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: target }).first().click();
const row = p.locator("[data-note-row]").filter({ hasText: noteTitle }).first();
await row.locator("xpath=preceding-sibling::button[@data-move-note]").first().click();
await p.waitForTimeout(700);
await p.locator("[data-move-target]").filter({ hasText: target }).first().click();
await p.waitForTimeout(2500);
};
@@ -59,6 +61,11 @@ try {
check(server.length === 1 && server[0].name === "Rechnungen", "the server has it", JSON.stringify(server));
/* ── filing a note ── */
check(
(await p.locator("[data-move-note]").count()) === 2,
"every note has a move button, without a right-click",
String(await p.locator("[data-move-note]").count())
);
await moveTo("Stromrechnung", "Rechnungen");
check(
(await titles()).join(",") === "Zählerstand",
+13
View File
@@ -149,6 +149,19 @@ try {
(await p.locator('canvas[data-draw-page="2"]').count()) === 1,
"there is a blank page past the end to draw on, before anything is written"
);
/* It has to read as an offer, not as a page that appeared by itself. Drawn
solid, a second sheet turned up the moment Draw was pressed while the count
still said "of 1", which looks like the app inventing a page. */
check(
(await p.locator("[data-spare-page]").count()) === 1,
"and it is marked as the spare rather than drawn as paper",
String(await p.locator("[data-spare-page]").count())
);
check(
(await p.locator("[data-note-position]").innerText()).includes("of 1"),
"so the count still says one page until something is on it",
await p.locator("[data-note-position]").innerText()
);
await draw(p, 1);
const first = await inkGrid(p, 1);
+14
View File
@@ -13,6 +13,7 @@ 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 { MoveNoteDialog } from "./ui/MoveNoteDialog.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
import { AccountMenu } from "./ui/AccountMenu.jsx";
@@ -90,6 +91,8 @@ export default function App() {
const [ctxMenu, setCtxMenu] = useState(null);
// { title, value, onSave } while a chapter is being named or renamed.
const [namingChapter, setNamingChapter] = useState(null);
// The note whose destination is being chosen, if any.
const [movingNote, setMovingNote] = 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);
@@ -776,6 +779,7 @@ export default function App() {
onOpenChapter={setActiveChapter}
onAddChapter={activeNotebook ? addChapter : null}
onChapterContextMenu={chapterContextMenu}
onMoveNote={setMovingNote}
onReorder={rearrangeNotes}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
@@ -900,6 +904,16 @@ export default function App() {
)}
/>
)}
{movingNote && (
<MoveNoteDialog
note={movingNote}
notebooks={notebooks}
chapters={data.chapters}
onMove={(id, folderId, chapterId) => data.moveNote(id, folderId, chapterId)}
onClose={() => setMovingNote(null)}
dark={dark}
/>
)}
{namingChapter && (
<NameDialog
title={namingChapter.title}
+49 -19
View File
@@ -799,25 +799,55 @@ export function NoteEditor({
something instead of floating in the middle of an empty pane.
Behind the text and deaf to the pointer: it is what is written
on, not part of what is written. */}
{Array.from({ length: canvasPages }, (_, i) => (
<div
key={i}
aria-hidden
data-page-sheet={i + 1}
style={{
position: "absolute",
left: 0,
right: 0,
top: i * pitch,
height: stageSize.h,
pointerEvents: "none",
background: T.bg,
border: isMobileViewport ? "none" : `1px solid ${T.border}`,
borderRadius: isMobileViewport ? 0 : 4,
boxShadow: isMobileViewport ? "none" : "0 2px 10px rgba(0,0,0,0.22)",
}}
/>
))}
{Array.from({ length: canvasPages }, (_, i) => {
/* The last sheet in Draw mode is the spare one, not a page.
Drawn as an outline rather than as paper: it appeared as a full
second page the moment Draw was pressed, while the count still
said "of 1" — which reads as the app inventing a page. It is an
offer, and should look like one until something is on it. */
const spare = mode === "draw" && i + 1 > pages.count;
return (
<div
key={i}
aria-hidden
data-page-sheet={i + 1}
data-spare-page={spare ? "yes" : undefined}
style={{
position: "absolute",
left: 0,
right: 0,
top: i * pitch,
height: stageSize.h,
pointerEvents: "none",
background: spare ? "transparent" : T.bg,
border: isMobileViewport && !spare ? "none" : `1px dashed ${T.border}`,
borderStyle: spare ? "dashed" : "solid",
borderWidth: isMobileViewport && !spare ? 0 : 1,
borderColor: T.border,
borderRadius: isMobileViewport && !spare ? 0 : 4,
boxShadow: isMobileViewport || spare ? "none" : "0 2px 10px rgba(0,0,0,0.22)",
opacity: spare ? 0.5 : 1,
}}
>
{spare && (
<div
style={{
position: "absolute",
top: 12,
left: 0,
right: 0,
textAlign: "center",
fontSize: 11,
letterSpacing: 0.5,
color: T.faint,
}}
>
Draw here to add a page
</div>
)}
</div>
);
})}
<div
style={{
position: "relative",
+40 -5
View File
@@ -67,6 +67,36 @@ const Grip = ({ T, ...rest }) => (
</div>
);
/**
* Moving a note, in one press.
*
* It used to be a right-click into a submenu — two steps before the list of
* places appeared at all, and on a phone a long press followed by a flyout.
* Sitting on the row beside the grip, it says what it does and takes one tap.
*/
const MoveButton = ({ T, onClick }) => (
<button
data-move-note
onClick={onClick}
title="Move to another notebook or chapter"
aria-label="Move note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
flexShrink: 0,
border: "none",
background: "transparent",
color: T.faint,
cursor: "pointer",
padding: 0,
}}
>
{I.move}
</button>
);
/** Width the separator's label keeps in both states, so renaming moves nothing. */
const MIDDLE_W = 132;
@@ -136,7 +166,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
borderRadius: 6,
background: "transparent",
color: T.text,
fontSize: 11.5,
fontSize: 12.5,
fontWeight: 700,
letterSpacing: 0.8,
textAlign: "center",
@@ -154,7 +184,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
border: "1px solid transparent",
background: "transparent",
color: separator.label ? T.text : T.muted,
fontSize: 11.5,
fontSize: 12.5,
fontWeight: 700,
letterSpacing: 0.8,
textTransform: "uppercase",
@@ -423,6 +453,7 @@ export function NoteList({
onOpenChapter,
onAddChapter,
onChapterContextMenu,
onMoveNote,
onReorder,
onToggleSidebar,
onSelectNote,
@@ -592,7 +623,7 @@ export function NoteList({
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 10px",
padding: "5px 10px",
borderRadius: 8,
border: `1px solid ${T.border}`,
background: "transparent",
@@ -614,8 +645,9 @@ export function NoteList({
background: "transparent",
color: T.muted,
borderRadius: 8,
padding: "2px 8px",
fontSize: 10.5,
padding: "5px 10px",
fontSize: 11.5,
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
}}
@@ -720,6 +752,9 @@ export function NoteList({
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{reorderable && <Grip T={T} {...dnd.gripProps} />}
{onMoveNote && !note.trashed && (
<MoveButton T={T} onClick={() => onMoveNote(note)} />
)}
<button
data-note-row={note.id}
onClick={() => onSelectNote(note.id)}
+180
View File
@@ -0,0 +1,180 @@
import { useMemo } from "react";
import { I } from "./icons.jsx";
import { coverBackground } from "./constants.jsx";
/**
* Where a note should go.
*
* Moving used to live only under a right-click, behind a submenu — two steps
* before the list of places even appeared, and on a phone a long press followed
* by a flyout. This is the same list, reached by one press of a button on the
* row.
*
* Everything is on one screen: the chapters of the notebook the note is already
* in, and then the other notebooks. Sending it to another notebook takes it out
* of whatever chapter it was in, because a chapter belongs to the book it is in
* — which is worth saying here rather than leaving to be discovered.
*/
export function MoveNoteDialog({ note, notebooks, chapters, onMove, onClose, dark }) {
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
row: dark ? "#0d1117" : "#f6f8fa",
hover: dark ? "#21262d" : "#eaeef2",
}),
[dark]
);
const here = chapters.filter((c) => c.notebookId === note.folderId);
const others = notebooks.filter((nb) => nb.id !== note.folderId);
const go = (folderId, chapterId) => {
onMove(note.id, folderId, chapterId);
onClose();
};
const Row = ({ children, onClick, disabled, dot, icon }) => (
<button
onClick={onClick}
disabled={disabled}
data-move-target
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "11px 12px",
borderRadius: 10,
border: "none",
background: disabled ? T.row : "transparent",
color: disabled ? T.muted : T.text,
cursor: disabled ? "default" : "pointer",
fontSize: 13,
textAlign: "left",
}}
onMouseEnter={(e) => !disabled && (e.currentTarget.style.background = T.hover)}
onMouseLeave={(e) => !disabled && (e.currentTarget.style.background = "transparent")}
>
{dot && <div style={{ width: 12, height: 16, borderRadius: 3, background: dot, flexShrink: 0 }} />}
{icon && <span style={{ display: "flex", opacity: 0.7, flexShrink: 0 }}>{icon}</span>}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{children}</span>
{disabled && <span style={{ marginLeft: "auto", fontSize: 11, flexShrink: 0 }}>here now</span>}
</button>
);
const heading = (text) => (
<div
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
color: T.muted,
margin: "12px 12px 4px",
}}
>
{text}
</div>
);
return (
<div
data-move-dialog
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 400,
maxHeight: "80vh",
display: "flex",
flexDirection: "column",
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: "20px 10px 12px",
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 12px 4px", fontSize: 16, fontWeight: 700, color: T.text }}>Move note</h3>
<div
style={{
margin: "0 12px 8px",
fontSize: 12,
color: T.muted,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{note.title || "Untitled"}
</div>
<div style={{ overflowY: "auto", flex: 1 }}>
{here.length > 0 && heading("In this notebook")}
{here.length > 0 && (
<Row icon={I.note} disabled={!note.chapterId} onClick={() => go(note.folderId, null)}>
The notebook itself
</Row>
)}
{here.map((c) => (
<Row
key={c.id}
icon={I.books}
disabled={note.chapterId === c.id}
onClick={() => go(note.folderId, c.id)}
>
{c.name}
</Row>
))}
{others.length > 0 && heading("Another notebook")}
{others.map((nb) => (
<Row key={nb.id} dot={coverBackground(nb.cover, nb.coverPattern)} onClick={() => go(nb.id, null)}>
{nb.name}
</Row>
))}
{here.length === 0 && others.length === 0 && (
<div style={{ padding: "18px 12px", fontSize: 12.5, color: T.muted }}>
There is nowhere else to put it yet — make another notebook, or a chapter in this one.
</div>
)}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", padding: "10px 12px 0" }}>
<button
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
</div>
</div>
</div>
);
}