Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312c4e190e | ||
|
|
a9391cdcf2 |
No files matched your search
@@ -8,6 +8,59 @@ not from the output of `docker push`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-17 — One direction to drop in, and a new note that stays on top
|
||||
|
||||
**Images** — web `sha256:b8bc284a…`, server `sha256:a66cc8eb…`
|
||||
|
||||
No backend source changed this round; the server digest moved only because
|
||||
`build.sh` builds both and a rebuild is not bit-identical.
|
||||
|
||||
**Fixed**
|
||||
- **Every new note pulled the arrangement apart.** A note was made with no
|
||||
position at all 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, and a
|
||||
little more of the notebook's order came undone with each note. A new note is
|
||||
now put at the very top and the list is renumbered to say so; every other row
|
||||
keeps its place, lines included. Rows that had never been dragged stop sliding
|
||||
about as they are edited, because they now carry a position of their own.
|
||||
- **A chapter could only be dropped *into*, never dragged past.** Taking the
|
||||
whole row as a place quietly took reordering away: a chapter could not be moved
|
||||
among its neighbours, and nothing — note or line — could be put in the gap
|
||||
under one. The **bottom edge of a chapter's header is that gap** now; the rest
|
||||
of the row is still the chapter, and still lights up to say so.
|
||||
- **A link showed the sheet of paper whatever it pointed at**, despite last
|
||||
round's work. Two things: the `@` menu drew each note's own mark but dropped it
|
||||
on the way into the link, and the refresh that keeps a link's label and mark
|
||||
current ran once, while the editor was still empty — the note's content is
|
||||
poured in afterwards, deliberately without an update event, so nothing ever
|
||||
looked again. It now runs whenever the document changes.
|
||||
- **The mark in the title bar sat above the line of the title.** Both boxes were
|
||||
centred, which is not the same as being on one line: 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. Measured, the mark was 2px high
|
||||
and dipped below a line the title never crossed. They share a **baseline** now,
|
||||
which needs no number.
|
||||
- **The task list's box hung off the line it belonged to.** Centred in the full
|
||||
line it was a 15px square against a 10px letter, its top level with the
|
||||
capitals and its bottom 4px past the baseline. It is centred on the *letters*
|
||||
and sized in em, so it stays the size of a capital at every type size.
|
||||
|
||||
**Changed**
|
||||
- **A drop 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 — hard to aim
|
||||
at, harder to read. There is now a strip **above the first row** as well as
|
||||
below the last, because that is the one place a drop *under* something cannot
|
||||
reach.
|
||||
- ***Move to…* is divided under headings** — the notebook itself, *Chapters
|
||||
here*, *Other notebooks* — instead of running two kinds of place together with
|
||||
a rule between them. The headings are deliberately not shaped like entries: one
|
||||
built like a menu row is a button that does nothing when pressed. Each row
|
||||
carries the mark its chapter was given or the cover its notebook wears.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-17 — One dialog per thing, and rows dragged into a chapter
|
||||
|
||||
**Images** — web `sha256:18a68a2d…`, server `sha256:febc4b93…`
|
||||
|
||||
+51
-6
@@ -279,15 +279,22 @@ try {
|
||||
/* Held and dropped in two steps, with a wait between: the highlight is React
|
||||
state, and asking for it in the same breath as the move that caused it reads
|
||||
the DOM before it has been drawn. */
|
||||
const holdOver = (dragId, chapterId) =>
|
||||
/* `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
|
||||
how a row can still be put in the gap under a chapter. */
|
||||
const holdOver = (dragId, chapterId, where = "body") =>
|
||||
p.evaluate(
|
||||
([from, to]) => {
|
||||
([from, to, part]) => {
|
||||
const row = document.querySelector(`[data-reorder-id="${from}"]`);
|
||||
const grip = row?.querySelector("[data-reorder-grip]");
|
||||
const target = document.querySelector(`[data-reorder-id="${to}"]`);
|
||||
if (!grip || !target) return `missing ${!!grip} ${!!target}`;
|
||||
const box = target.getBoundingClientRect();
|
||||
window.__drop = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
const head = target.querySelector("[data-reorder-head]") || target;
|
||||
const box = head.getBoundingClientRect();
|
||||
window.__drop = {
|
||||
x: box.x + box.width / 2,
|
||||
y: part === "edge" ? box.bottom - 2 : box.y + box.height / 2,
|
||||
};
|
||||
const fire = (el, type, x, y) =>
|
||||
el.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
@@ -305,7 +312,7 @@ try {
|
||||
fire(window, "pointermove", window.__drop.x, window.__drop.y);
|
||||
return "held";
|
||||
},
|
||||
[dragId, chapterId]
|
||||
[dragId, chapterId, where]
|
||||
);
|
||||
|
||||
const letGo = () =>
|
||||
@@ -357,8 +364,15 @@ try {
|
||||
const shownChapters = await p
|
||||
.locator("[data-chapter-row]")
|
||||
.evaluateAll((els) => els.map((e) => e.getAttribute("data-chapter-row")));
|
||||
// The two strips at the ends of the list are drop targets, not rows: they
|
||||
// carry a reorder id and nothing else, least of all a grip to drag by.
|
||||
const noteId = rowOrder.find(
|
||||
(id) => id && !id.startsWith("sep-") && id !== "__end__" && !shownChapters.includes(id)
|
||||
(id) =>
|
||||
id &&
|
||||
!id.startsWith("sep-") &&
|
||||
id !== "__end__" &&
|
||||
id !== "__start__" &&
|
||||
!shownChapters.includes(id)
|
||||
);
|
||||
check(
|
||||
!!noteId && !!shownChapters[0],
|
||||
@@ -395,6 +409,37 @@ try {
|
||||
check(false, "a separator was there to drag", "none found");
|
||||
}
|
||||
|
||||
/* ── a chapter is a neighbour too ──
|
||||
* Taking the whole row as "into" quietly took reordering away: a chapter could
|
||||
* 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
|
||||
* than lighting the chapter up. */
|
||||
const looseLeft = (
|
||||
await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId))
|
||||
).find(
|
||||
(id) =>
|
||||
id &&
|
||||
!id.startsWith("sep-") &&
|
||||
id !== "__end__" &&
|
||||
id !== "__start__" &&
|
||||
!shownChapters.includes(id)
|
||||
);
|
||||
if (looseLeft) {
|
||||
const held = await holdOver(looseLeft, shownChapters[0], "edge");
|
||||
await p.waitForTimeout(400);
|
||||
const litAtEdge = await p.locator("[data-chapter-drop]").count();
|
||||
const lineAtEdge = await p.locator("[data-drop-line]").count();
|
||||
await letGo();
|
||||
await p.waitForTimeout(2000);
|
||||
check(
|
||||
held === "held" && litAtEdge === 0 && lineAtEdge === 1,
|
||||
"held over the bottom edge of a chapter, it offers a line instead of taking the row in",
|
||||
JSON.stringify({ held, litAtEdge, lineAtEdge })
|
||||
);
|
||||
} else {
|
||||
check(false, "a loose note was left to try the edge with", "none");
|
||||
}
|
||||
|
||||
/* ── 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
|
||||
* lands on the backdrop — the nearest ancestor of press and release — and the
|
||||
|
||||
+73
-4
@@ -127,13 +127,23 @@ try {
|
||||
};
|
||||
|
||||
const rows = p.locator("[data-note-row]");
|
||||
/* A drop lands *under* the row it was released on, and only there — both
|
||||
directions meant every gap could be aimed at from two sides. */
|
||||
await drag(rows.nth(2), rows.nth(0));
|
||||
const under = await titles();
|
||||
check(
|
||||
under[1] === beforeDrag[2] && under[0] === beforeDrag[0],
|
||||
"dropping a note on a row puts it under that row",
|
||||
`${beforeDrag.join(",")} → ${under.join(",")}`
|
||||
);
|
||||
|
||||
// Which leaves the very top out of reach, so it has a strip of its own.
|
||||
await drag(rows.nth(2), p.locator("[data-drop-start]"));
|
||||
const arranged = await titles();
|
||||
check(
|
||||
arranged[0] === beforeDrag[2],
|
||||
"dragging a note to the top puts it there",
|
||||
`${beforeDrag.join(",")} → ${arranged.join(",")}`
|
||||
arranged[0] === under[2],
|
||||
"and the strip above the first row puts it at the very top",
|
||||
`${under.join(",")} → ${arranged.join(",")}`
|
||||
);
|
||||
|
||||
/* The search box is simply there, with nothing to open first. A magnifying
|
||||
@@ -293,6 +303,47 @@ try {
|
||||
await p.waitForTimeout(800);
|
||||
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
|
||||
|
||||
/* ── a new note goes on top, and takes nothing with it ──
|
||||
*
|
||||
* 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 line's position counts the rows
|
||||
* above it, so a row appearing without a renumber left every line one row too
|
||||
* high, and the arrangement came apart a little more with each new note. */
|
||||
const layout = () =>
|
||||
p
|
||||
.locator("[data-note-row], [data-separator]")
|
||||
.evaluateAll((els) =>
|
||||
els.map((el) =>
|
||||
el.hasAttribute("data-separator") ? `── ${el.innerText.trim()}` : el.innerText.split("\n")[0].trim()
|
||||
)
|
||||
);
|
||||
const beforeNew = await layout();
|
||||
await createNote(p);
|
||||
await p.waitForTimeout(2500);
|
||||
const afterNew = await layout();
|
||||
check(
|
||||
afterNew[0] === "Untitled Note",
|
||||
"a new note is made at the very top, above the lines as well",
|
||||
afterNew.join(" | ")
|
||||
);
|
||||
check(
|
||||
afterNew.slice(1).join(" | ") === beforeNew.join(" | "),
|
||||
"and every other row stayed exactly where it was",
|
||||
`${beforeNew.join(" | ")} → ${afterNew.join(" | ")}`
|
||||
);
|
||||
|
||||
// And it survives the trip to the server: the positions were written, not
|
||||
// only drawn.
|
||||
await p.reload({ waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||||
await p.waitForTimeout(1800);
|
||||
check(
|
||||
(await layout()).join(" | ") === afterNew.join(" | "),
|
||||
"and the arrangement comes back the same after a reload",
|
||||
(await layout()).join(" | ")
|
||||
);
|
||||
|
||||
/* ── the choices reach the server, and the screen ──
|
||||
* 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. */
|
||||
@@ -338,7 +389,11 @@ try {
|
||||
(await p.locator("[data-confirm-dialog]").count()) === 0,
|
||||
"and the question is gone with it, not left standing over the app"
|
||||
);
|
||||
check((await titles()).length === 3, "with every note still there", (await titles()).join(", "));
|
||||
check(
|
||||
(await titles()).join(",") === afterNew.filter((row) => !row.startsWith("──")).join(","),
|
||||
"with every note still there",
|
||||
(await titles()).join(", ")
|
||||
);
|
||||
|
||||
/* ── exporting a note and importing it back ── */
|
||||
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click();
|
||||
@@ -640,6 +695,20 @@ try {
|
||||
"and the notebooks it offers are on the screen, not past its edge",
|
||||
JSON.stringify(await sub.boundingBox())
|
||||
);
|
||||
|
||||
/* It holds two different kinds of place, so it says which is which. The
|
||||
headings are deliberately not shaped like entries: one built like a menu row
|
||||
is a button that does nothing when pressed. */
|
||||
const headings = await sub.locator("[data-menu-heading]").allInnerTexts();
|
||||
check(
|
||||
headings.some((h) => /other notebooks/i.test(h)),
|
||||
"the list is divided under headings rather than run together",
|
||||
headings.join(" / ")
|
||||
);
|
||||
check(
|
||||
(await sub.locator("[data-menu-heading]").evaluateAll((els) => els.every((el) => el.tagName !== "BUTTON"))),
|
||||
"and a heading is not a button"
|
||||
);
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(400);
|
||||
await p.setViewportSize({ width: 1400, height: 950 });
|
||||
|
||||
+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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
handlers — a finger generates no `pointerover` on anything it did not press.
|
||||
|
||||
**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, so every gap could be aimed at from two sides — which made a
|
||||
drag hard to aim and harder to read. One direction, one line, drawn under the
|
||||
row it will land under. The two ends of the list have strips of their own: the
|
||||
one below the last row was always there, and there is now one above the first,
|
||||
because that is the single place a drop *under* something cannot reach.
|
||||
|
||||
**Rearranging is a mode**, switched on by the handle button beside the sort menu
|
||||
and off again by itself when the list is left or the order stops being a
|
||||
hand-made one. Only while it is on does a row wear its grip or move at all. Rows
|
||||
@@ -348,6 +356,13 @@ hover, there was no way to open it in the first place. It is now placed by
|
||||
measuring, opens on a tap, unfolds in place on a narrow screen, and flips to the
|
||||
other side on a wide one when the right would not fit.
|
||||
|
||||
*Move to…* holds two different kinds of place, so it **says which is which**:
|
||||
the notebook itself, then *Chapters here*, then *Other notebooks*. The headings
|
||||
are deliberately not shaped like entries — small, faint and set apart — because
|
||||
a heading built like a menu row is a button that does nothing when pressed. Each
|
||||
row carries the mark its chapter was given or the cover its notebook wears, so a
|
||||
place is recognised in the menu the same way it is recognised in the list.
|
||||
|
||||
### Installing it
|
||||
|
||||
The install entry used to appear only when the browser had offered a prompt
|
||||
@@ -1017,8 +1032,15 @@ took three visits. Its menu therefore holds one *Edit* and one *Remove*.
|
||||
|
||||
**A row is put into a chapter by dragging it onto it.** While rearranging is on,
|
||||
a chapter row takes what is dropped on it and lights up to say so, rather than
|
||||
growing a drop line above it: a chapter is a place, not a neighbour. Notes and
|
||||
separators both go in this way.
|
||||
growing a drop line: a chapter is a place, not a neighbour. Notes and separators
|
||||
both go in this way.
|
||||
|
||||
But it is a neighbour as well, and taking the *whole* row as "into" quietly took
|
||||
that away — a chapter could no longer be moved among its neighbours, and nothing
|
||||
could be put in the gap under one. So the **bottom edge of a chapter's header is
|
||||
the gap** and the rest of it is the chapter. The header, not the row: an open
|
||||
chapter carries its notes inside that row, and holding over those plainly means
|
||||
the chapter.
|
||||
|
||||
A chapter shows even when the notebook holds nothing else. "Nothing here" is
|
||||
asked of every row, not of the notes alone — asked of the notes, a notebook whose
|
||||
@@ -1083,6 +1105,16 @@ 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
|
||||
both separators and dragging look broken until the sort menu was found.
|
||||
|
||||
**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:
|
||||
a separator's position counts the rows above it, so a row appearing without a
|
||||
renumber left every line one row too high, and the arrangement came apart a
|
||||
little more with each new note. `placeOnTop` builds the order from the list
|
||||
*without* the new note — measuring the room after the furniture has moved would
|
||||
write the misreading back as the truth — and puts the note in front of it. Every
|
||||
other row keeps its place, and the rows that had never been dragged stop sliding
|
||||
about, because now they carry a position of their own.
|
||||
|
||||
The views are different: "All Notes", "Starred" and the bin are questions asked
|
||||
of every notebook at once, so there is nothing for an arrangement to belong to
|
||||
and nowhere to keep it. They start in "last changed" and are not offered the
|
||||
|
||||
Reference in new issue
Block a user