Group the two visibility switches, and put the drawing tools back where they belong

"Zeichnung" and "Randnotizen" are two halves of one question — how much of your
own work do you want to see right now — and they sat at opposite ends of the
toolbar, each beside the tool that made them. They are now one pair in one shape
at the right-hand end. Each still appears only once there is something of its
kind to hide, so the pair can be lopsided, which is honest.

The drawing tools had drifted past the pin controls as those were added, so
switching to Zeichnen made them appear somewhere other than where the eye
already was. They follow the button that turns them on again.

A separator no longer carries a ×. It sat a few pixels from the label you click
to rename, putting the gesture that tidies the list next to the one that destroys
part of it — so removal is a right-click behind a confirmation that says the
notes are not affected, which is how everything else in that list is acted on.

218 unit tests, 237 browser checks across eleven suites. The toolbar checks
compare measured positions rather than presence, since the complaint was about
where things were, not whether they existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-11 01:24:25 +02:00
1 parent 4c1b632029
commit 0a7cf26b28
6 files changed
+208 -104

No files matched your search

+21 -3
View File
@@ -187,9 +187,27 @@ try {
await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
await p.locator("[data-separator] button[aria-label=\"Remove separator\"]").click();
await p.waitForTimeout(1800);
check((await p.locator("[data-separator]").count()) === 0, "a separator can be removed again");
/* Removing is a right-click behind a confirmation. The × used to sit a few
pixels from the label you click to rename, so tidying the list and
destroying part of it were neighbouring gestures. */
check(
(await p.locator('[data-separator] button[aria-label="Remove separator"]').count()) === 0,
"there is no delete button sitting next to the rename target"
);
await p.locator("[data-separator]").click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Trenner entfernen").click();
await p.waitForTimeout(600);
check(
/Notizen bleiben/.test(await p.locator("body").innerText()),
"right-clicking it asks first, and says the notes are not affected"
);
await p.getByRole("button", { name: "Entfernen" }).click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 0, "and then it is gone");
check((await titles()).length === 3, "with every note still there", (await titles()).join(", "));
/* ── exporting a note and importing it back ── */
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click();
+59 -1
View File
@@ -704,7 +704,65 @@ try {
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* ── showing and hiding them ── */
/* ── the two visibility switches, together at the end of the bar ── */
// Drawings and margin notes are two halves of one question — how much of your
// own work do you want to see — and they used to sit at opposite ends of the
// toolbar, each beside the tool that made them.
// Each switch appears only when there is something of its kind to hide, so a
// stroke goes back on the page first — the pair is the point here.
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(600);
const inkPage = await p.locator('[data-pdf-page="1"]').boundingBox();
await p.mouse.move(inkPage.x + 60, inkPage.y + 300);
await p.mouse.down();
await p.mouse.move(inkPage.x + 160, inkPage.y + 330, { steps: 8 });
await p.mouse.up();
await p.waitForTimeout(2000);
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(700);
const barOrder = await p.evaluate(() => {
const bar = document.querySelector("[data-pdf-mode]")?.parentElement;
if (!bar) return null;
const at = (sel) => {
const el = document.querySelector(sel);
return el ? el.getBoundingClientRect().left : null;
};
return {
draw: at("[data-pdf-mode]"),
pin: at("[data-pdf-pin-mode]"),
ink: at("[data-pdf-hide-ink]"),
pins: at("[data-pdf-show-pins]"),
barRight: bar.getBoundingClientRect().right,
};
});
check(
barOrder.ink > barOrder.pin && barOrder.pins > barOrder.ink,
"both switches sit past the tools, side by side",
JSON.stringify(barOrder)
);
check(
barOrder.barRight - barOrder.pins < 160,
"at the right-hand end of the bar",
`${Math.round(barOrder.barRight - barOrder.pins)}px from the edge`
);
/* The drawing tools belong to the button that turns them on. */
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(700);
const toolAt = await p.evaluate(() => ({
draw: document.querySelector("[data-pdf-mode]").getBoundingClientRect().right,
firstTool: document.querySelector('[data-pdf-tool="pen"]').getBoundingClientRect().left,
pin: document.querySelector("[data-pdf-pin-mode]").getBoundingClientRect().left,
}));
check(
toolAt.firstTool > toolAt.draw && toolAt.firstTool < toolAt.pin,
"and the drawing tools appear right beside Zeichnen, not past everything else",
JSON.stringify(toolAt)
);
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(700);
check((await p.locator("[data-pdf-show-pins]").count()) === 1, "the margin notes can be hidden");
await p.locator("[data-pdf-show-pins]").click();
await p.waitForTimeout(1200);
+26 -1
View File
@@ -202,6 +202,31 @@ export default function App() {
writeSeparators(separators.map((s) => (s.id === id ? { ...s, label } : s)));
const removeSeparator = (id) => writeSeparators(separators.filter((s) => s.id !== id));
const separatorContextMenu = (e, separator) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: e.clientX,
y: e.clientY,
items: [
{
label: "Trenner entfernen",
icon: I.trash,
danger: true,
action: () =>
confirm({
title: "Trenner entfernen",
message: separator.label
? `Trennlinie "${separator.label}" entfernen? Die Notizen bleiben.`
: "Diese Trennlinie entfernen? Die Notizen bleiben.",
confirmLabel: "Entfernen",
onConfirm: () => removeSeparator(separator.id),
}),
},
],
});
};
const isSearching = searchQuery.trim().length > 0;
const filteredNotes = useMemo(() => {
@@ -614,7 +639,7 @@ export default function App() {
separators={separators}
onAddSeparator={addSeparator}
onRenameSeparator={renameSeparator}
onRemoveSeparator={removeSeparator}
onSeparatorContextMenu={separatorContextMenu}
dnd={noteDnd}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
+9 -20
View File
@@ -43,8 +43,13 @@ const MIDDLE_W = 132;
*
* Draggable like a note, because a divider that cannot move with what it
* divides is worse than none.
*
* Removed by right-click rather than by a × on the row. The × sat a few pixels
* from the label you click to rename, so the gesture that tidies the list and
* the gesture that destroys part of it were neighbours — and right-click is
* already how everything else in this list is acted on.
*/
function SeparatorRow({ T, separator, onRename, onRemove }) {
function SeparatorRow({ T, separator, onRename, onContextMenu }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(separator.label);
@@ -56,6 +61,7 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
return (
<div
data-separator={separator.id}
onContextMenu={(e) => onContextMenu(e, separator)}
style={{
display: "flex",
alignItems: "center",
@@ -131,23 +137,6 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
)}
</div>
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
<button
onClick={() => onRemove(separator.id)}
title="Trenner entfernen"
aria-label="Remove separator"
style={{
border: "none",
background: "transparent",
color: T.faint,
cursor: "pointer",
fontSize: 13,
lineHeight: 1,
padding: "0 2px",
flexShrink: 0,
}}
>
×
</button>
</div>
);
}
@@ -249,7 +238,7 @@ export function NoteList({
separators,
onAddSeparator,
onRenameSeparator,
onRemoveSeparator,
onSeparatorContextMenu,
dnd,
onToggleSidebar,
onSelectNote,
@@ -489,7 +478,7 @@ export function NoteList({
T={T}
separator={entry.separator}
onRename={onRenameSeparator}
onRemove={onRemoveSeparator}
onContextMenu={onSeparatorContextMenu}
/>
</div>
);
+77 -73
View File
@@ -25,6 +25,29 @@ import { I } from "../../ui/icons.jsx";
import { PEN_SIZES, DEFAULT_PEN_SIZE, newSaveToken } from "../drawing/strokes.js";
import { FONT_UI } from "../../ui/theme";
/**
* The pair of switches for what is drawn on top of the document.
*
* One shape for both, because they are two halves of one question — how much of
* your own work do you want to see right now — and a pair that looked different
* would not read as a pair.
*/
const visibilityButton = (T, shown) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
height: 28,
padding: "0 10px",
borderRadius: 8,
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
border: `1px solid ${shown ? T.border : T.accent + "66"}`,
background: shown ? "transparent" : T.accent + "1a",
color: shown ? T.muted : T.accent,
});
/** Pages this far outside the viewport are drawn ahead of being scrolled to. */
const RENDER_MARGIN_PX = 1200;
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
@@ -589,53 +612,6 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
{I.pen} {drawing ? "Zeichnen" : "Lesen"}
</button>
{/* Reading the document as it was printed is a normal thing to want, and
so is having every remark in view. Both are one click away. */}
{totalPinnedNotes(ink) > 0 && (
<button
data-pdf-show-pins={showPins ? "on" : "off"}
onClick={() => {
setShowPins((v) => !v);
setOpenPin(null);
}}
title={showPins ? "Randnotizen ausblenden" : "Randnotizen einblenden"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
border: `1px solid ${showPins ? T.border : T.accent + "66"}`,
background: showPins ? "transparent" : T.accent + "1a",
color: showPins ? T.muted : T.accent,
fontWeight: 600,
}}
>
{showPins ? I.eye : I.eyeOff} Randnotizen
</button>
)}
{/* A note about a passage belongs on the passage. */}
<button
data-pdf-pin-mode={placing ? "on" : "off"}
onClick={() => {
setPlacing((v) => !v);
setDrawing(false);
setOpenPin(null);
}}
title={placing ? "Stelle anklicken — oder abbrechen" : "Notiz an eine Stelle heften"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
background: placing ? T.accent + "22" : "transparent",
color: placing ? T.accent : T.muted,
fontWeight: 600,
}}
>
{I.pin} {placing ? "Stelle wählen" : "Randnotiz"}
</button>
{drawing && (
<>
{["pen", "highlighter", "eraser"].map((t) => (
@@ -723,32 +699,27 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
</>
)}
{/* A labelled control, not a dot. Hiding the ink changes what the page
shows, so it has to be findable without being clicked to find out —
and its state has to be readable at a glance rather than from the
difference between a filled and a hollow circle.
Only shown once there is a drawing: until then it would offer to hide
nothing. */}
{totalStrokes(ink) > 0 && (
<button
data-pdf-hide-ink={hideInk ? "on" : "off"}
onClick={() => setHideInk((v) => !v)}
title={hideInk ? "Zeichnung wieder einblenden" : "Zeichnung ausblenden"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
border: `1px solid ${hideInk ? T.accent + "66" : T.border}`,
background: hideInk ? T.accent + "1a" : "transparent",
color: hideInk ? T.accent : T.muted,
fontWeight: 600,
fontSize: 12,
}}
>
{hideInk ? I.eyeOff : I.eye} {hideInk ? "Zeichnung aus" : "Zeichnung an"}
</button>
)}
{/* A note about a passage belongs on the passage. */}
<button
data-pdf-pin-mode={placing ? "on" : "off"}
onClick={() => {
setPlacing((v) => !v);
setDrawing(false);
setOpenPin(null);
}}
title={placing ? "Stelle anklicken — oder abbrechen" : "Notiz an eine Stelle heften"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
background: placing ? T.accent + "22" : "transparent",
color: placing ? T.accent : T.muted,
fontWeight: 600,
}}
>
{I.pin} {placing ? "Stelle wählen" : "Randnotiz"}
</button>
{notesToggle}
@@ -760,6 +731,39 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
<button onClick={() => stepZoom(1)} title="Größer" style={barButton}>
+
</button>
{/* What is shown on top of the document, kept together at the end of
the bar. Drawings and margin notes are two halves of one question —
"how much of my own work do I want to see right now" — and they used
to sit at opposite ends of the toolbar, each next to the tool that
made them. Each still appears only once there is something of that
kind to hide. */}
{(totalStrokes(ink) > 0 || totalPinnedNotes(ink) > 0) && (
<div style={{ width: 1, height: 18, background: T.border, margin: "0 2px" }} />
)}
{totalStrokes(ink) > 0 && (
<button
data-pdf-hide-ink={hideInk ? "on" : "off"}
onClick={() => setHideInk((v) => !v)}
title={hideInk ? "Zeichnung einblenden" : "Zeichnung ausblenden"}
style={visibilityButton(T, !hideInk)}
>
{hideInk ? I.eyeOff : I.eye} Zeichnung
</button>
)}
{totalPinnedNotes(ink) > 0 && (
<button
data-pdf-show-pins={showPins ? "on" : "off"}
onClick={() => {
setShowPins((v) => !v);
setOpenPin(null);
}}
title={showPins ? "Randnotizen ausblenden" : "Randnotizen einblenden"}
style={visibilityButton(T, showPins)}
>
{showPins ? I.eye : I.eyeOff} Randnotizen
</button>
)}
</div>
</div>
+16 -6
View File
@@ -332,11 +332,11 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
| `pdf` | 82 | import, page proportions, lazy loading, and reading with the network off |
| `pdf` | 85 | import, page proportions, lazy loading, and reading with the network off |
| `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` | 35 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `organise` | 38 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `offline` | 12 | a cold page load with the network off still opens notes and searches them |
Each suite creates a throwaway account through the admin API and deletes it
@@ -739,7 +739,10 @@ questions asked of every notebook at once, so there is nothing for an
arrangement to belong to and nowhere to keep it.
**Separators** are lines drawn between notes, optionally named, stored on the
notebook beside the sort mode. They move by dragging like a note does — a
notebook beside the sort mode. One is removed by right-clicking it and
confirming — a × on the row would sit a few pixels from the label you click to
rename, putting the gesture that tidies the list next to the one that destroys
part of it. They move by dragging like a note does — a
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. They are shown only in the
@@ -808,9 +811,16 @@ the links reported to the server. Its target is a marker inside this note's own
overlay, so no note has that id, and reporting it would fill the backlink table
with rows that match nothing.
**They can be hidden.** Reading the document as it was printed is a normal thing
to want; the toolbar toggles the markers and the column together, and the page
takes the width back while they are away.
**They can be hidden**, and so can the drawings — by a pair of switches at the
right-hand end of the toolbar. They are two halves of one question, *how much of
my own work do I want to see right now*, and they used to sit at opposite ends of
the bar, each beside the tool that made them. Each appears only once there is
something of its kind to hide. Hiding the margin notes takes the markers and the
column together, and the page takes the width back while they are away.
The drawing tools sit directly after the button that turns them on. They had
drifted to the far side of two other controls, so switching to Zeichnen made them
appear somewhere other than where the eye already was.
They come along when the document is exported with annotations: each becomes a
numbered marker plus a small box with its text, placed on whichever side of the