Compare commits
2
Commits
312c4e190e
...
ea1bca5d25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea1bca5d25 | ||
|
|
f01522aa6f |
No files matched your search
@@ -8,6 +8,47 @@ not from the output of `docker push`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-17 — A chapter is a list too, and deleting stops moving things
|
||||||
|
|
||||||
|
**Images** — web `sha256:c1bcdcc4…`, server `sha256:6c9af404…`
|
||||||
|
|
||||||
|
No backend source changed this round; the server digest moved only because
|
||||||
|
`build.sh` builds both and a rebuild is not bit-identical.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **Deleting a note moved everything else.** A row leaving is the same problem as
|
||||||
|
a row arriving, in reverse: a line's position counts the rows above it, so a
|
||||||
|
note deleted above a line left the line counting rows that were no longer
|
||||||
|
there, and everything under it appeared to jump up past it. Every place a row
|
||||||
|
leaves a list now closes the gap — the bin, a move to another notebook or
|
||||||
|
chapter, and a chapter being removed. Coming back from the bin, or arriving by
|
||||||
|
a move, lands at the top like anything else arriving; an old position belongs
|
||||||
|
to an arrangement that has moved on.
|
||||||
|
- **A line could not be dragged under another line.** Two lines in the same gap
|
||||||
|
carry the same position, and the comparator said "a separator comes first"
|
||||||
|
without asking what the other one was — claiming both directions at once for
|
||||||
|
that case. A comparator that contradicts itself has no defined result, so the
|
||||||
|
sort returned them in whichever order it happened to walk, and the line sprang
|
||||||
|
back above every time. They keep the order they are written in now.
|
||||||
|
- **Removing a chapter is two moves, and only one was being made.** Its row
|
||||||
|
leaves the count, *and* the notes it held arrive in the notebook — at the top,
|
||||||
|
in the order they had inside it, since their old positions were numbered
|
||||||
|
against a list that no longer exists.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- **A chapter is always in the user's own order, and can be arranged like any
|
||||||
|
other list.** Its notes wear handles under the *same* switch as the list beside
|
||||||
|
them — two switches for two lists looked at together is one too many — and are
|
||||||
|
dragged among themselves, strips at both ends included. The two lists keep out
|
||||||
|
of each other's way by scope: a chapter unfolded under its row holds rows
|
||||||
|
inside a row of the notebook's list, so "the nearest row above the pointer"
|
||||||
|
cannot say which list a drag belongs to.
|
||||||
|
- **The sort menu is gone inside a chapter.** The mode belongs to the notebook
|
||||||
|
and governs the notebook's own list; a chapter is a place someone filled by
|
||||||
|
hand, and a menu there would offer a choice that cannot be honoured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-08-17 — One direction to drop in, and a new note that stays on top
|
## 2026-08-17 — One direction to drop in, and a new note that stays on top
|
||||||
|
|
||||||
**Images** — web `sha256:b8bc284a…`, server `sha256:a66cc8eb…`
|
**Images** — web `sha256:b8bc284a…`, server `sha256:a66cc8eb…`
|
||||||
|
|||||||
+107
-19
@@ -282,12 +282,17 @@ try {
|
|||||||
/* `where` picks the part of the row held over: a chapter is a place in the
|
/* `where` picks the part of the row held over: a chapter is a place in the
|
||||||
body of its header and a neighbour along the bottom edge of it, which is
|
body of its header and a neighbour along the bottom edge of it, which is
|
||||||
how a row can still be put in the gap under a chapter. */
|
how a row can still be put in the gap under a chapter. */
|
||||||
const holdOver = (dragId, chapterId, where = "body") =>
|
const holdOver = (dragId, chapterId, where = "body", scope = "") =>
|
||||||
p.evaluate(
|
p.evaluate(
|
||||||
([from, to, part]) => {
|
([from, to, part, sc]) => {
|
||||||
const row = document.querySelector(`[data-reorder-id="${from}"]`);
|
/* Both ends looked up in one list. The two ends of a list have strips
|
||||||
|
with the same names in every list on screen, so a bare id would find
|
||||||
|
the notebook's when the chapter's was meant. */
|
||||||
|
const find = (id) =>
|
||||||
|
document.querySelector(`[data-reorder-id="${id}"][data-reorder-scope="${sc}"]`);
|
||||||
|
const row = find(from);
|
||||||
const grip = row?.querySelector("[data-reorder-grip]");
|
const grip = row?.querySelector("[data-reorder-grip]");
|
||||||
const target = document.querySelector(`[data-reorder-id="${to}"]`);
|
const target = find(to);
|
||||||
if (!grip || !target) return `missing ${!!grip} ${!!target}`;
|
if (!grip || !target) return `missing ${!!grip} ${!!target}`;
|
||||||
const head = target.querySelector("[data-reorder-head]") || target;
|
const head = target.querySelector("[data-reorder-head]") || target;
|
||||||
const box = head.getBoundingClientRect();
|
const box = head.getBoundingClientRect();
|
||||||
@@ -312,7 +317,7 @@ try {
|
|||||||
fire(window, "pointermove", window.__drop.x, window.__drop.y);
|
fire(window, "pointermove", window.__drop.x, window.__drop.y);
|
||||||
return "held";
|
return "held";
|
||||||
},
|
},
|
||||||
[dragId, chapterId, where]
|
[dragId, chapterId, where, scope]
|
||||||
);
|
);
|
||||||
|
|
||||||
const letGo = () =>
|
const letGo = () =>
|
||||||
@@ -359,8 +364,17 @@ try {
|
|||||||
const looseBefore = (await titles()).length;
|
const looseBefore = (await titles()).length;
|
||||||
/* Both ends of the drag are read off the rows on screen: what is draggable is
|
/* Both ends of the drag are read off the rows on screen: what is draggable is
|
||||||
what is rendered, and the server's idea of the notebook includes rows this
|
what is rendered, and the server's idea of the notebook includes rows this
|
||||||
list is not showing. */
|
list is not showing.
|
||||||
const rowOrder = await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId));
|
Only the notebook's own rows: an open chapter draws its notes with handles
|
||||||
|
of their own, and those belong to a second list — dragging one of those from
|
||||||
|
here would be measuring one list against the other. */
|
||||||
|
const notebookRows = () =>
|
||||||
|
p
|
||||||
|
.locator("[data-reorder-id]")
|
||||||
|
.evaluateAll((els) =>
|
||||||
|
els.filter((e) => (e.dataset.reorderScope ?? "") === "").map((e) => e.dataset.reorderId)
|
||||||
|
);
|
||||||
|
const rowOrder = await notebookRows();
|
||||||
const shownChapters = await p
|
const shownChapters = await p
|
||||||
.locator("[data-chapter-row]")
|
.locator("[data-chapter-row]")
|
||||||
.evaluateAll((els) => els.map((e) => e.getAttribute("data-chapter-row")));
|
.evaluateAll((els) => els.map((e) => e.getAttribute("data-chapter-row")));
|
||||||
@@ -391,9 +405,7 @@ try {
|
|||||||
|
|
||||||
/* A line is drawn in the notebook and dragged in, which is how a chapter is
|
/* A line is drawn in the notebook and dragged in, which is how a chapter is
|
||||||
divided at all. */
|
divided at all. */
|
||||||
const sepRow = (
|
const sepRow = (await notebookRows()).find((id) => id && id.startsWith("sep-"));
|
||||||
await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId))
|
|
||||||
).find((id) => id && id.startsWith("sep-"));
|
|
||||||
if (sepRow) {
|
if (sepRow) {
|
||||||
await dragOnto(sepRow, shownChapters[0]);
|
await dragOnto(sepRow, shownChapters[0]);
|
||||||
await p.waitForTimeout(2500);
|
await p.waitForTimeout(2500);
|
||||||
@@ -414,9 +426,7 @@ try {
|
|||||||
* not be moved among its neighbours, and nothing could be put in the gap under
|
* not be moved among its neighbours, and nothing could be put in the gap under
|
||||||
* one. The bottom edge of the header is that gap, and asks for a line rather
|
* one. The bottom edge of the header is that gap, and asks for a line rather
|
||||||
* than lighting the chapter up. */
|
* than lighting the chapter up. */
|
||||||
const looseLeft = (
|
const looseLeft = (await notebookRows()).find(
|
||||||
await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId))
|
|
||||||
).find(
|
|
||||||
(id) =>
|
(id) =>
|
||||||
id &&
|
id &&
|
||||||
!id.startsWith("sep-") &&
|
!id.startsWith("sep-") &&
|
||||||
@@ -440,6 +450,75 @@ try {
|
|||||||
check(false, "a loose note was left to try the edge with", "none");
|
check(false, "a loose note was left to try the edge with", "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── a chapter is a list of its own, and can be arranged like one ──
|
||||||
|
* Its notes are shown with handles under the same switch as the list beside
|
||||||
|
* them, and dragged among themselves. They could not be dragged at all before:
|
||||||
|
* a chapter was somewhere to put notes and not somewhere to arrange them. */
|
||||||
|
/* The one that actually holds notes, asked of the server rather than assumed
|
||||||
|
to be the first row: which chapter comes first is an arrangement, and this
|
||||||
|
test is not about that. */
|
||||||
|
const fullest = (await api(p, "GET", "/notes")).body
|
||||||
|
.filter((n) => n.chapter_id)
|
||||||
|
.reduce((counts, n) => counts.set(n.chapter_id, (counts.get(n.chapter_id) || 0) + 1), new Map());
|
||||||
|
const busiest = [...fullest.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||||
|
const openRow = p.locator(`[data-chapter-row="${busiest}"]`);
|
||||||
|
if ((await p.locator(`[data-chapter-notes="${busiest}"]`).count()) === 0) {
|
||||||
|
await openRow.click();
|
||||||
|
await p.waitForTimeout(1600);
|
||||||
|
}
|
||||||
|
if ((await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle")) === "off") {
|
||||||
|
await p.locator("[data-reorder-toggle]").click();
|
||||||
|
await p.waitForTimeout(700);
|
||||||
|
}
|
||||||
|
const inChapterRows = () =>
|
||||||
|
p
|
||||||
|
.locator("[data-reorder-id]")
|
||||||
|
.evaluateAll((els) =>
|
||||||
|
els
|
||||||
|
.filter((e) => (e.dataset.reorderScope ?? "").startsWith("chapter:"))
|
||||||
|
.map((e) => e.dataset.reorderId)
|
||||||
|
);
|
||||||
|
const chapterOrder = () =>
|
||||||
|
p.locator("[data-chapter-note]").evaluateAll((els) => els.map((e) => e.innerText.trim()));
|
||||||
|
const handles = await inChapterRows();
|
||||||
|
check(handles.length > 0, "the notes inside a chapter wear handles too", JSON.stringify(handles));
|
||||||
|
// The name of the chapter's list, which both ends of the drag are looked up in.
|
||||||
|
const chapterScope = await p
|
||||||
|
.locator("[data-reorder-id]")
|
||||||
|
.evaluateAll(
|
||||||
|
(els) => els.map((e) => e.dataset.reorderScope ?? "").find((s) => s.startsWith("chapter:")) ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const beforeInside = await chapterOrder();
|
||||||
|
if (beforeInside.length >= 2) {
|
||||||
|
const rows = await inChapterRows();
|
||||||
|
const last = rows.filter((id) => id !== "__start__" && id !== "__end__").pop();
|
||||||
|
await holdOver(last, "__start__", "body", chapterScope);
|
||||||
|
await p.waitForTimeout(400);
|
||||||
|
await letGo();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
const afterInside = await chapterOrder();
|
||||||
|
check(
|
||||||
|
afterInside[0] === beforeInside[beforeInside.length - 1] && afterInside.length === beforeInside.length,
|
||||||
|
"and can be dragged among themselves, the strip at the top included",
|
||||||
|
`${beforeInside.join(",")} → ${afterInside.join(",")}`
|
||||||
|
);
|
||||||
|
// Written, not only drawn: the chapter's order is the notebook's business
|
||||||
|
// like any other, and has to survive the trip.
|
||||||
|
await p.reload({ waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(3000);
|
||||||
|
await openNotebook();
|
||||||
|
await openRow.click();
|
||||||
|
await p.waitForTimeout(1800);
|
||||||
|
check(
|
||||||
|
(await chapterOrder()).join(",") === afterInside.join(","),
|
||||||
|
"and the chapter comes back in the order it was left in",
|
||||||
|
(await chapterOrder()).join(",")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
check(false, "a chapter with two notes to arrange", beforeInside.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── a dialog does not close because a selection ended outside it ──
|
/* ── a dialog does not close because a selection ended outside it ──
|
||||||
* Select a word in the field, drag a little past the edge, let go: the click
|
* Select a word in the field, drag a little past the edge, let go: the click
|
||||||
* lands on the backdrop — the nearest ancestor of press and release — and the
|
* lands on the backdrop — the nearest ancestor of press and release — and the
|
||||||
@@ -537,8 +616,17 @@ try {
|
|||||||
|
|
||||||
/* ── a chapter is not a wastebasket ──
|
/* ── a chapter is not a wastebasket ──
|
||||||
* Removing one must never remove what it held: the notes come back to the
|
* Removing one must never remove what it held: the notes come back to the
|
||||||
* notebook, where they can be seen and filed again. */
|
* notebook, where they can be seen and filed again.
|
||||||
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
|
*
|
||||||
|
* The one holding notes, named rather than taken as "the first row": a chapter
|
||||||
|
* is a row in the arrangement like any other and can be anywhere in it. */
|
||||||
|
const holdings = (await api(p, "GET", "/notes")).body.filter((n) => n.chapter_id);
|
||||||
|
const doomed = holdings[0]?.chapter_id;
|
||||||
|
const freedTitles = holdings
|
||||||
|
.filter((n) => n.chapter_id === doomed)
|
||||||
|
.map((n) => n.title)
|
||||||
|
.sort();
|
||||||
|
await p.locator(`[data-chapter-row="${doomed}"]`).click({ button: "right", force: true });
|
||||||
await p.waitForTimeout(600);
|
await p.waitForTimeout(600);
|
||||||
await p.getByText("Remove chapter", { exact: true }).click();
|
await p.getByText("Remove chapter", { exact: true }).click();
|
||||||
await p.waitForTimeout(700);
|
await p.waitForTimeout(700);
|
||||||
@@ -562,15 +650,15 @@ try {
|
|||||||
"the chapter is gone, and only that one",
|
"the chapter is gone, and only that one",
|
||||||
String(await p.locator("[data-chapter-row]").count())
|
String(await p.locator("[data-chapter-row]").count())
|
||||||
);
|
);
|
||||||
const after = (await titles()).sort().join(",");
|
const after = (await titles()).sort();
|
||||||
check(
|
check(
|
||||||
after === "Gartenzaun,Stromrechnung,Wasserrechnung,Zählerstand",
|
freedTitles.every((t) => after.includes(t)),
|
||||||
"and every note it held is back in the notebook",
|
"and every note it held is back in the notebook",
|
||||||
after
|
`${freedTitles.join(",")} → ${after.join(",")}`
|
||||||
);
|
);
|
||||||
const freed = (await api(p, "GET", "/notes")).body;
|
const freed = (await api(p, "GET", "/notes")).body;
|
||||||
check(
|
check(
|
||||||
freed.every((n) => !n.chapter_id),
|
freed.every((n) => n.chapter_id !== doomed),
|
||||||
"with nothing on the server still pointing at a chapter that is gone",
|
"with nothing on the server still pointing at a chapter that is gone",
|
||||||
JSON.stringify(freed.map((n) => [n.title, n.chapter_id]))
|
JSON.stringify(freed.map((n) => [n.title, n.chapter_id]))
|
||||||
);
|
);
|
||||||
|
|||||||
+20
-1
@@ -344,6 +344,25 @@ try {
|
|||||||
(await layout()).join(" | ")
|
(await layout()).join(" | ")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ── and taking one away is the same promise, in reverse ──
|
||||||
|
* A line's position counts the rows above it, so deleting a note above one
|
||||||
|
* left the line counting rows that were no longer there: everything under it
|
||||||
|
* appeared to jump up past the line, which is what made deleting feel as
|
||||||
|
* though it scrambled the notebook. */
|
||||||
|
const beforeDelete = await layout();
|
||||||
|
await p.locator("[data-note-row]").first().click({ button: "right", force: true });
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
await p.locator("[data-context-menu]").getByText("Move to Trash", { exact: true }).click();
|
||||||
|
await p.waitForTimeout(700);
|
||||||
|
await p.locator("[data-confirm-dialog]").getByRole("button", { name: "Move to Trash", exact: true }).click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
const arrangedRows = await layout();
|
||||||
|
check(
|
||||||
|
arrangedRows.join(" | ") === beforeDelete.slice(1).join(" | "),
|
||||||
|
"deleting a note takes out only that note — the lines keep the rows they were drawn above",
|
||||||
|
`${beforeDelete.join(" | ")} → ${arrangedRows.join(" | ")}`
|
||||||
|
);
|
||||||
|
|
||||||
/* ── the choices reach the server, and the screen ──
|
/* ── the choices reach the server, and the screen ──
|
||||||
* A notebook divided into three parts is easier to read at a glance when the
|
* A notebook divided into three parts is easier to read at a glance when the
|
||||||
* lines differ; grey-on-grey they all look like the same line repeated. */
|
* lines differ; grey-on-grey they all look like the same line repeated. */
|
||||||
@@ -390,7 +409,7 @@ try {
|
|||||||
"and the question is gone with it, not left standing over the app"
|
"and the question is gone with it, not left standing over the app"
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
(await titles()).join(",") === afterNew.filter((row) => !row.startsWith("──")).join(","),
|
(await titles()).join(",") === arrangedRows.filter((row) => !row.startsWith("──")).join(","),
|
||||||
"with every note still there",
|
"with every note still there",
|
||||||
(await titles()).join(", ")
|
(await titles()).join(", ")
|
||||||
);
|
);
|
||||||
|
|||||||
+47
-9
@@ -29,7 +29,6 @@ import { ChapterNotes, NoteList } from "./features/notes/NoteList.jsx";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_NOTEBOOK_SORT,
|
DEFAULT_NOTEBOOK_SORT,
|
||||||
DEFAULT_SORT,
|
DEFAULT_SORT,
|
||||||
canReorder,
|
|
||||||
sortModesFor,
|
sortModesFor,
|
||||||
sortNotes,
|
sortNotes,
|
||||||
separatorsFor,
|
separatorsFor,
|
||||||
@@ -77,6 +76,13 @@ export default function App() {
|
|||||||
* question. It used to be answered silently by "a chapter is open", so a new
|
* question. It used to be answered silently by "a chapter is open", so a new
|
||||||
* note went into the chapter however far away the user had since clicked. */
|
* note went into the chapter however far away the user had since clicked. */
|
||||||
const [inChapter, setInChapter] = useState(false);
|
const [inChapter, setInChapter] = useState(false);
|
||||||
|
/* Rearranging is a mode, and it belongs to the screen rather than to one list
|
||||||
|
on it: a chapter shown beside or under the notebook is a second list, and
|
||||||
|
one switch for both is what "I am rearranging now" means. 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);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const [showNewNb, setShowNewNb] = useState(false);
|
const [showNewNb, setShowNewNb] = useState(false);
|
||||||
@@ -245,6 +251,12 @@ export default function App() {
|
|||||||
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, listChapter),
|
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, listChapter),
|
||||||
[data, activeFolder, listChapter]
|
[data, activeFolder, listChapter]
|
||||||
);
|
);
|
||||||
|
/* And the same for the chapter shown beside or under the list, which is a
|
||||||
|
second list on the same screen with an arrangement of its own. */
|
||||||
|
const rearrangeInChapter = useCallback(
|
||||||
|
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
|
||||||
|
[data, activeFolder, activeChapter]
|
||||||
|
);
|
||||||
|
|
||||||
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
|
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
|
||||||
const isTrashView = activeFolder === "trash";
|
const isTrashView = activeFolder === "trash";
|
||||||
@@ -356,6 +368,17 @@ export default function App() {
|
|||||||
|
|
||||||
const isSearching = searchQuery.trim().length > 0;
|
const isSearching = searchQuery.trim().length > 0;
|
||||||
|
|
||||||
|
/* Whether an arrangement is in effect, and so whether there is anything to
|
||||||
|
rearrange. A chapter always has one; the notebook's own list has one when it
|
||||||
|
is in the hand-made order. Searching suspends both — dragging against a
|
||||||
|
narrowed list would renumber notes it is hiding. */
|
||||||
|
const listReorderable = !!activeNotebook && !isSearching && (!!listChapter || sortMode === "manual");
|
||||||
|
const chapterReorderable = !!activeNotebook && !isSearching && !!activeChapter;
|
||||||
|
const listKey = `${activeFolder}:${listChapter ?? ""}`;
|
||||||
|
useEffect(() => {
|
||||||
|
setReordering(false);
|
||||||
|
}, [listKey, listReorderable]);
|
||||||
|
|
||||||
const filteredNotes = useMemo(() => {
|
const filteredNotes = useMemo(() => {
|
||||||
let list;
|
let list;
|
||||||
if (activeFolder === "trash") list = trashedNotes;
|
if (activeFolder === "trash") list = trashedNotes;
|
||||||
@@ -367,6 +390,13 @@ export default function App() {
|
|||||||
look like it had done nothing. */
|
look like it had done nothing. */
|
||||||
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
|
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
|
||||||
|
|
||||||
|
/* Inside a chapter the order is always the user's own.
|
||||||
|
*
|
||||||
|
* The mode belongs to the notebook and governs the notebook's own list; a
|
||||||
|
* chapter is a place someone made and filled by hand, and filing a note into
|
||||||
|
* one only means something if it stays where it was put. */
|
||||||
|
const mode = listChapter ? "manual" : sortMode;
|
||||||
|
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
// A search reads every note's text, so results keep the index's relevance
|
// A search reads every note's text, so results keep the index's relevance
|
||||||
// order instead of being re-sorted by date.
|
// order instead of being re-sorted by date.
|
||||||
@@ -378,7 +408,7 @@ export default function App() {
|
|||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
return list.filter((n) => n.title.toLowerCase().includes(q));
|
return list.filter((n) => n.title.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
return sortNotes(list, sortMode);
|
return sortNotes(list, mode);
|
||||||
}, [
|
}, [
|
||||||
activeFolder,
|
activeFolder,
|
||||||
listChapter,
|
listChapter,
|
||||||
@@ -389,11 +419,11 @@ export default function App() {
|
|||||||
sortMode,
|
sortMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* The notes of the chapter being looked into, in the same order they would
|
/* The notes of the chapter being looked into, in the order they were put in.
|
||||||
have in a list of their own. */
|
Always the hand-made one, as in the list — see `filteredNotes`. */
|
||||||
const chapterNotes = useMemo(
|
const chapterNotes = useMemo(
|
||||||
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []),
|
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), "manual") : []),
|
||||||
[activeChapter, activeNotes, sortMode]
|
[activeChapter, activeNotes]
|
||||||
);
|
);
|
||||||
/* And the lines drawn inside it, which are its own — the notebook's stay in
|
/* And the lines drawn inside it, which are its own — the notebook's stay in
|
||||||
the notebook, and each chapter's stay in that chapter. */
|
the notebook, and each chapter's stay in that chapter. */
|
||||||
@@ -849,9 +879,15 @@ export default function App() {
|
|||||||
const chapterContents = (compact) => (
|
const chapterContents = (compact) => (
|
||||||
<ChapterNotes
|
<ChapterNotes
|
||||||
T={T}
|
T={T}
|
||||||
|
chapterId={activeChapter}
|
||||||
notes={chapterNotes}
|
notes={chapterNotes}
|
||||||
separators={chapterSeparators}
|
separators={chapterSeparators}
|
||||||
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
|
reorderable={chapterReorderable}
|
||||||
|
/* One switch for the screen: turning rearranging on in the notebook's
|
||||||
|
list gives the chapter beside it handles too. Two switches for two
|
||||||
|
lists that are looked at together would be one too many. */
|
||||||
|
moving={reordering && chapterReorderable}
|
||||||
|
onReorder={rearrangeInChapter}
|
||||||
activeNote={activeNote}
|
activeNote={activeNote}
|
||||||
onSeparatorContextMenu={separatorContextMenu}
|
onSeparatorContextMenu={separatorContextMenu}
|
||||||
onSelectNote={openNote}
|
onSelectNote={openNote}
|
||||||
@@ -967,7 +1003,9 @@ export default function App() {
|
|||||||
sortMode={sortMode}
|
sortMode={sortMode}
|
||||||
sortModes={sortModesFor(!!activeNotebook)}
|
sortModes={sortModesFor(!!activeNotebook)}
|
||||||
onSortModeChange={setSortMode}
|
onSortModeChange={setSortMode}
|
||||||
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
|
reorderable={listReorderable}
|
||||||
|
reordering={reordering}
|
||||||
|
onToggleReorder={() => setReordering((v) => !v)}
|
||||||
separators={separators}
|
separators={separators}
|
||||||
onAddSeparator={addSeparator}
|
onAddSeparator={addSeparator}
|
||||||
onSeparatorContextMenu={separatorContextMenu}
|
onSeparatorContextMenu={separatorContextMenu}
|
||||||
@@ -983,7 +1021,7 @@ export default function App() {
|
|||||||
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
|
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
|
||||||
onReorder={rearrangeNotes}
|
onReorder={rearrangeNotes}
|
||||||
onFileInto={fileIntoChapter}
|
onFileInto={fileIntoChapter}
|
||||||
listKey={`${activeFolder}:${listChapter ?? ""}`}
|
listKey={listKey}
|
||||||
isCurrent={showsWhere ? !inChapter : null}
|
isCurrent={showsWhere ? !inChapter : null}
|
||||||
onFocusList={showsWhere ? () => setInChapter(false) : undefined}
|
onFocusList={showsWhere ? () => setInChapter(false) : undefined}
|
||||||
newNotePlace={
|
newNotePlace={
|
||||||
|
|||||||
@@ -216,36 +216,67 @@ function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
|
|||||||
*/
|
*/
|
||||||
export function ChapterNotes({
|
export function ChapterNotes({
|
||||||
T,
|
T,
|
||||||
|
chapterId,
|
||||||
notes,
|
notes,
|
||||||
separators,
|
separators,
|
||||||
reorderable,
|
reorderable,
|
||||||
|
moving = false,
|
||||||
|
onReorder,
|
||||||
activeNote,
|
activeNote,
|
||||||
onSeparatorContextMenu,
|
onSeparatorContextMenu,
|
||||||
onSelectNote,
|
onSelectNote,
|
||||||
onNoteContextMenu,
|
onNoteContextMenu,
|
||||||
compact = false,
|
compact = false,
|
||||||
}) {
|
}) {
|
||||||
// Lines only mean something where the order is the user's own, exactly as in
|
// A chapter is always in the order it was filled in, so its lines always mean
|
||||||
// the notebook's list — in "newest first" the notes slide past them.
|
// something and its rows can always be dragged.
|
||||||
const entries = reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry);
|
const entries = reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry);
|
||||||
|
/* Its own drag, scoped to this chapter.
|
||||||
|
*
|
||||||
|
* Unfolded under its row, these rows sit *inside* a row of the notebook's
|
||||||
|
* list, so "the nearest row above the pointer" cannot say which of the two
|
||||||
|
* lists is being dragged in. The scope says it. */
|
||||||
|
const dnd = useReorderDrag(
|
||||||
|
!!moving && !!onReorder,
|
||||||
|
onReorder || (() => {}),
|
||||||
|
undefined,
|
||||||
|
`chapter:${chapterId ?? ""}`
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{entries.length === 0 && (
|
{entries.length === 0 && (
|
||||||
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
|
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
|
||||||
)}
|
)}
|
||||||
|
{moving && entries.length > 0 && (
|
||||||
|
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 18 }}>
|
||||||
|
{dnd.overId === START_ID && <DropLine T={T} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{entries.map((entry) =>
|
{entries.map((entry) =>
|
||||||
entry.kind === "separator" ? (
|
entry.kind === "separator" ? (
|
||||||
<SeparatorRow
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
|
{...(moving ? dnd.rowProps(entry.id) : {})}
|
||||||
|
style={{ opacity: dnd.draggingId === entry.id ? 0.45 : 1 }}
|
||||||
|
>
|
||||||
|
<SeparatorRow
|
||||||
T={T}
|
T={T}
|
||||||
separator={entry.separator}
|
separator={entry.separator}
|
||||||
onContextMenu={onSeparatorContextMenu}
|
onContextMenu={onSeparatorContextMenu}
|
||||||
grip={null}
|
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
|
||||||
/>
|
/>
|
||||||
|
{moving && dnd.overId === entry.id && <DropLine T={T} />}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
|
{...(moving ? dnd.rowProps(entry.id) : {})}
|
||||||
|
style={{ opacity: dnd.draggingId === entry.id ? 0.45 : 1 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||||
|
{moving && <Grip T={T} {...dnd.gripProps} />}
|
||||||
|
<button
|
||||||
data-chapter-note={entry.id}
|
data-chapter-note={entry.id}
|
||||||
onClick={() => onSelectNote(entry.id)}
|
onClick={() => onSelectNote(entry.id)}
|
||||||
onContextMenu={(e) => onNoteContextMenu(e, entry.note)}
|
onContextMenu={(e) => onNoteContextMenu(e, entry.note)}
|
||||||
@@ -291,9 +322,17 @@ export function ChapterNotes({
|
|||||||
{entry.note.title}
|
{entry.note.title}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
{moving && dnd.overId === entry.id && <DropLine T={T} />}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{/* No button to add one here. A line is drawn in the notebook and dragged
|
{moving && entries.length > 0 && (
|
||||||
|
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 34 }}>
|
||||||
|
{dnd.overId === END_ID && <DropLine T={T} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* No button to add a line here. One is drawn in the notebook and dragged
|
||||||
into the chapter, the same way a note is put in one — a second way to
|
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
|
make the same thing, in a second place, is a second thing to keep
|
||||||
straight. */}
|
straight. */}
|
||||||
@@ -518,6 +557,10 @@ export function NoteList({
|
|||||||
query,
|
query,
|
||||||
onQueryChange,
|
onQueryChange,
|
||||||
reorderable,
|
reorderable,
|
||||||
|
/** Whether rearranging is switched on. Held above this list, because a chapter
|
||||||
|
shown beside it is a second list under the same switch. */
|
||||||
|
reordering,
|
||||||
|
onToggleReorder,
|
||||||
separators,
|
separators,
|
||||||
onAddSeparator,
|
onAddSeparator,
|
||||||
onSeparatorContextMenu,
|
onSeparatorContextMenu,
|
||||||
@@ -546,16 +589,7 @@ export function NoteList({
|
|||||||
onImportMarkdown,
|
onImportMarkdown,
|
||||||
onEmptyTrash,
|
onEmptyTrash,
|
||||||
}) {
|
}) {
|
||||||
/* Rearranging is a mode, switched on for as long as it is being done.
|
const moving = !!reorderable && !!reordering;
|
||||||
*
|
|
||||||
* 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
|
/* 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
|
browsers never fire from touch — on a phone the arrangement could not be
|
||||||
@@ -644,10 +678,13 @@ export function NoteList({
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||||
{reorderable && (
|
{reorderable && <ReorderToggle T={T} on={!!reordering} onToggle={onToggleReorder} />}
|
||||||
<ReorderToggle T={T} on={reordering} onToggle={() => setReordering((v) => !v)} />
|
{/* Not inside a chapter: the mode belongs to the notebook, and a
|
||||||
|
chapter is always in the order it was filled in. A menu there
|
||||||
|
would offer a choice that cannot be honoured. */}
|
||||||
|
{!isTrashView && !openChapter && (
|
||||||
|
<SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />
|
||||||
)}
|
)}
|
||||||
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
|
|
||||||
{!isTrashView && (
|
{!isTrashView && (
|
||||||
<AddMenu
|
<AddMenu
|
||||||
T={T}
|
T={T}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sortModesFor,
|
sortModesFor,
|
||||||
sortNotes,
|
sortNotes,
|
||||||
withNewSeparator,
|
withNewSeparator,
|
||||||
|
withoutRow,
|
||||||
} from "./noteSort";
|
} from "./noteSort";
|
||||||
import type { NoteMeta } from "../../types";
|
import type { NoteMeta } from "../../types";
|
||||||
|
|
||||||
@@ -277,3 +278,71 @@ describe("placeOnTop", () => {
|
|||||||
expect(placeOnTop(notes, separators, chapters, "ghost")).toBeNull();
|
expect(placeOnTop(notes, separators, chapters, "ghost")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("two lines in the same gap", () => {
|
||||||
|
/* A separator's position counts the rows above it, so two lines with nothing
|
||||||
|
between them carry the same number. Which of them comes first is then only
|
||||||
|
the order they are written in — and the comparator used to claim *both*
|
||||||
|
directions for that case, so a line dragged under another sprang back. */
|
||||||
|
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 })];
|
||||||
|
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
|
||||||
|
|
||||||
|
it("keeps the order they were written in", () => {
|
||||||
|
const two = [
|
||||||
|
{ id: "s1", label: "Erst", order: 1 },
|
||||||
|
{ id: "s2", label: "Dann", order: 1 },
|
||||||
|
];
|
||||||
|
expect(kinds(mergeEntries(notes, two))).toEqual(["n:a", "s:s1", "s:s2", "n:b"]);
|
||||||
|
expect(kinds(mergeEntries(notes, [two[1]!, two[0]!]))).toEqual(["n:a", "s:s2", "s:s1", "n:b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be swapped by dragging one under the other", () => {
|
||||||
|
const two = [
|
||||||
|
{ id: "s1", label: "Erst", order: 1 },
|
||||||
|
{ id: "s2", label: "Dann", order: 1 },
|
||||||
|
];
|
||||||
|
const next = reindexEntries(notes, two, "s1", "s2")!;
|
||||||
|
expect(next.separators.map((s) => s.id)).toEqual(["s2", "s1"]);
|
||||||
|
expect(kinds(mergeEntries(notes, next.separators))).toEqual(["n:a", "s:s2", "s:s1", "n:b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withoutRow", () => {
|
||||||
|
/* a │ b │ ─── │ c: deleting a note above the line used to leave the line
|
||||||
|
counting rows that were no longer there, so everything under it appeared to
|
||||||
|
jump up past it. */
|
||||||
|
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })];
|
||||||
|
const separators = [{ id: "s1", label: "Teil 2", order: 2 }];
|
||||||
|
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
|
||||||
|
|
||||||
|
it("closes the gap and takes the lines down with it", () => {
|
||||||
|
const next = withoutRow(notes, separators, [], "a")!;
|
||||||
|
expect(next.noteIds).toEqual(["b", "c"]);
|
||||||
|
expect(next.separators[0]!.order).toBe(1);
|
||||||
|
const left = [note("b", { sortOrder: 0 }), note("c", { sortOrder: 1 })];
|
||||||
|
expect(kinds(mergeEntries(left, next.separators))).toEqual(["n:b", "s:s1", "n:c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a line that was already above the gap where it was", () => {
|
||||||
|
const next = withoutRow(notes, [{ id: "s1", label: "", order: 1 }], [], "c")!;
|
||||||
|
expect(next.separators[0]!.order).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports nothing to do for a row that is not in the list", () => {
|
||||||
|
expect(withoutRow(notes, separators, [], "ghost")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("placeOnTop with several notes", () => {
|
||||||
|
/* Removing a chapter frees its notes into the notebook. They arrive together,
|
||||||
|
at the top, in the order they had inside it. */
|
||||||
|
const notes = [note("x", { sortOrder: 0 }), note("y", { sortOrder: 1 })];
|
||||||
|
const freed = [note("p"), note("q")];
|
||||||
|
const separators = [{ id: "s1", label: "", order: 1 }];
|
||||||
|
|
||||||
|
it("puts them all in front, in the order given", () => {
|
||||||
|
const next = placeOnTop([...freed, ...notes], separators, [], ["p", "q"])!;
|
||||||
|
expect(next.noteIds).toEqual(["p", "q", "x", "y"]);
|
||||||
|
expect(next.separators[0]!.order).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -161,11 +161,20 @@ export function mergeEntries(
|
|||||||
separator,
|
separator,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...rowEntries, ...separatorEntries].sort(
|
return [...rowEntries, ...separatorEntries].sort((a, b) => {
|
||||||
|
if (a.order !== b.order) return a.order - b.order;
|
||||||
|
/* Two lines in the same gap keep the order they were written in.
|
||||||
|
*
|
||||||
|
* This used to say "a separator comes first" without asking what the other
|
||||||
|
* one was — so for two separators it claimed *both* directions at once. A
|
||||||
|
* comparator that contradicts itself has no defined result, and the sort
|
||||||
|
* returned them in whichever order it happened to walk: a line dragged under
|
||||||
|
* another sprang back above it, every time. */
|
||||||
|
if (a.kind === "separator" && b.kind === "separator") return 0;
|
||||||
// A separator sitting at the same position as a row comes first, so
|
// A separator sitting at the same position as a row comes first, so
|
||||||
// "a line above this one" lands where it was asked for.
|
// "a line above this one" lands where it was asked for.
|
||||||
(a, b) => a.order - b.order || (a.kind === "separator" ? -1 : 1)
|
return a.kind === "separator" ? -1 : 1;
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The result of a drag: what to write back for each kind of row. */
|
/** The result of a drag: what to write back for each kind of row. */
|
||||||
@@ -256,22 +265,51 @@ export function placeOnTop(
|
|||||||
notes: NoteMeta[],
|
notes: NoteMeta[],
|
||||||
separators: Separator[],
|
separators: Separator[],
|
||||||
chapters: Chapter[],
|
chapters: Chapter[],
|
||||||
id: string
|
ids: string | string[]
|
||||||
): Arrangement | null {
|
): Arrangement | null {
|
||||||
const note = notes.find((n) => n.id === id);
|
const wanted = Array.isArray(ids) ? ids : [ids];
|
||||||
if (!note) return null;
|
const arriving = wanted
|
||||||
/* Built from the list *without* the new note, then the note put in front.
|
.map((id) => notes.find((n) => n.id === id))
|
||||||
|
.filter((n): n is NoteMeta => !!n);
|
||||||
|
if (arriving.length === 0) return null;
|
||||||
|
/* Built from the list *without* the new notes, then those put in front.
|
||||||
*
|
*
|
||||||
* Merging it in first would be measuring the room after the furniture moved:
|
* Merging them in first would be measuring the room after the furniture
|
||||||
* the new row already counts as a row, so every line reads as sitting one
|
* moved: a new row already counts as a row, so every line reads as sitting
|
||||||
* place further down than it was drawn, and renumbering would write that
|
* one place further down than it was drawn, and renumbering would write that
|
||||||
* misreading back as the truth. */
|
* misreading back as the truth. */
|
||||||
|
const held = new Set(arriving.map((n) => n.id));
|
||||||
const entries = mergeEntries(
|
const entries = mergeEntries(
|
||||||
notes.filter((n) => n.id !== id),
|
notes.filter((n) => !held.has(n.id)),
|
||||||
separators,
|
separators,
|
||||||
chapters
|
chapters
|
||||||
);
|
);
|
||||||
entries.unshift({ kind: "note", id, order: 0, note });
|
entries.unshift(...arriving.map((note) => ({ kind: "note" as const, id: note.id, order: 0, note })));
|
||||||
|
return toArrangement(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole list renumbered with one row taken out of it.
|
||||||
|
*
|
||||||
|
* For a note being thrown away or moved elsewhere. A row leaving is the same
|
||||||
|
* problem as a row arriving, in reverse: a separator's position counts the rows
|
||||||
|
* above it, so a note deleted above a line left that line one row too *low*, and
|
||||||
|
* everything below the line appeared to jump up past it. Nothing here moves
|
||||||
|
* except what is leaving.
|
||||||
|
*
|
||||||
|
* `notes` must still contain the row — it is the list as it is drawn, which is
|
||||||
|
* what the lines were counted against.
|
||||||
|
*/
|
||||||
|
export function withoutRow(
|
||||||
|
notes: NoteMeta[],
|
||||||
|
separators: Separator[],
|
||||||
|
chapters: Chapter[],
|
||||||
|
id: string
|
||||||
|
): Arrangement | null {
|
||||||
|
const entries = mergeEntries(notes, separators, chapters);
|
||||||
|
const at = entries.findIndex((e) => e.id === id);
|
||||||
|
if (at < 0) return null;
|
||||||
|
entries.splice(at, 1);
|
||||||
return toArrangement(entries);
|
return toArrangement(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import * as sync from "../syncEngine";
|
import * as sync from "../syncEngine";
|
||||||
import * as searchIndex from "../search";
|
import * as searchIndex from "../search";
|
||||||
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
||||||
import { placeOnTop, reindexEntries } from "../features/notes/noteSort";
|
import { placeOnTop, reindexEntries, sortNotes, withoutRow } from "../features/notes/noteSort";
|
||||||
import type { Arrangement } from "../features/notes/noteSort";
|
import type { Arrangement } from "../features/notes/noteSort";
|
||||||
import { extractOnImport } from "../features/pdf/pdfText";
|
import { extractOnImport } from "../features/pdf/pdfText";
|
||||||
import type {
|
import type {
|
||||||
@@ -248,16 +248,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n)));
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const trashNote = useCallback(async (id: string) => {
|
|
||||||
await sync.trashNote(id);
|
|
||||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const restoreNote = useCallback(async (id: string) => {
|
|
||||||
await sync.restoreNoteLocal(id);
|
|
||||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const permanentDeleteNote = useCallback(async (id: string) => {
|
const permanentDeleteNote = useCallback(async (id: string) => {
|
||||||
await sync.permanentDeleteNoteLocal(id);
|
await sync.permanentDeleteNoteLocal(id);
|
||||||
searchIndex.removeEntry(id);
|
searchIndex.removeEntry(id);
|
||||||
@@ -270,11 +260,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
setNotes((p) => p.filter((n) => !n.trashed));
|
setNotes((p) => p.filter((n) => !n.trashed));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const moveNote = useCallback(async (id: string, folderId: string, chapterId: string | null = null) => {
|
|
||||||
await sync.moveNote(id, folderId, chapterId);
|
|
||||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything one list is made of: its notes, its lines, its chapters.
|
* Everything one list is made of: its notes, its lines, its chapters.
|
||||||
*
|
*
|
||||||
@@ -392,6 +377,62 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
[applyArrangement, listOf]
|
[applyArrangement, listOf]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a row out of the list it is drawn in, and renumbers what is left.
|
||||||
|
*
|
||||||
|
* A row leaving is the same problem as a row arriving, in reverse. A line's
|
||||||
|
* position counts the rows above it, so deleting a note above a line left the
|
||||||
|
* line one row too low — and everything under it appeared to jump up past it.
|
||||||
|
* Called *before* the note is taken out of the store, because the list as it
|
||||||
|
* is drawn is what the lines were counted against.
|
||||||
|
*/
|
||||||
|
const takeOut = useCallback(
|
||||||
|
(note: NoteMeta | undefined) => {
|
||||||
|
if (!note?.folderId || note.trashed) return;
|
||||||
|
const chapterId = note.chapterId ?? null;
|
||||||
|
const list = listOf(note.folderId, chapterId);
|
||||||
|
if (!list) return;
|
||||||
|
const next = withoutRow(list.notes, list.mine, list.chapters, note.id);
|
||||||
|
if (!next) return;
|
||||||
|
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
|
||||||
|
},
|
||||||
|
[applyArrangement, listOf]
|
||||||
|
);
|
||||||
|
|
||||||
|
const trashNote = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
takeOut(notesRef.current.find((n) => n.id === id));
|
||||||
|
await sync.trashNote(id);
|
||||||
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n)));
|
||||||
|
},
|
||||||
|
[takeOut]
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Back from the bin and back to the top, like anything else arriving in a
|
||||||
|
list: its old position belongs to an arrangement that has moved on. */
|
||||||
|
const restoreNote = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await sync.restoreNoteLocal(id);
|
||||||
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n)));
|
||||||
|
const note = notesRef.current.find((n) => n.id === id);
|
||||||
|
if (note) putOnTop({ ...note, trashed: false });
|
||||||
|
},
|
||||||
|
[putOnTop]
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Out of one list and in at the top of the other. Both ends need saying: the
|
||||||
|
list it leaves has to close the gap, or its lines slip. */
|
||||||
|
const moveNote = useCallback(
|
||||||
|
async (id: string, folderId: string, chapterId: string | null = null) => {
|
||||||
|
const before = notesRef.current.find((n) => n.id === id);
|
||||||
|
takeOut(before);
|
||||||
|
await sync.moveNote(id, folderId, chapterId);
|
||||||
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
|
||||||
|
if (before) putOnTop({ ...before, folderId, chapterId });
|
||||||
|
},
|
||||||
|
[putOnTop, takeOut]
|
||||||
|
);
|
||||||
|
|
||||||
const createNote = useCallback(
|
const createNote = useCallback(
|
||||||
async (folderId: string, chapterId: string | null = null) => {
|
async (folderId: string, chapterId: string | null = null) => {
|
||||||
const note = await sync.createNote(folderId, chapterId);
|
const note = await sync.createNote(folderId, chapterId);
|
||||||
@@ -456,13 +497,40 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
await sync.updateChapter(id, updates);
|
await sync.updateChapter(id, updates);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removing a chapter: the row goes, the notes come back.
|
||||||
|
*
|
||||||
|
* Both halves have to be said in the notebook's arrangement, or the lines
|
||||||
|
* around them slip. The chapter's row is taken out of the count, and its notes
|
||||||
|
* arrive at the top in the order they had inside it — their old positions
|
||||||
|
* were numbered against the chapter's list, which no longer exists.
|
||||||
|
*/
|
||||||
const deleteChapter = useCallback(
|
const deleteChapter = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
const chapter = chaptersRef.current.find((c) => c.id === id);
|
||||||
|
const held = notesRef.current.filter((n) => !n.trashed && n.chapterId === id);
|
||||||
await sync.deleteChapter(id);
|
await sync.deleteChapter(id);
|
||||||
setChapters((p) => p.filter((c) => c.id !== id));
|
setChapters((p) => p.filter((c) => c.id !== id));
|
||||||
setNotes((p) => p.map((n) => (n.chapterId === id ? { ...n, chapterId: null } : n)));
|
setNotes((p) => p.map((n) => (n.chapterId === id ? { ...n, chapterId: null } : n)));
|
||||||
|
|
||||||
|
const notebookId = chapter?.notebookId;
|
||||||
|
if (!notebookId) return;
|
||||||
|
const list = listOf(notebookId, null);
|
||||||
|
if (!list) return;
|
||||||
|
const freed = sortNotes(held, "manual").map((n) => ({ ...n, chapterId: null }));
|
||||||
|
const next = freed.length
|
||||||
|
? placeOnTop(
|
||||||
|
[...freed, ...list.notes],
|
||||||
|
list.mine,
|
||||||
|
// Without the chapter itself: its row leaves the count as well.
|
||||||
|
list.chapters.filter((c) => c.id !== id),
|
||||||
|
freed.map((n) => n.id)
|
||||||
|
)
|
||||||
|
: // An empty one only leaves a gap to close.
|
||||||
|
withoutRow(list.notes, list.mine, list.chapters, id);
|
||||||
|
if (next) applyArrangement(notebookId, null, list.all, list.others, next);
|
||||||
},
|
},
|
||||||
[]
|
[applyArrangement, listOf]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface ReorderDrag {
|
|||||||
into?: boolean
|
into?: boolean
|
||||||
) => {
|
) => {
|
||||||
"data-reorder-id": string;
|
"data-reorder-id": string;
|
||||||
|
"data-reorder-scope": string;
|
||||||
"data-reorder-into"?: string;
|
"data-reorder-into"?: string;
|
||||||
onPointerDown: (e: React.PointerEvent) => void;
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
};
|
};
|
||||||
@@ -45,10 +46,19 @@ export interface ReorderDrag {
|
|||||||
gripProps: { "data-reorder-grip": string };
|
gripProps: { "data-reorder-grip": string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `scope` names the list these rows belong to, and is what keeps two lists on
|
||||||
|
* the same screen out of each other's way: a chapter unfolded under its row
|
||||||
|
* holds rows *inside* a row of the notebook's list, so "the nearest row above
|
||||||
|
* the pointer" is not enough to say which list is being dragged in. Rows are
|
||||||
|
* looked up by scope, and a note carried in the notebook simply finds no target
|
||||||
|
* over the notes inside a chapter.
|
||||||
|
*/
|
||||||
export function useReorderDrag(
|
export function useReorderDrag(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
reorder: (dragId: string, targetId: string) => void,
|
reorder: (dragId: string, targetId: string) => void,
|
||||||
fileInto?: (dragId: string, targetId: string) => void
|
fileInto?: (dragId: string, targetId: string) => void,
|
||||||
|
scope = ""
|
||||||
): ReorderDrag {
|
): ReorderDrag {
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
const [intoId, setIntoId] = useState<string | null>(null);
|
const [intoId, setIntoId] = useState<string | null>(null);
|
||||||
@@ -73,6 +83,12 @@ export function useReorderDrag(
|
|||||||
const onPointerDown = useCallback(
|
const onPointerDown = useCallback(
|
||||||
(id: string) => (e: React.PointerEvent) => {
|
(id: string) => (e: React.PointerEvent) => {
|
||||||
if (!enabled || e.button > 0) return;
|
if (!enabled || e.button > 0) return;
|
||||||
|
/* Only presses that belong to this list. A chapter's rows sit inside a row
|
||||||
|
of the notebook's list, so its events travel up through one — and a grip
|
||||||
|
pressed in the chapter would otherwise pick up the chapter's whole row
|
||||||
|
as well. The nearest scoped row decides whose press it is. */
|
||||||
|
const own = (e.target as HTMLElement)?.closest?.("[data-reorder-scope]") as HTMLElement | null;
|
||||||
|
if (own && own.dataset.reorderScope !== scope) return;
|
||||||
const fromGrip = !!(e.target as HTMLElement)?.closest?.("[data-reorder-grip]");
|
const fromGrip = !!(e.target as HTMLElement)?.closest?.("[data-reorder-grip]");
|
||||||
// A finger on the row itself is a scroll or a long press, not a drag.
|
// A finger on the row itself is a scroll or a long press, not a drag.
|
||||||
if (e.pointerType !== "mouse" && !fromGrip) return;
|
if (e.pointerType !== "mouse" && !fromGrip) return;
|
||||||
@@ -84,7 +100,7 @@ export function useReorderDrag(
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enabled]
|
[enabled, scope]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,7 +119,7 @@ export function useReorderDrag(
|
|||||||
// tracked with enter/leave handlers, because a finger generates no
|
// tracked with enter/leave handlers, because a finger generates no
|
||||||
// pointerover on anything it did not press.
|
// pointerover on anything it did not press.
|
||||||
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
||||||
const row = el?.closest?.("[data-reorder-id]") as HTMLElement | null;
|
const row = el?.closest?.(`[data-reorder-scope="${scope}"]`) as HTMLElement | null;
|
||||||
const target = row?.dataset.reorderId ?? null;
|
const target = row?.dataset.reorderId ?? null;
|
||||||
const next = target && target !== d.id ? target : null;
|
const next = target && target !== d.id ? target : null;
|
||||||
/* A chapter is both a place and a neighbour, so which one it is depends on
|
/* A chapter is both a place and a neighbour, so which one it is depends on
|
||||||
@@ -156,7 +172,7 @@ export function useReorderDrag(
|
|||||||
window.removeEventListener("pointerup", up);
|
window.removeEventListener("pointerup", up);
|
||||||
window.removeEventListener("pointercancel", stop);
|
window.removeEventListener("pointercancel", stop);
|
||||||
};
|
};
|
||||||
}, [enabled, reorder, fileInto, stop]);
|
}, [enabled, reorder, fileInto, scope, stop]);
|
||||||
|
|
||||||
// A drag that outlives its list would leave the page unselectable.
|
// A drag that outlives its list would leave the page unselectable.
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -172,6 +188,7 @@ export function useReorderDrag(
|
|||||||
draggingId,
|
draggingId,
|
||||||
rowProps: (id: string, into = false) => ({
|
rowProps: (id: string, into = false) => ({
|
||||||
"data-reorder-id": id,
|
"data-reorder-id": id,
|
||||||
|
"data-reorder-scope": scope,
|
||||||
...(into ? { "data-reorder-into": "" } : {}),
|
...(into ? { "data-reorder-into": "" } : {}),
|
||||||
onPointerDown: onPointerDown(id),
|
onPointerDown: onPointerDown(id),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -332,6 +332,14 @@ devices get different starts, because they want different things:
|
|||||||
The row under the finger is found with `elementFromPoint`, not with enter/leave
|
The row under the finger is found with `elementFromPoint`, not with enter/leave
|
||||||
handlers — a finger generates no `pointerover` on anything it did not press.
|
handlers — a finger generates no `pointerover` on anything it did not press.
|
||||||
|
|
||||||
|
**Two lines in the same gap keep the order they were written in.** A separator's
|
||||||
|
position counts the rows above it, so two lines with nothing between them carry
|
||||||
|
the same number, and only the order they are stored in can tell them apart. The
|
||||||
|
comparator used to say "a separator comes first" without asking what the other
|
||||||
|
one was — claiming both directions at once for that case. A comparator that
|
||||||
|
contradicts itself has no defined result, and a line dragged under another sprang
|
||||||
|
back above it every time.
|
||||||
|
|
||||||
**A drop lands the row under the one it was released on, and only ever there.**
|
**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
|
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 — which made a
|
described twice, so every gap could be aimed at from two sides — which made a
|
||||||
@@ -1105,6 +1113,27 @@ still reads newest-first — untouched rows all carry position 0 and fall back t
|
|||||||
it — so nothing is lost by starting there, while starting in "last changed" made
|
it — so nothing is lost by starting there, while starting in "last changed" made
|
||||||
both separators and dragging look broken until the sort menu was found.
|
both separators and dragging look broken until the sort menu was found.
|
||||||
|
|
||||||
|
**Inside a chapter the order is always the user's own.** The mode belongs to the
|
||||||
|
notebook and governs the notebook's own list; a chapter is a place someone made
|
||||||
|
and filled by hand, and filing a note into one only means something if it stays
|
||||||
|
where it was put. So a chapter has no sort menu of its own, its lines always
|
||||||
|
show, and its rows can always be dragged — with the *same* switch as the list
|
||||||
|
beside it, because two switches for two lists looked at together is one too many.
|
||||||
|
The two lists keep out of each other's way by scope: a chapter unfolded under its
|
||||||
|
row holds rows *inside* a row of the notebook's list, so "the nearest row above
|
||||||
|
the pointer" cannot say which list a drag belongs to.
|
||||||
|
|
||||||
|
**A row leaving is the same problem as a row arriving, in reverse.** Deleting a
|
||||||
|
note above a line left the line counting rows that were no longer there, so
|
||||||
|
everything under it appeared to jump up past it — which is what made deleting
|
||||||
|
feel as though it scrambled the notebook. `withoutRow` renumbers the list the row
|
||||||
|
is leaving, and every place a row leaves one says so: the bin, a move to another
|
||||||
|
notebook or chapter, and a chapter being removed. Restoring from the bin and
|
||||||
|
moving into a list are arrivals, and arrive at the top like anything else — an
|
||||||
|
old position belongs to an arrangement that has moved on. Removing a chapter is
|
||||||
|
both at once: its row leaves the count, and the notes it held arrive at the top
|
||||||
|
in the order they had inside it.
|
||||||
|
|
||||||
**A new note is put at the very top, and the list is renumbered to say so.**
|
**A new note is put at the very top, and the list is renumbered to say so.**
|
||||||
Left to the tie-break it landed near the top by age, which is not the same thing:
|
Left to the tie-break it landed 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
|
a separator's position counts the rows above it, so a row appearing without a
|
||||||
|
|||||||
Reference in new issue
Block a user