A filed note stays filed, and the handles are asked for
Three things that all came from chapters being thought through too late. A note put into a chapter came back out of it a few seconds later. The server had it right the whole time; every sync rebuilds the local copy from the server's answer, and the function doing that never carried chapter_id — so each sync handed back a note belonging to no chapter and the notebook took it in again. The bin's query left the column out too, which would have unfiled anything restored from it. A separator drawn in a notebook vanished when a chapter was opened. "Which chapter is open" and "which list is on screen" were one value. Opened beside or below, the list still shows the notebook — but everything around it had switched to the chapter, so the notebook's own separators were filtered away and a drag among its rows was measured against the chapter's notes, found nothing, and did nothing. And where a new note lands is now visible and yours to choose: pressing in either place makes it the one being worked in, marked with a line along its top and the word "here", and the + button names it. Rearranging becomes a mode, switched on beside the sort menu — until then no row wears a grip and none can be dragged. Filing a note is back in the row's own menu, where starring and exporting already are; the button that had appeared in every row is gone with its dialog. The account panel closes with Escape. Thirteen suites green: 335 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4bb86bc5ac
commit
b2a5f051fe
12 files changed
+406
-291
No files matched your search
@@ -42,9 +42,11 @@ const DropLine = ({ T }) => (
|
||||
*
|
||||
* A mouse can drag a row from anywhere, and did. A finger cannot: pressing the
|
||||
* row has to keep scrolling the list, and holding it has to keep opening the
|
||||
* menu. So touch gets its own target, wide enough to hit and only there while a
|
||||
* hand-made arrangement is in effect — which is the only time moving a row means
|
||||
* anything.
|
||||
* menu. So touch gets its own target, wide enough to hit.
|
||||
*
|
||||
* Only there while rearranging is switched on. Standing in every row all the
|
||||
* time it was six dots of clutter beside every title, and a row could be pulled
|
||||
* out of place by a press that was meant to open it.
|
||||
*/
|
||||
const Grip = ({ T, ...rest }) => (
|
||||
<div
|
||||
@@ -68,36 +70,6 @@ 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;
|
||||
|
||||
@@ -135,7 +107,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
/* Room above it, so it reads as the end of what came before rather than
|
||||
as decoration attached to the next note. */
|
||||
padding: "14px 8px 8px",
|
||||
cursor: "grab",
|
||||
cursor: grip ? "grab" : "default",
|
||||
}}
|
||||
>
|
||||
{grip}
|
||||
@@ -343,7 +315,7 @@ function SortMenu({ T, mode, modes, onChange, disabled }) {
|
||||
* putting something new in here — and they had grown into three buttons side by
|
||||
* side, two of which were icons you had to hover to identify.
|
||||
*/
|
||||
function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||
function AddMenu({ T, place, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef(null);
|
||||
|
||||
@@ -388,7 +360,8 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||
<div style={{ position: "relative" }} ref={box}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="New Note"
|
||||
// Names the place, so it is clear before pressing where the note lands.
|
||||
title={place ? `New note in ${place}` : "New Note"}
|
||||
data-add-menu={open ? "on" : "off"}
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -430,6 +403,32 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches rearranging on.
|
||||
*
|
||||
* Rows are dragged rarely and read constantly, so the handles are not there by
|
||||
* default: the list stays a list, and taking hold of a row is something asked
|
||||
* for. While it is on, and only then, every row shows its grip and can be
|
||||
* carried; pressing a row does not open it by accident.
|
||||
*/
|
||||
const ReorderToggle = ({ T, on, onToggle }) => (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
data-reorder-toggle={on ? "on" : "off"}
|
||||
title={on ? "Done rearranging" : "Rearrange the rows"}
|
||||
aria-label="Rearrange the rows"
|
||||
aria-pressed={on}
|
||||
style={{
|
||||
...iconButton(T),
|
||||
color: on ? "#fff" : T.muted,
|
||||
background: on ? T.accent : "transparent",
|
||||
borderColor: on ? T.accent : T.border,
|
||||
}}
|
||||
>
|
||||
{on ? I.check : I.grip}
|
||||
</button>
|
||||
);
|
||||
|
||||
/** The middle column: every note in the selected folder, in the chosen order. */
|
||||
export function NoteList({
|
||||
T,
|
||||
@@ -459,8 +458,14 @@ export function NoteList({
|
||||
onChapterContextMenu,
|
||||
expandedChapter,
|
||||
renderChapterNotes,
|
||||
onMoveNote,
|
||||
onReorder,
|
||||
/** 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. */
|
||||
isCurrent,
|
||||
onFocusList,
|
||||
/** The place a new note would land in, named on the + button. */
|
||||
newNotePlace,
|
||||
onToggleSidebar,
|
||||
onSelectNote,
|
||||
onNoteContextMenu,
|
||||
@@ -469,10 +474,21 @@ export function NoteList({
|
||||
onImportMarkdown,
|
||||
onEmptyTrash,
|
||||
}) {
|
||||
/* Rearranging is a mode, switched on for as long as it is being done.
|
||||
*
|
||||
* It ends by itself when the list is left: coming back to a notebook to find
|
||||
* every row wearing a handle, because it was rearranged last week, is not
|
||||
* where anybody left off. */
|
||||
const [reordering, setReordering] = useState(false);
|
||||
// Off again on the way to another list, and whenever a hand-made arrangement
|
||||
// stops being in effect at all — an order the app chose cannot be dragged.
|
||||
useEffect(() => setReordering(false), [listKey, reorderable]);
|
||||
const moving = !!reorderable && reordering;
|
||||
|
||||
/* 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(!!reorderable, onReorder);
|
||||
const dnd = useReorderDrag(moving, onReorder);
|
||||
/* 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. */
|
||||
@@ -484,6 +500,7 @@ export function NoteList({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-list-current={isCurrent === null || isCurrent === undefined ? undefined : isCurrent ? "on" : "off"}
|
||||
style={{
|
||||
width: isMobile ? "100%" : 300,
|
||||
minWidth: isMobile ? 0 : 300,
|
||||
@@ -493,6 +510,8 @@ export function NoteList({
|
||||
flexDirection: "column",
|
||||
background: T.bg,
|
||||
fontFamily: FONT_UI,
|
||||
// A line along the top of whichever pane is being worked in.
|
||||
boxShadow: isCurrent ? `inset 0 2px 0 ${T.accent}` : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -541,12 +560,21 @@ export function NoteList({
|
||||
>
|
||||
{openChapter ? openChapter.name : title}
|
||||
</h2>
|
||||
{/* Said in words as well as in the line above, because a coloured edge
|
||||
is only a hint until you know what it means. */}
|
||||
{isCurrent && (
|
||||
<span style={{ fontSize: 10, color: T.accent, fontWeight: 700, flexShrink: 0 }}>· here</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
{reorderable && (
|
||||
<ReorderToggle T={T} on={reordering} onToggle={() => setReordering((v) => !v)} />
|
||||
)}
|
||||
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
|
||||
{!isTrashView && (
|
||||
<AddMenu
|
||||
T={T}
|
||||
place={newNotePlace}
|
||||
onCreateNote={onCreateNote}
|
||||
onImportPdf={onImportPdf}
|
||||
onImportMarkdown={onImportMarkdown}
|
||||
@@ -615,12 +643,20 @@ export function NoteList({
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
{/* Only while it is on, and then it says what to do rather than
|
||||
explaining the order the sort menu already names. */}
|
||||
{moving && (
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 11, color: T.accent, fontWeight: 600 }}>
|
||||
Drag the rows into place
|
||||
</span>
|
||||
)}
|
||||
{/* A chapter divides the notebook; a separator divides a list. They
|
||||
sit together because both are "add something that is not a note",
|
||||
and apart from the + button, which makes notes. */}
|
||||
@@ -666,7 +702,16 @@ export function NoteList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
|
||||
{/* Pressing in the list itself — on a note or on the empty space below
|
||||
them — says "I am in the notebook", and a new note goes there.
|
||||
Deliberately not the whole pane: the + button sits in the header, and
|
||||
taking the focus back on the way to pressing it would mean a note
|
||||
could never be made in the open chapter at all. */}
|
||||
<div
|
||||
data-note-list-body
|
||||
onPointerDown={onFocusList}
|
||||
style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}
|
||||
>
|
||||
{notes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -718,14 +763,14 @@ export function NoteList({
|
||||
? mergeEntries(notes, separators, shownChapters)
|
||||
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)]
|
||||
).map((entry) => {
|
||||
const showDropLine = reorderable && dnd.overId === entry.id;
|
||||
const showDropLine = moving && 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 ? dnd.rowProps(entry.id) : {};
|
||||
const rowProps = moving ? dnd.rowProps(entry.id) : {};
|
||||
const lifted = dnd.draggingId === entry.id;
|
||||
|
||||
if (entry.kind === "chapter") {
|
||||
@@ -733,7 +778,7 @@ export function NoteList({
|
||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||
{reorderable && <Grip T={T} {...dnd.gripProps} />}
|
||||
{moving && <Grip T={T} {...dnd.gripProps} />}
|
||||
<ChapterRow
|
||||
T={T}
|
||||
chapter={entry.chapter}
|
||||
@@ -752,7 +797,7 @@ export function NoteList({
|
||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<SeparatorRow
|
||||
grip={<Grip T={T} {...dnd.gripProps} />}
|
||||
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
|
||||
T={T}
|
||||
separator={entry.separator}
|
||||
editing={renamingSeparator === entry.id}
|
||||
@@ -770,10 +815,7 @@ export function NoteList({
|
||||
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{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)} />
|
||||
)}
|
||||
{moving && <Grip T={T} {...dnd.gripProps} />}
|
||||
<button
|
||||
data-note-row={note.id}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
@@ -787,7 +829,7 @@ export function NoteList({
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||||
cursor: reorderable ? "grab" : "pointer",
|
||||
cursor: moving ? "grab" : "pointer",
|
||||
marginBottom: 2,
|
||||
textAlign: "left",
|
||||
}}
|
||||
@@ -848,7 +890,7 @@ export function NoteList({
|
||||
})}
|
||||
{/* Dropping below the last row moves something to the end, which
|
||||
otherwise needs an awkward drop onto the very last note. */}
|
||||
{reorderable && (
|
||||
{moving && (
|
||||
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 56 }}>
|
||||
{dnd.overId === END_ID && <DropLine T={T} />}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user