One direction to drop in, and a new note that stays on top

Every new note pulled the arrangement apart. A note was made with no
position and left to the tie-break, which put it near the top by age --
which is not the same thing. A separator's position counts the rows above
it, so a row appearing without a renumber left every line one row too
high. placeOnTop builds the order from the list *without* the new note and
puts it in front: measuring the room after the furniture has moved would
write the misreading back as the truth.

A drop now lands the row under the one it was released on, and only there.
Dropping above a row and dropping below the row before it are the same move
described twice, so every gap could be aimed at from two sides. The strip
above the first row is the one place a drop under something cannot reach.

A chapter could only be dropped into, never dragged past: taking the whole
row as a place quietly took reordering away. The bottom edge of its header
is the gap now, the rest of the row is still the chapter.

A link showed the sheet of paper whatever it pointed at. The @ menu dropped
the mark on the way into the link, and the refresh that keeps a link current
ran once, while the editor was still empty -- content is poured in afterwards
without an update event, so nothing ever looked again.

The mark in the title bar and the box in a task list were each centred on a
line box rather than on the letters in it, which is not the same as being on
one line. The mark shares the title's baseline; the box is centred on the
capitals and sized in em.

Move to... is divided under headings that are deliberately not shaped like
entries, and each row carries the mark its chapter or notebook wears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-17 11:45:16 +02:00
1 parent 2ffa357dfc
commit a9391cdcf2
15 files changed
+724 -189

No files matched your search

+150 -81
View File
@@ -3,9 +3,19 @@ import { useCallback, useEffect, useRef, useState } from "react";
import * as sync from "../syncEngine";
import * as searchIndex from "../search";
import { sameStrokeDoc } from "../features/drawing/strokes.js";
import { reindexEntries } from "../features/notes/noteSort";
import { placeOnTop, reindexEntries } from "../features/notes/noteSort";
import type { Arrangement } from "../features/notes/noteSort";
import { extractOnImport } from "../features/pdf/pdfText";
import type { Backlink, Chapter, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
import type {
Backlink,
Chapter,
NoteContent,
NoteMeta,
Notebook,
Separator,
StrokeDoc,
User,
} from "../types";
/** How often the whole account is reconciled with the server. */
const FULL_SYNC_MS = 30_000;
@@ -202,34 +212,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
/* ── Notes ── */
const createNote = useCallback(async (folderId: string, chapterId: string | null = null) => {
const note = await sync.createNote(folderId, chapterId);
setNotes((p) => [note, ...p]);
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
return note;
}, []);
/**
* Uploads a PDF and creates the note that holds it.
*
* Not queued for offline: the file has to reach the server before a note can
* point at it, and a note referring to bytes nobody has would be worse than
* being told to try again with a connection.
*/
const importPdf = useCallback(async (folderId: string, file: File) => {
const attachment = await sync.uploadPdf(file, file.name);
const title = file.name.replace(/\.pdf$/i, "") || "Dokument";
const note = await sync.createPdfNote(folderId, title, attachment.id);
setNotes((p) => [note, ...p]);
/* Read the document here, now, on the machine that already has it open.
*
* Waiting until someone opens it means a filed invoice may never be read at
* all, and until it is, no device can find it. Not awaited: the note exists
* and should appear at once — the text catches up. */
void extractOnImport(file, attachment.id);
return note;
}, []);
const updateNote = useCallback((id: string, edit: NoteEdit) => {
if (edit.title !== undefined) {
const title = edit.title;
@@ -294,71 +276,158 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
}, []);
/**
* Puts one row where another was, in the user's own order.
* Everything one list is made of: its notes, its lines, its chapters.
*
* Notes and separators move the same way, so this takes both and writes both
* back. The new positions are computed over *every* note in that notebook,
* taken from the store rather than from whatever the list is showing: a search
* or a filter must never be able to renumber the notes it is hiding.
* `chapterId` scopes it to one list — the notebook itself, or one of its
* chapters. A row's place is only meaningful beside the rows it is shown with,
* and the separators of the other chapters have to come through a write
* untouched. The notes are taken from the store rather than from whatever the
* list is showing: a search must never be able to renumber what it is hiding.
*
* `extra` is a note that exists but has not reached the store yet — the one
* just created. Building the list without it would renumber the notebook
* around a row that is already on screen.
*/
/* `chapterId` scopes the arrangement to one list: the notebook itself, or one
of its chapters. A note's place is only meaningful beside the notes it is
shown with, and the separators of the other chapters must come through the
write untouched. */
const rearrange = useCallback(
(notebookId: string, dragId: string, targetId: string, chapterId: string | null = null) => {
const listOf = useCallback((notebookId: string, chapterId: string | null, extra?: NoteMeta) => {
const notebook = notebooksRef.current.find((nb) => nb.id === notebookId);
if (!notebook) return;
const inNotebook = notesRef.current.filter(
if (!notebook) return null;
const known = extra ? [extra, ...notesRef.current.filter((n) => n.id !== extra.id)] : notesRef.current;
const notes = known.filter(
(n) => !n.trashed && n.folderId === notebookId && (n.chapterId ?? null) === chapterId
);
const all = notebook.separators || [];
const mine = all.filter((sep) => (sep.chapterId ?? null) === chapterId);
const others = all.filter((sep) => (sep.chapterId ?? null) !== chapterId);
/* Chapters are rows in this list too, and only in the notebook itself — a
chapter has no chapters of its own. */
const inList = chapterId
? []
: chaptersRef.current.filter((c) => c.notebookId === notebookId);
return {
notes,
all,
mine: all.filter((sep) => (sep.chapterId ?? null) === chapterId),
others: all.filter((sep) => (sep.chapterId ?? null) !== chapterId),
/* Chapters are rows in this list too, and only in the notebook itself — a
chapter has no chapters of its own. */
chapters: chapterId ? [] : chaptersRef.current.filter((c) => c.notebookId === notebookId),
};
}, []);
const next = reindexEntries(inNotebook, mine, dragId, targetId, inList);
if (!next) return;
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
/** Writes an arrangement back — to the screen at once, to the server behind it. */
const applyArrangement = useCallback(
(
notebookId: string,
chapterId: string | null,
all: Separator[],
others: Separator[],
next: Arrangement
) => {
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
const position = new Map(next.notes.map((n) => [n.id, n.order]));
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
void sync.reorderNotes(next.notes);
const position = new Map(next.notes.map((n) => [n.id, n.order]));
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
void sync.reorderNotes(next.notes);
if (next.chapters.length) {
const chapterAt = new Map(next.chapters.map((c) => [c.id, c.order]));
setChapters((p) => p.map((c) => (chapterAt.has(c.id) ? { ...c, sortOrder: chapterAt.get(c.id)! } : c)));
void sync.reorderChapters(next.chapters);
}
if (next.chapters.length) {
const chapterAt = new Map(next.chapters.map((c) => [c.id, c.order]));
setChapters((p) => p.map((c) => (chapterAt.has(c.id) ? { ...c, sortOrder: chapterAt.get(c.id)! } : c)));
void sync.reorderChapters(next.chapters);
}
/* Only written when it actually changed.
*
* A plain note drag leaves the separators alone, and a write for nothing
* costs more than a request: it reloads the notes, which reshuffles any that
* share an `updatedAt` second — so "last changed" quietly came out in a
* different order after every drag.
*
* Compared as a set rather than as an array, because the merge above puts
* the other chapters' separators first and that says nothing about whether
* anything changed. */
const fingerprint = (list: typeof all) =>
list
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
.sort()
.join("\n");
const before = fingerprint(all);
if (fingerprint(merged) !== before) {
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
void sync.updateNotebook(notebookId, { separators: merged });
}
/* Only written when it actually changed.
*
* A plain note drag leaves the separators alone, and a write for nothing
* costs more than a request: it reloads the notes, which reshuffles any
* that share an `updatedAt` second — so "last changed" quietly came out in
* a different order after every drag.
*
* Compared as a set rather than as an array, because the merge above puts
* the other chapters' separators first and that says nothing about whether
* anything changed. */
const fingerprint = (seps: Separator[]) =>
seps
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
.sort()
.join("\n");
if (fingerprint(merged) !== fingerprint(all)) {
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
void sync.updateNotebook(notebookId, { separators: merged });
}
},
[]
);
/**
* Puts one row under the one it was dropped on, in the user's own order.
*
* Notes, lines and chapters move the same way, so this takes all three and
* writes all three back.
*/
const rearrange = useCallback(
(notebookId: string, dragId: string, targetId: string, chapterId: string | null = null) => {
const list = listOf(notebookId, chapterId);
if (!list) return;
const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters);
if (!next) return;
applyArrangement(notebookId, chapterId, list.all, list.others, next);
},
[applyArrangement, listOf]
);
/**
* Puts a freshly made note at the top of the list it was made in.
*
* A new note used to be given no position at all, and the sort put it near the
* top by age. That is not the same thing: a separator's position counts the
* rows above it, so a row that appears without anybody being renumbered leaves
* every line one row too high — and each new note pulled the notebook's
* arrangement further apart. Renumbering the list once, here, keeps every
* other row exactly where it was and puts the new one above all of them,
* lines and chapters included.
*/
const putOnTop = useCallback(
(note: NoteMeta) => {
if (!note.folderId) return;
const chapterId = note.chapterId ?? null;
const list = listOf(note.folderId, chapterId, note);
if (!list) return;
const next = placeOnTop(list.notes, list.mine, list.chapters, note.id);
if (!next) return;
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
},
[applyArrangement, listOf]
);
const createNote = useCallback(
async (folderId: string, chapterId: string | null = null) => {
const note = await sync.createNote(folderId, chapterId);
setNotes((p) => [note, ...p]);
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
putOnTop(note);
return note;
},
[putOnTop]
);
/**
* Uploads a PDF and creates the note that holds it.
*
* Not queued for offline: the file has to reach the server before a note can
* point at it, and a note referring to bytes nobody has would be worse than
* being told to try again with a connection.
*/
const importPdf = useCallback(
async (folderId: string, file: File) => {
const attachment = await sync.uploadPdf(file, file.name);
const title = file.name.replace(/\.pdf$/i, "") || "Dokument";
const note = await sync.createPdfNote(folderId, title, attachment.id);
setNotes((p) => [note, ...p]);
putOnTop(note);
/* Read the document here, now, on the machine that already has it open.
*
* Waiting until someone opens it means a filed invoice may never be read
* at all, and until it is, no device can find it. Not awaited: the note
* exists and should appear at once — the text catches up. */
void extractOnImport(file, attachment.id);
return note;
},
[putOnTop]
);
/* ── Notebooks ── */
/* ── Chapters ──
+21 -4
View File
@@ -106,10 +106,27 @@ export function useReorderDrag(
const row = el?.closest?.("[data-reorder-id]") as HTMLElement | null;
const target = row?.dataset.reorderId ?? null;
const next = target && target !== d.id ? target : null;
/* A chapter takes what is dropped on it rather than standing beside it,
* which is what "drag it into the chapter" means. Filing is only offered
* when there is somewhere to file *to*. */
const into = next && row?.dataset.reorderInto !== undefined && fileInto ? next : null;
/* A chapter is both a place and a neighbour, so which one it is depends on
* where in the row the pointer is.
*
* Anywhere on the row meant "into", and that quietly took reordering away:
* a chapter could no longer be moved among its neighbours, and nothing —
* note or line — could be put in the gap under one. The bottom edge is
* that gap; the body of the row is the chapter itself and takes what is
* carried in. The indicator says which: a line in the gap, the whole row
* lit up when it is about to take something. */
let into: string | null = null;
if (next && row && row.dataset.reorderInto !== undefined && fileInto) {
// The header, not the row: an open chapter's row includes the notes
// inside it, and an edge measured on *that* would be half a notebook.
const head = (row.querySelector("[data-reorder-head]") as HTMLElement | null) || row;
const rect = head.getBoundingClientRect();
const edge = Math.min(10, rect.height / 3);
// Only that band is the gap. Below it lie the chapter's own notes, and
// holding over them plainly means the chapter.
const inGap = e.clientY >= rect.bottom - edge && e.clientY <= rect.bottom;
if (!inGap) into = next;
}
if (into !== intoRef.current) {
intoRef.current = into;
setIntoId(into);