One dialog per thing, and rows dragged into a chapter

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

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

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

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

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

Fourteen suites green: 374 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-17 00:23:26 +02:00
1 parent 59844046c4
commit d68b0775c2
20 files changed
+1185 -594

No files matched your search

+54 -42
View File
@@ -193,48 +193,60 @@ export function EditorPane({
}}
>
{isMobile && <BackButton T={T} onClick={onBack} />}
{/* The note's own mark, left of its name — where the eye goes first and
where the same mark sits in the list. Pressing it opens the picker. */}
<button
onClick={onPickIcon}
data-note-icon={note.icon || "none"}
title="Choose an icon for this note"
aria-label="Choose an icon for this note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
flexShrink: 0,
borderRadius: 9,
border: "none",
background: "transparent",
cursor: "pointer",
fontSize: note.icon ? 17 : 14,
lineHeight: 1,
color: note.iconColor || T.faint,
}}
>
{note.icon || I.note}
</button>
<input
type="text"
aria-label="Note title"
value={note.title}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{
flex: 1,
border: "none",
background: "transparent",
outline: "none",
fontSize: isMobile ? 15 : 17,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
minWidth: 0,
}}
/>
{/* The mark and the name are one thing, so they sit in one group with a
small gap of their own. Left as separate children of the header they
were spaced like the buttons at the far end — the mark read as a
control that happened to be parked beside the title. */}
<div style={{ display: "flex", alignItems: "center", gap: 4, flex: 1, minWidth: 0 }}>
<button
onClick={onPickIcon}
data-note-icon={note.icon || "none"}
title="Choose an icon for this note"
aria-label="Choose an icon for this note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
/* One square box whatever it holds. An emoji and the drawn glyph
have different metrics, and sizing to the content moved the
title sideways the moment a mark was chosen. */
width: 26,
height: 26,
flexShrink: 0,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
padding: 0,
fontSize: 16,
lineHeight: 1,
color: note.iconColor || T.faint,
}}
>
{note.icon || I.note}
</button>
<input
type="text"
aria-label="Note title"
value={note.title}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{
flex: 1,
border: "none",
background: "transparent",
outline: "none",
fontSize: isMobile ? 15 : 17,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
minWidth: 0,
// Matches the box beside it, so the two sit on one line rather
// than each finding its own centre.
lineHeight: "26px",
padding: 0,
}}
/>
</div>
<button onClick={onExport} title="Export" style={toolbarButton(T, T.faint)}>
{I.export}
</button>
@@ -37,6 +37,7 @@ export function NoteEditor({
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
resolveLinkIcon,
backlinks,
}) {
const [mode, setMode] = useState("text");
@@ -864,6 +865,7 @@ export function NoteEditor({
minHeight={stageSize.h}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
resolveLinkIcon={resolveLinkIcon}
/>
</div>
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
+45 -148
View File
@@ -5,6 +5,7 @@ import { fmtDate } from "../../ui/format";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
import { END_ID, SORT_LABELS, mergeEntries } from "./noteSort";
import { useReorderDrag } from "../../hooks/useReorderDrag";
import { Rule } from "../../ui/separatorStyle.jsx";
const iconButton = (T) => ({
display: "flex",
@@ -76,66 +77,23 @@ const MIDDLE_MIN = 54;
/** Widest, so a long name cannot squeeze the rules away to nothing. */
const MIDDLE_MAX = 170;
/**
* How a separator's rule is drawn.
*
* Built as a border rather than a background, because that is the only way to
* get dashes and dots without an image — and `double` needs the height to carry
* two lines and the gap between them, or the browser draws one.
*/
const RULE_STYLES = {
solid: { height: 2, borderRadius: 2, solid: true },
dashed: { height: 0, borderTop: "2px dashed" },
dotted: { height: 0, borderTop: "3px dotted" },
double: { height: 0, borderTop: "4px double" },
thick: { height: 4, borderRadius: 2, solid: true },
};
/** One half of the line, left or right of the label. */
function Rule({ colour, style }) {
const shape = RULE_STYLES[style] || RULE_STYLES.solid;
return (
<div
data-separator-rule={style || "solid"}
style={{
flex: 1,
minWidth: 8,
height: shape.height,
borderRadius: shape.borderRadius,
...(shape.solid
? { background: colour }
: { borderTop: `${shape.borderTop} ${colour}` }),
}}
/>
);
}
/**
* A line drawn between notes, with an optional name.
*
* Draggable like a note, because a divider that cannot move with what it
* divides is worse than none.
* Draggable like a note, because a divider that cannot move with what it divides
* is worse than none.
*
* Both renaming and removing are right-click, which is how everything else in
* this list is acted on. A left-click on the label started renaming, so the row
* could not be clicked — to select it, to drag from it — without falling into an
* edit; and the × for removal sat a few pixels from that same target.
* Everything about it — its name, its colour, the shape of its rule — is changed
* in one dialog reached by right-click, like every other row here is acted on.
* It used to be three menu entries and an input that appeared in the row itself:
* three shapes of interaction for one small thing, and none of them showing what
* the result would look like.
*/
function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu, grip }) {
const [draft, setDraft] = useState(separator.label);
function SeparatorRow({ T, separator, onContextMenu, grip }) {
// Its own colour when it has been given one; the list's faint grey otherwise,
// which is what every separator looked like before there was a choice.
const colour = separator.color || T.faint;
useEffect(() => {
if (editing) setDraft(separator.label);
}, [editing, separator.label]);
const commit = () => {
onStartRename(null);
if (draft !== separator.label) onRename(separator.id, draft);
};
return (
<div
data-separator={separator.id}
@@ -155,73 +113,27 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
{/* The two rules always share what is left over equally. */}
<Rule colour={colour} style={separator.style} />
{/* Sized to the name it holds, between a floor and a ceiling. A fixed slot
kept the row from jumping when renaming began, at the cost of a short
name floating in the middle of a gap it did not fill. */}
kept the row from jumping when renaming began — which it no longer can,
since renaming happens in a dialog — at the cost of a short name
floating in the middle of a gap it did not fill. */}
<div
title="Right-click to edit"
style={{
flex: "0 1 auto",
minWidth: MIDDLE_MIN,
maxWidth: MIDDLE_MAX,
display: "flex",
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
color: separator.label ? separator.color || T.text : T.muted,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setDraft(separator.label);
onStartRename(null);
}
}}
aria-label="Separator label"
placeholder="Section"
// Follows what is being typed, so the row grows with the name
// instead of standing in a slot sized for a name nobody wrote.
size={Math.max(6, draft.length + 1)}
style={{
width: "100%",
minWidth: 0,
border: `1px solid ${T.border}`,
borderRadius: 6,
background: "transparent",
color: T.text,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textAlign: "center",
// Matches the button's box exactly, so nothing shifts vertically
// either when the input replaces it.
padding: "1px 4px",
outline: "none",
}}
/>
) : (
<div
title="Right-click for options"
style={{
width: "100%",
border: "1px solid transparent",
background: "transparent",
color: separator.label ? separator.color || T.text : T.muted,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
padding: "1px 4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{separator.label || "Unnamed"}
</div>
)}
{separator.label || "Unnamed"}
</div>
<Rule colour={colour} style={separator.style} />
</div>
@@ -308,11 +220,7 @@ export function ChapterNotes({
separators,
reorderable,
activeNote,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
onAddSeparator,
onSelectNote,
onNoteContextMenu,
compact = false,
@@ -332,9 +240,6 @@ export function ChapterNotes({
key={entry.id}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
grip={null}
/>
@@ -388,28 +293,10 @@ export function ChapterNotes({
</button>
)
)}
{/* A chapter can be divided like the notebook it sits in. Only where a
line can be seen, same as in the list outside. */}
{reorderable && onAddSeparator && (
<button
onClick={onAddSeparator}
data-add-chapter-separator
title="Insert a separator line in this chapter"
style={{
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
borderRadius: 8,
padding: "4px 9px",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
marginTop: 6,
}}
>
+ Separator
</button>
)}
{/* No button to add one here. A line is drawn in the notebook and dragged
into the chapter, the same way a note is put in one — a second way to
make the same thing, in a second place, is a second thing to keep
straight. */}
</>
);
}
@@ -633,9 +520,6 @@ export function NoteList({
reorderable,
separators,
onAddSeparator,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
chapters,
openChapter,
@@ -645,6 +529,8 @@ export function NoteList({
expandedChapter,
renderChapterNotes,
onReorder,
/** Drops a carried row *into* a chapter rather than beside it. */
onFileInto,
/** Identifies the list on screen, so rearranging does not stay on across a move to another. */
listKey,
/** Whether this pane is the one being worked in; null when there is nothing to tell apart. */
@@ -674,7 +560,7 @@ export function NoteList({
/* Reordering by pointer rather than by the HTML5 drag API, which mobile
browsers never fire from touch — on a phone the arrangement could not be
changed at all, with nothing on screen to say why. */
const dnd = useReorderDrag(moving, onReorder);
const dnd = useReorderDrag(moving, onReorder, onFileInto);
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
or the trash, which are views over notebooks rather than a place to file
anything, and not inside a chapter, because a chapter holds notes. */
@@ -968,8 +854,22 @@ export function NoteList({
const lifted = dnd.draggingId === entry.id;
if (entry.kind === "chapter") {
const taking = dnd.intoId === entry.id;
return (
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
<div
key={entry.id}
{...(moving ? dnd.rowProps(entry.id, true) : {})}
data-chapter-drop={taking ? "on" : undefined}
style={{
opacity: lifted ? 0.45 : 1,
/* Held over, the whole row lights up rather than growing a
line above it: a chapter is about to take what is
carried, not stand next to it. */
outline: taking ? `2px solid ${T.accent}` : "none",
outlineOffset: -1,
borderRadius: 10,
}}
>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{moving && <Grip T={T} {...dnd.gripProps} />}
@@ -994,9 +894,6 @@ export function NoteList({
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
/>
</div>
@@ -27,6 +27,7 @@ export function PdfWorkspace({
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
resolveLinkIcon,
}) {
const [showNotes, setShowNotes] = useState(false);
// Set when a link in the notes points at a marker; the viewer clears it once
@@ -155,6 +156,7 @@ export function PdfWorkspace({
onOpenNote={openTarget}
searchLinkTargets={searchTargets}
resolveLinkLabel={resolveLabel}
resolveLinkIcon={resolveLinkIcon}
/* On a phone the viewer is not on screen at all, so the way back to
the document has to be here. */
onBackToDocument={narrow ? () => setShowNotes(false) : null}