Fix a permanent black line in the note list, and add separators
The drop indicator was a `borderTop` set in the same style object as a `border` shorthand. Clearing the longhand resets its width and colour to the defaults while the shorthand's `solid` stays, so a row kept a 2px black line for good — measured at rest, before any drag. It is now its own element, which also stops the list twitching as a row grows and shrinks by a pixel. That element then turned out to be a dead strip of its own: it pushes the row down as it appears, so the pointer ended up over the line rather than over the row and releasing there dropped on nothing. The drag handlers moved to the whole row and the indicator no longer takes pointer events. A strip below the last row means "put it at the end", which otherwise needs a drop onto the final note. Separators are lines drawn between notes, optionally named, stored on the notebook beside its sort mode. They drag like notes do, and their position is "how many notes come before me", so they hold their place as the notes around them are renamed or edited. Shown only in the manual order — in "newest first" the notes would slide past the line on every edit. "All Notes" and "Starred" no longer offer a hand-made order: they are questions asked of every notebook, so there is nothing for an arrangement to belong to. 199 unit tests, 190 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a0864c58b
commit
2cee00e38e
10 files changed
+731
-125
No files matched your search
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { I } from "../../ui/icons.jsx";
|
||||
import { fmtDate } from "../../ui/format";
|
||||
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||
import { SORT_LABELS, SORT_MODES } from "./noteSort";
|
||||
import { END_ID, SORT_LABELS, mergeEntries } from "./noteSort";
|
||||
|
||||
const iconButton = (T) => ({
|
||||
display: "flex",
|
||||
@@ -18,6 +18,127 @@ const iconButton = (T) => ({
|
||||
color: T.muted,
|
||||
});
|
||||
|
||||
/** A note rendered outside the manual order, wrapped to look like an entry. */
|
||||
const asNoteEntry = (note) => ({ kind: "note", id: note.id, note });
|
||||
|
||||
/**
|
||||
* Where the dragged row will land.
|
||||
*
|
||||
* A real element rather than a border on the row above. A border that appears
|
||||
* and disappears changes that row's height, so the list twitched under the
|
||||
* cursor — and setting `borderTop` in the same style object as a `border`
|
||||
* shorthand left a permanent 2px black line behind once it was cleared, because
|
||||
* clearing the longhand resets its width and colour to the defaults while the
|
||||
* shorthand's `solid` stays.
|
||||
*/
|
||||
const DropLine = ({ T }) => (
|
||||
<div data-drop-line style={{ height: 2, borderRadius: 2, background: T.accent, margin: "2px 4px" }} />
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function SeparatorRow({ T, separator, onRename, onRemove }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(separator.label);
|
||||
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
if (draft !== separator.label) onRename(separator.id, draft);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-separator={separator.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 8px 6px",
|
||||
cursor: "grab",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ height: 1, background: T.border, flex: separator.label || editing ? 0 : 1, minWidth: 12 }}
|
||||
/>
|
||||
{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);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
aria-label="Separator label"
|
||||
placeholder="Abschnitt"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: T.textSec,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
padding: "2px 6px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft(separator.label);
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Umbenennen"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: separator.label ? T.textSec : T.faint,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: "uppercase",
|
||||
cursor: "text",
|
||||
padding: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{separator.label || "Benennen"}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 12 }} />
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How this notebook's notes are ordered.
|
||||
*
|
||||
@@ -25,7 +146,7 @@ const iconButton = (T) => ({
|
||||
* device and still there next week — a reference book stays alphabetical, a
|
||||
* journal stays newest-first, without either being re-chosen on each visit.
|
||||
*/
|
||||
function SortMenu({ T, mode, onChange, disabled }) {
|
||||
function SortMenu({ T, mode, modes, onChange, disabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef(null);
|
||||
|
||||
@@ -65,7 +186,7 @@ function SortMenu({ T, mode, onChange, disabled }) {
|
||||
boxShadow: "0 10px 26px rgba(0,0,0,.28)",
|
||||
}}
|
||||
>
|
||||
{SORT_MODES.map((m) => (
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
data-sort-option={m}
|
||||
@@ -109,8 +230,13 @@ export function NoteList({
|
||||
trashedCount,
|
||||
snippetFor,
|
||||
sortMode,
|
||||
sortModes,
|
||||
onSortModeChange,
|
||||
reorderable,
|
||||
separators,
|
||||
onAddSeparator,
|
||||
onRenameSeparator,
|
||||
onRemoveSeparator,
|
||||
dnd,
|
||||
onToggleSidebar,
|
||||
onSelectNote,
|
||||
@@ -176,7 +302,7 @@ export function NoteList({
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
{!isTrashView && <SortMenu T={T} mode={sortMode} onChange={onSortModeChange} />}
|
||||
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={onImportMarkdown}
|
||||
@@ -242,15 +368,39 @@ export function NoteList({
|
||||
{sortMode === "manual" && !isTrashView && (
|
||||
<div
|
||||
style={{
|
||||
padding: "5px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "5px 10px 5px 16px",
|
||||
fontSize: 10.5,
|
||||
color: T.faint,
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
{reorderable
|
||||
? "Eigene Reihenfolge — zum Umsortieren ziehen"
|
||||
: "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"}
|
||||
<span style={{ flex: 1 }}>
|
||||
{reorderable
|
||||
? "Eigene Reihenfolge — zum Umsortieren ziehen"
|
||||
: "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"}
|
||||
</span>
|
||||
{reorderable && (
|
||||
<button
|
||||
onClick={onAddSeparator}
|
||||
data-add-separator
|
||||
title="Trennlinie einfügen"
|
||||
style={{
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
color: T.muted,
|
||||
borderRadius: 8,
|
||||
padding: "2px 8px",
|
||||
fontSize: 10.5,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
+ Trenner
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -295,77 +445,130 @@ export function NoteList({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
notes.map((note) => {
|
||||
const snippet = snippetFor(note.id);
|
||||
const isTarget = reorderable && dnd?.overId === note.id;
|
||||
return (
|
||||
<button
|
||||
key={note.id}
|
||||
data-note-row={note.id}
|
||||
draggable={reorderable}
|
||||
onDragStart={reorderable ? (e) => dnd.onDragStart(e, note.id) : undefined}
|
||||
onDragOver={reorderable ? (e) => dnd.onDragOver(e, note.id) : undefined}
|
||||
onDrop={reorderable ? (e) => dnd.onDrop(e, note.id) : undefined}
|
||||
onDragEnd={reorderable ? dnd.onDragEnd : undefined}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
onContextMenu={(e) => onNoteContextMenu(e, note)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||
borderTop: isTarget ? `2px solid ${T.accent}` : undefined,
|
||||
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||||
cursor: reorderable ? "grab" : "pointer",
|
||||
marginBottom: 2,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3, width: "100%" }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{note.trashed ? "🗑️" : note.type === "pdf" ? "📄" : "📝"}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.starred && !note.trashed && (
|
||||
<span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>
|
||||
)}
|
||||
</div>
|
||||
{snippet && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: T.textSec,
|
||||
lineHeight: 1.45,
|
||||
marginTop: 1,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{snippet}
|
||||
<>
|
||||
{/* Separators are part of the manual arrangement and only mean
|
||||
something there: in "newest first" the notes would slide past a
|
||||
line on every edit. They are not shown in the other orders, and
|
||||
are still in place on the way back. */}
|
||||
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
|
||||
const showDropLine = reorderable && dnd?.overId === entry.id;
|
||||
/* The whole row, indicator included, is the drop target.
|
||||
*
|
||||
* Putting these on the inner element left the indicator itself as
|
||||
* a dead strip: it pushes the row down as it appears, so the
|
||||
* pointer ended up over the line rather than over the row, and
|
||||
* releasing there dropped on nothing. */
|
||||
const rowProps = reorderable
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: (e) => dnd.onDragStart(e, entry.id),
|
||||
onDragOver: (e) => dnd.onDragOver(e, entry.id),
|
||||
onDrop: (e) => dnd.onDrop(e, entry.id),
|
||||
onDragEnd: dnd.onDragEnd,
|
||||
}
|
||||
: {};
|
||||
|
||||
if (entry.kind === "separator") {
|
||||
return (
|
||||
<div key={entry.id} {...rowProps}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<SeparatorRow
|
||||
T={T}
|
||||
separator={entry.separator}
|
||||
onRename={onRenameSeparator}
|
||||
onRemove={onRemoveSeparator}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 10, color: T.faint, marginTop: 3 }}>{fmtDate(note.updatedAt)}</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const note = entry.note;
|
||||
const snippet = snippetFor(note.id);
|
||||
return (
|
||||
<div key={note.id} {...rowProps}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<button
|
||||
data-note-row={note.id}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
onContextMenu={(e) => onNoteContextMenu(e, note)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||||
cursor: reorderable ? "grab" : "pointer",
|
||||
marginBottom: 2,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
marginBottom: 3,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{note.trashed ? "🗑️" : note.type === "pdf" ? "📄" : "📝"}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.starred && !note.trashed && (
|
||||
<span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>
|
||||
)}
|
||||
</div>
|
||||
{snippet && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: T.textSec,
|
||||
lineHeight: 1.45,
|
||||
marginTop: 1,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{snippet}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 10, color: T.faint, marginTop: 3 }}>
|
||||
{fmtDate(note.updatedAt)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Dropping below the last row moves something to the end, which
|
||||
otherwise needs an awkward drop onto the very last note. */}
|
||||
{reorderable && (
|
||||
<div
|
||||
data-drop-end
|
||||
onDragOver={(e) => dnd.onDragOver(e, END_ID)}
|
||||
onDrop={(e) => dnd.onDrop(e, END_ID)}
|
||||
style={{ minHeight: 40 }}
|
||||
>
|
||||
{dnd?.overId === END_ID && <DropLine T={T} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user