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:
1 parent
2ffa357dfc
commit
a9391cdcf2
15 files changed
+724
-189
No files matched your search
+40
-17
@@ -683,26 +683,49 @@ export default function App() {
|
||||
*/
|
||||
const moveSubmenu = (note) => {
|
||||
const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId);
|
||||
const targets = [
|
||||
...(note.chapterId
|
||||
? [{ label: "The notebook itself", action: () => data.moveNote(note.id, note.folderId, null) }]
|
||||
: []),
|
||||
...here.map((c) => ({
|
||||
label: c.name,
|
||||
icon: I.books,
|
||||
action: () => data.moveNote(note.id, note.folderId, c.id),
|
||||
})),
|
||||
...(here.length || note.chapterId ? [{ divider: true }] : []),
|
||||
...notebooks
|
||||
.filter((nb) => nb.id !== note.folderId)
|
||||
.map((nb) => ({
|
||||
const home = notebooks.find((nb) => nb.id === note.folderId);
|
||||
const elsewhere = notebooks.filter((nb) => nb.id !== note.folderId);
|
||||
const targets = [];
|
||||
|
||||
/* The notebook the note is already in, when it is sitting in one of that
|
||||
notebook's chapters — coming back out is a move like any other, and it
|
||||
wears the notebook's own cover so it is read as a place. */
|
||||
if (note.chapterId && home) {
|
||||
targets.push({
|
||||
label: home.name,
|
||||
colorDot: coverBackground(home.cover, home.coverPattern),
|
||||
action: () => data.moveNote(note.id, home.id, null),
|
||||
});
|
||||
}
|
||||
/* Grouped and named, because the list holds two different kinds of place and
|
||||
used to run them together with a rule between: a chapter of this notebook
|
||||
is one step, another notebook is a different one. Each row carries the
|
||||
mark its chapter or notebook was given, so it is found the way it is found
|
||||
everywhere else. */
|
||||
if (here.length) {
|
||||
targets.push({ heading: "Chapters here" });
|
||||
targets.push(
|
||||
...here.map((c) => ({
|
||||
label: c.name,
|
||||
icon: c.icon || I.books,
|
||||
iconColor: c.color || undefined,
|
||||
action: () => data.moveNote(note.id, note.folderId, c.id),
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (elsewhere.length) {
|
||||
targets.push({ heading: "Other notebooks" });
|
||||
targets.push(
|
||||
...elsewhere.map((nb) => ({
|
||||
label: nb.name,
|
||||
colorDot: coverBackground(nb.cover, nb.coverPattern),
|
||||
action: () => data.moveNote(note.id, nb.id, null),
|
||||
})),
|
||||
];
|
||||
// A lone divider is not a menu.
|
||||
const usable = targets.filter((t) => !t.divider);
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Headings alone are not a menu.
|
||||
const usable = targets.filter((t) => !t.divider && !t.heading);
|
||||
return usable.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
|
||||
};
|
||||
|
||||
|
||||
@@ -249,25 +249,34 @@
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
/* The box sits in the middle of the *first line* beside it, not at a fixed
|
||||
nudge from the top. `margin-top: 0.35em` was measured once against one font at
|
||||
one size; changing the note's typeface, or writing a task that wraps, left the
|
||||
box floating above the text it belongs to. Giving the label the height of one
|
||||
line and centring inside it holds for every size. */
|
||||
/* The box is centred on the *letters* of the first line beside it — not on that
|
||||
line's box, and not at a fixed nudge from the top.
|
||||
|
||||
`margin-top: 0.35em` was measured once against one font at one size. Centring
|
||||
in the full line was the next attempt and is still not it: a line box is
|
||||
taller than the letters in it by the depth of a descender, so its middle sits
|
||||
below the middle of a capital. Measured, that left the box's top level with
|
||||
the capitals and its bottom 4px past the baseline — a square hanging off the
|
||||
line. Two tenths of an em off the line's height is where the capitals'
|
||||
middle actually is, for every face on offer here. */
|
||||
.note-editor .ProseMirror ul[data-type="taskList"] li > label {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: calc(1em * var(--ne-line));
|
||||
height: calc(1em * var(--ne-line) - 0.3em);
|
||||
user-select: none;
|
||||
}
|
||||
.note-editor .ProseMirror ul[data-type="taskList"] li > div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* Sized in em, so the box stays the size of a capital whatever the note's type
|
||||
size is. At 15px it was a 15px square against a 10px letter, which read as a
|
||||
box the text was standing next to rather than part of the same line. */
|
||||
.note-editor .ProseMirror ul[data-type="taskList"] input[type="checkbox"] {
|
||||
accent-color: var(--ne-accent);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.note-editor .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||
|
||||
@@ -162,7 +162,10 @@ function renderSuggestionList() {
|
||||
|
||||
const pick = (i) => {
|
||||
const item = items[i];
|
||||
if (item && command) command({ id: item.id, label: item.label, kind: item.kind });
|
||||
// The mark travels with the choice. Dropped here, every new link was born
|
||||
// wearing the default sheet of paper — the menu showed the note's own mark
|
||||
// and the link that came out of it did not.
|
||||
if (item && command) command({ id: item.id, label: item.label, kind: item.kind, icon: item.icon || null });
|
||||
};
|
||||
|
||||
const place = (rect) => {
|
||||
|
||||
@@ -133,29 +133,60 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
|
||||
* node — the link survived on screen and vanished on the next reload. */
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed || !resolveLinkLabel) return;
|
||||
const edits = [];
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name !== "noteLink") return;
|
||||
const label = resolveLinkLabel(node.attrs.noteId);
|
||||
const missing = label === null;
|
||||
const nextLabel = missing ? node.attrs.label : label;
|
||||
// The mark travels the same way, and for the same reason: it belongs to
|
||||
// the note, so it has to follow when the note's changes.
|
||||
const icon = (resolveLinkIcon ? resolveLinkIcon(node.attrs.noteId) : node.attrs.icon) ?? null;
|
||||
if (
|
||||
nextLabel !== node.attrs.label ||
|
||||
missing !== !!node.attrs.missing ||
|
||||
icon !== (node.attrs.icon ?? null)
|
||||
) {
|
||||
edits.push([pos, { ...node.attrs, label: nextLabel, missing, icon }]);
|
||||
}
|
||||
});
|
||||
if (edits.length === 0) return;
|
||||
const tr = editor.state.tr;
|
||||
for (const [pos, attrs] of edits) tr.setNodeMarkup(pos, undefined, attrs);
|
||||
tr.setMeta(LABEL_REFRESH, true);
|
||||
tr.setMeta("addToHistory", false); // undo should not step through renames
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
const refresh = () => {
|
||||
if (editor.isDestroyed) return;
|
||||
const edits = [];
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name !== "noteLink") return;
|
||||
const label = resolveLinkLabel(node.attrs.noteId);
|
||||
const missing = label === null;
|
||||
const nextLabel = missing ? node.attrs.label : label;
|
||||
// The mark travels the same way, and for the same reason: it belongs to
|
||||
// the note, so it has to follow when the note's changes.
|
||||
const icon = (resolveLinkIcon ? resolveLinkIcon(node.attrs.noteId) : node.attrs.icon) ?? null;
|
||||
if (
|
||||
nextLabel !== node.attrs.label ||
|
||||
missing !== !!node.attrs.missing ||
|
||||
icon !== (node.attrs.icon ?? null)
|
||||
) {
|
||||
edits.push([pos, { ...node.attrs, label: nextLabel, missing, icon }]);
|
||||
}
|
||||
});
|
||||
if (edits.length === 0) return;
|
||||
const tr = editor.state.tr;
|
||||
for (const [pos, attrs] of edits) tr.setNodeMarkup(pos, undefined, attrs);
|
||||
tr.setMeta(LABEL_REFRESH, true);
|
||||
tr.setMeta("addToHistory", false); // undo should not step through renames
|
||||
editor.view.dispatch(tr);
|
||||
};
|
||||
|
||||
refresh();
|
||||
|
||||
/* And again whenever the document changes.
|
||||
*
|
||||
* Running once was not enough, and the gap was the ordinary case: this
|
||||
* effect runs while the editor is still empty, and the note's content is
|
||||
* poured in afterwards — deliberately without an update event, so the
|
||||
* editor is not marked dirty by its own loading. Every link therefore kept
|
||||
* whatever it was saved with, which is why a reference always showed the
|
||||
* sheet of paper however its target was marked.
|
||||
*
|
||||
* Deferred by a frame rather than dispatched from inside the handler:
|
||||
* ProseMirror is still finishing the transaction that brought us here. */
|
||||
let frame = 0;
|
||||
const schedule = () => {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
editor.on("transaction", schedule);
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
editor.off("transaction", schedule);
|
||||
};
|
||||
}, [editor, resolveLinkLabel, resolveLinkIcon]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
|
||||
@@ -197,21 +197,30 @@ export function EditorPane({
|
||||
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 }}>
|
||||
{/* Aligned on the baseline, not on the box.
|
||||
Centring the two boxes looked right on paper and wrong on screen: a
|
||||
title's box is taller than its letters by the depth of a descender,
|
||||
so its capitals sit above the middle — while an emoji's picture fills
|
||||
its box and sat on the middle. Measured, the mark ended up 2px high
|
||||
and dipped below the line the title never crossed. Sharing a baseline
|
||||
is what "on one line" means, and it needs no number. */}
|
||||
<div style={{ display: "flex", alignItems: "baseline", 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. */
|
||||
title sideways the moment a mark was chosen. Laid out as text
|
||||
rather than as a flex box, because only text has a baseline for
|
||||
the title to line up with. */
|
||||
display: "inline-block",
|
||||
textAlign: "center",
|
||||
width: 26,
|
||||
height: 26,
|
||||
lineHeight: "26px",
|
||||
flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
@@ -219,11 +228,15 @@ export function EditorPane({
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
fontSize: 16,
|
||||
lineHeight: 1,
|
||||
color: note.iconColor || T.faint,
|
||||
}}
|
||||
>
|
||||
{note.icon || I.note}
|
||||
{note.icon || (
|
||||
/* A drawn glyph has no baseline of its own — the browser sets its
|
||||
bottom edge on the line, which leaves it floating above the
|
||||
letters. A single pixel below puts its middle on theirs. */
|
||||
<span style={{ display: "inline-block", verticalAlign: "-1px" }}>{I.note}</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -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 { END_ID, SORT_LABELS, mergeEntries } from "./noteSort";
|
||||
import { END_ID, SORT_LABELS, START_ID, mergeEntries } from "./noteSort";
|
||||
import { useReorderDrag } from "../../hooks/useReorderDrag";
|
||||
import { Rule } from "../../ui/separatorStyle.jsx";
|
||||
|
||||
@@ -842,14 +842,25 @@ export function NoteList({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* The one place a drop under a row cannot reach. Only while the
|
||||
list is being rearranged, so it costs nothing the rest of the
|
||||
time. */}
|
||||
{moving && (
|
||||
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 22 }}>
|
||||
{dnd.overId === START_ID && <DropLine T={T} />}
|
||||
</div>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
/* The line is drawn *under* the row, because that is where the
|
||||
row being carried is about to land. One direction only: a gap
|
||||
that can be aimed at from both sides is a gap nobody can aim
|
||||
at, and the two ends of the list have strips of their own.
|
||||
|
||||
The whole wrapper, line included, is the drop target. On the
|
||||
inner element the line itself was a dead strip — it grows as it
|
||||
appears, so the pointer could end up over the line rather than
|
||||
over any row, and releasing there dropped on nothing. */
|
||||
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 = moving ? dnd.rowProps(entry.id) : {};
|
||||
const lifted = dnd.draggingId === entry.id;
|
||||
|
||||
@@ -870,8 +881,9 @@ export function NoteList({
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||
{/* The header alone is the chapter for the purpose of a
|
||||
drop — an open one carries its notes inside this row. */}
|
||||
<div data-reorder-head style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||
{moving && <Grip T={T} {...dnd.gripProps} />}
|
||||
<ChapterRow
|
||||
T={T}
|
||||
@@ -882,6 +894,7 @@ export function NoteList({
|
||||
/>
|
||||
</div>
|
||||
{expandedChapter === entry.id && renderChapterNotes?.(entry.chapter)}
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -889,13 +902,13 @@ export function NoteList({
|
||||
if (entry.kind === "separator") {
|
||||
return (
|
||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<SeparatorRow
|
||||
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
|
||||
T={T}
|
||||
separator={entry.separator}
|
||||
onContextMenu={onSeparatorContextMenu}
|
||||
/>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -904,7 +917,6 @@ export function NoteList({
|
||||
const snippet = snippetFor(note.id);
|
||||
return (
|
||||
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||
{moving && <Grip T={T} {...dnd.gripProps} />}
|
||||
<button
|
||||
@@ -989,6 +1001,7 @@ export function NoteList({
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
END_ID,
|
||||
START_ID,
|
||||
canReorder,
|
||||
mergeEntries,
|
||||
placeOnTop,
|
||||
reindex,
|
||||
reindexEntries,
|
||||
sortModesFor,
|
||||
@@ -89,8 +91,10 @@ describe("reindex", () => {
|
||||
note("d", { sortOrder: 3 }),
|
||||
];
|
||||
|
||||
it("moves a note onto the position of the one it was dropped on", () => {
|
||||
expect(reindex(all, "d", "b")).toEqual(["a", "d", "b", "c"]);
|
||||
/* Under, always under. Both directions meant every gap could be aimed at from
|
||||
two sides, which is what made a drag hard to predict. */
|
||||
it("moves a note under the one it was dropped on", () => {
|
||||
expect(reindex(all, "d", "b")).toEqual(["a", "b", "d", "c"]);
|
||||
});
|
||||
|
||||
it("works upwards as well as downwards", () => {
|
||||
@@ -198,18 +202,25 @@ describe("reindexEntries", () => {
|
||||
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })];
|
||||
const separators = [{ id: "s1", label: "Teil 2", order: 1 }];
|
||||
|
||||
it("moves a note across a separator and keeps the separator where it was", () => {
|
||||
it("moves a note across a separator and leaves the line above the same note", () => {
|
||||
// a │ ─── │ b │ c, and c is dropped under a. The line still sits above b;
|
||||
// its number counts the rows before it, and there is one more of those now.
|
||||
const next = reindexEntries(notes, separators, "c", "a")!;
|
||||
expect(next.noteIds).toEqual(["c", "a", "b"]);
|
||||
expect(next.noteIds).toEqual(["a", "c", "b"]);
|
||||
expect(next.separators[0]!.order).toBe(2);
|
||||
});
|
||||
|
||||
it("moves a separator itself", () => {
|
||||
const next = reindexEntries(notes, separators, "s1", "a")!;
|
||||
const next = reindexEntries(notes, separators, "s1", START_ID)!;
|
||||
expect(next.separators[0]!.order).toBe(0);
|
||||
expect(next.noteIds).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("sends a row to the top when dropped on the strip above the first one", () => {
|
||||
// The one place a drop under a row cannot reach.
|
||||
expect(reindexEntries(notes, [], "c", START_ID)!.noteIds).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("sends a row to the end when dropped below the last one", () => {
|
||||
expect(reindexEntries(notes, [], "a", END_ID)!.noteIds).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
@@ -224,3 +235,45 @@ describe("reindexEntries", () => {
|
||||
expect(next.separators).toEqual([{ id: "s1", label: "x", order: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeOnTop", () => {
|
||||
/* A notebook someone has arranged: two notes with a line between them, and a
|
||||
chapter above both. The numbers are what a drag left behind. */
|
||||
const notes = [note("a", { sortOrder: 1 }), note("b", { sortOrder: 2 })];
|
||||
const chapters = [{ id: "ch", notebookId: "nb", name: "Kapitel", sortOrder: 0 }] as never;
|
||||
const separators = [{ id: "s1", label: "Teil 2", order: 2 }];
|
||||
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
|
||||
|
||||
const fresh = [note("neu", { sortOrder: 0 }), ...notes];
|
||||
const arranged = placeOnTop(fresh, separators, chapters, "neu")!;
|
||||
|
||||
it("puts the new note above everything, lines and chapters included", () => {
|
||||
expect(arranged.notes[0]).toEqual({ id: "neu", order: 0 });
|
||||
expect(arranged.chapters).toEqual([{ id: "ch", order: 1 }]);
|
||||
});
|
||||
|
||||
it("leaves every other row in the order it was in", () => {
|
||||
expect(arranged.noteIds).toEqual(["neu", "a", "b"]);
|
||||
});
|
||||
|
||||
/* The whole point. A separator's number counts the rows above it, so a row
|
||||
appearing at the top without a renumber leaves every line one row too high
|
||||
— which is how a notebook came apart a little more with each new note. */
|
||||
it("moves the lines down with the rows they sat above", () => {
|
||||
expect(arranged.separators[0]!.order).toBe(3);
|
||||
// Drawn again from exactly what was written back.
|
||||
const after = [note("neu", { sortOrder: 0 }), note("a", { sortOrder: 2 }), note("b", { sortOrder: 3 })];
|
||||
const movedChapters = [{ id: "ch", notebookId: "nb", name: "Kapitel", sortOrder: 1 }] as never;
|
||||
expect(kinds(mergeEntries(after, arranged.separators, movedChapters))).toEqual([
|
||||
"n:neu",
|
||||
"c:ch",
|
||||
"n:a",
|
||||
"s:s1",
|
||||
"n:b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports nothing to do for a note that is not in the list", () => {
|
||||
expect(placeOnTop(notes, separators, chapters, "ghost")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -203,6 +203,13 @@ export function toArrangement(entries: Entry[]): Arrangement {
|
||||
*
|
||||
* `notes` must be every note in the notebook, not the filtered view. Returns
|
||||
* null when the move changes nothing, which spares the caller a pointless write.
|
||||
*
|
||||
* A drop lands the row **under** the one it was released on, and only ever
|
||||
* there. Dropping above a row and dropping below the row before it are the same
|
||||
* move described twice, and having both meant every gap could be aimed at from
|
||||
* two sides — which is a hard thing to aim at and a harder thing to read. The
|
||||
* two strips, one above the first row and one below the last, are how the ends
|
||||
* are reached.
|
||||
*/
|
||||
export function reindexEntries(
|
||||
notes: NoteMeta[],
|
||||
@@ -216,21 +223,64 @@ export function reindexEntries(
|
||||
const from = entries.findIndex((e) => e.id === dragId);
|
||||
if (from < 0) return null;
|
||||
|
||||
// The strip below the last row means "put it at the end" — otherwise moving
|
||||
// something past the final note needs a drop onto the note itself, which puts
|
||||
// it above.
|
||||
const to = targetId === END_ID ? entries.length - 1 : entries.findIndex((e) => e.id === targetId);
|
||||
if (to < 0) return null;
|
||||
|
||||
const [moved] = entries.splice(from, 1);
|
||||
if (!moved) return null;
|
||||
entries.splice(to, 0, moved);
|
||||
|
||||
if (targetId === START_ID) entries.unshift(moved);
|
||||
else if (targetId === END_ID) entries.push(moved);
|
||||
else {
|
||||
// Found after the row has been lifted out, so the index is the one in the
|
||||
// list the row is being dropped into.
|
||||
const to = entries.findIndex((e) => e.id === targetId);
|
||||
if (to < 0) return null;
|
||||
entries.splice(to + 1, 0, moved);
|
||||
}
|
||||
return toArrangement(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole list renumbered with one row put at the very top.
|
||||
*
|
||||
* For a note that has just been made. It used to be given no position at all and
|
||||
* left to the tie-break, which put it near the top and moved everything else:
|
||||
* a separator's position counts the rows above it, so a row appearing at the top
|
||||
* without anybody being renumbered left every line one row too high — the
|
||||
* notebook's arrangement came apart a little more with each new note.
|
||||
*
|
||||
* Renumbering the list settles that, and settles it for good: rows that were
|
||||
* never dragged all carried position 0 and were being ordered by how recently
|
||||
* they changed, so they slid about on their own. Afterwards each keeps the place
|
||||
* it is in — which is what a hand-made order is for.
|
||||
*/
|
||||
export function placeOnTop(
|
||||
notes: NoteMeta[],
|
||||
separators: Separator[],
|
||||
chapters: Chapter[],
|
||||
id: string
|
||||
): Arrangement | null {
|
||||
const note = notes.find((n) => n.id === id);
|
||||
if (!note) return null;
|
||||
/* Built from the list *without* the new note, then the note put in front.
|
||||
*
|
||||
* Merging it in first would be measuring the room after the furniture moved:
|
||||
* the new row already counts as a row, so every line reads as sitting one
|
||||
* place further down than it was drawn, and renumbering would write that
|
||||
* misreading back as the truth. */
|
||||
const entries = mergeEntries(
|
||||
notes.filter((n) => n.id !== id),
|
||||
separators,
|
||||
chapters
|
||||
);
|
||||
entries.unshift({ kind: "note", id, order: 0, note });
|
||||
return toArrangement(entries);
|
||||
}
|
||||
|
||||
/** Drop target standing for "after everything". */
|
||||
export const END_ID = "__end__";
|
||||
|
||||
/** And its opposite: the one place a drop under a row cannot reach. */
|
||||
export const START_ID = "__start__";
|
||||
|
||||
/** A new separator, added at the top where it can be seen and then dragged. */
|
||||
export const withNewSeparator = (
|
||||
separators: Separator[],
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,7 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
text: dark ? "#e6edf3" : "#1f2328",
|
||||
hover: dark ? "#262c36" : "#f3f4f6",
|
||||
faint: dark ? "#7d8590" : "#8c959f",
|
||||
danger: dark ? "#f85149" : "#cf222e",
|
||||
}),
|
||||
[dark]
|
||||
@@ -76,15 +77,56 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{items.map((item, i) =>
|
||||
item.divider ? (
|
||||
<div key={i} style={{ height: 1, background: T.border, margin: "4px 6px" }} />
|
||||
) : item.submenu ? (
|
||||
<SubMenu key={i} item={item} T={T} onClose={onClose} />
|
||||
) : (
|
||||
<MenuButton key={i} T={T} item={item} onClose={onClose} />
|
||||
)
|
||||
)}
|
||||
{renderItems(items, T, onClose)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The items of one menu, in order.
|
||||
*
|
||||
* Shared by the menu and by anything that flies out of it, so a heading or a
|
||||
* divider reads the same wherever it appears.
|
||||
*/
|
||||
function renderItems(items, T, onClose, small) {
|
||||
return items.map((item, i) =>
|
||||
item.divider ? (
|
||||
<div key={i} style={{ height: 1, background: T.border, margin: "4px 6px" }} />
|
||||
) : item.heading ? (
|
||||
<Heading key={i} T={T}>
|
||||
{item.heading}
|
||||
</Heading>
|
||||
) : item.submenu ? (
|
||||
<SubMenu key={i} item={item} T={T} onClose={onClose} />
|
||||
) : (
|
||||
<MenuButton key={i} T={T} item={item} onClose={onClose} small={small} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the rows under it are.
|
||||
*
|
||||
* Deliberately unlike the rows: small, faint, spaced out and set left of them.
|
||||
* Given the shape of a menu entry it would be a button that does nothing when
|
||||
* pressed, which is worse than no heading at all.
|
||||
*/
|
||||
function Heading({ T, children }) {
|
||||
return (
|
||||
<div
|
||||
data-menu-heading
|
||||
style={{
|
||||
padding: "8px 10px 3px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.7,
|
||||
textTransform: "uppercase",
|
||||
color: T.faint,
|
||||
cursor: "default",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +161,23 @@ function MenuButton({ T, item, onClose, small }) {
|
||||
{item.colorDot && (
|
||||
<div style={{ width: 12, height: 8, borderRadius: 2, background: item.colorDot, flexShrink: 0 }} />
|
||||
)}
|
||||
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
||||
{/* A chapter's own mark, in its own colour, at full strength — the point
|
||||
of giving one a mark is to find it by that and not by reading. The
|
||||
drawn glyphs stay a shade back, as they always were. */}
|
||||
{item.icon && (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
lineHeight: 1,
|
||||
opacity: item.iconColor || typeof item.icon === "string" ? 1 : 0.7, // a mark, not a control
|
||||
color: item.iconColor || undefined,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
@@ -180,9 +238,7 @@ function SubMenu({ item, T, onClose }) {
|
||||
}
|
||||
}
|
||||
>
|
||||
{item.submenu.map((sub, j) => (
|
||||
<MenuButton key={j} T={T} item={sub} onClose={onClose} small />
|
||||
))}
|
||||
{renderItems(item.submenu, T, onClose, true)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user