A filed note stays filed, and the handles are asked for
Three things that all came from chapters being thought through too late. A note put into a chapter came back out of it a few seconds later. The server had it right the whole time; every sync rebuilds the local copy from the server's answer, and the function doing that never carried chapter_id — so each sync handed back a note belonging to no chapter and the notebook took it in again. The bin's query left the column out too, which would have unfiled anything restored from it. A separator drawn in a notebook vanished when a chapter was opened. "Which chapter is open" and "which list is on screen" were one value. Opened beside or below, the list still shows the notebook — but everything around it had switched to the chapter, so the notebook's own separators were filtered away and a drag among its rows was measured against the chapter's notes, found nothing, and did nothing. And where a new note lands is now visible and yours to choose: pressing in either place makes it the one being worked in, marked with a line along its top and the word "here", and the + button names it. Rearranging becomes a mode, switched on beside the sort menu — until then no row wears a grip and none can be dragged. Filing a note is back in the row's own menu, where starring and exporting already are; the button that had appeared in every row is gone with its dialog. The account panel closes with Escape. Thirteen suites green: 335 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4bb86bc5ac
commit
b2a5f051fe
12 files changed
+406
-291
No files matched your search
@@ -8,6 +8,45 @@ not from the output of `docker push`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-12 — A filed note stays filed
|
||||||
|
|
||||||
|
**Images** — web `sha256:…`, server `sha256:…`
|
||||||
|
|
||||||
|
The server image changed: the bin now hands back a note's chapter with it.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **A note put into a chapter came back out of it by itself**, a few seconds
|
||||||
|
later. The server had filed it correctly the whole time; the sync that follows
|
||||||
|
rebuilds every local note from the server's answer, and the function doing
|
||||||
|
that never carried `chapter_id` — so each sync handed back a note belonging to
|
||||||
|
no chapter and the notebook's list took it in again. The bin's query left the
|
||||||
|
column out too, which would have unfiled anything restored from it.
|
||||||
|
- **A separator drawn in a notebook vanished when a chapter was opened.** "Which
|
||||||
|
chapter is open" and "which list is on screen" were one value. Opened beside or
|
||||||
|
below, the list still shows the notebook — but everything around it had
|
||||||
|
switched to the chapter, so the notebook's own separators were filtered away
|
||||||
|
and a drag among its rows was measured against the chapter's notes, found
|
||||||
|
nothing, and did nothing.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- **Rearranging is a mode now.** The handle button beside the sort menu switches
|
||||||
|
it on; until then no row shows its grip and none can be dragged, and it goes
|
||||||
|
off again by itself when the list is left. Six dots stood beside every title
|
||||||
|
all the time for something done rarely, and an ordinary press could carry a
|
||||||
|
row off.
|
||||||
|
- **Filing a note is back in the row's own menu** and the button that had
|
||||||
|
appeared in every row is gone, with the dialog behind it. It belongs with
|
||||||
|
starring, exporting and discarding — things done to a row, in one place.
|
||||||
|
- **Which place a new note lands in is now visible and yours to choose.** With a
|
||||||
|
chapter open beside or below, the notebook's list and the chapter are both on
|
||||||
|
screen: pressing in either makes it the one being worked in, marked with a line
|
||||||
|
along its top and the word *here*, and the + button names it. Before, a new
|
||||||
|
note went into the chapter however far away you had since clicked.
|
||||||
|
- **The account panel closes with Escape.** Only a click beside it did, which on
|
||||||
|
a panel that wide means aiming at a sliver of page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-08-12 — Chapters as rows, in two views
|
## 2026-08-12 — Chapters as rows, in two views
|
||||||
|
|
||||||
**Images** — web `sha256:95657aaf…`, server `sha256:b4db0725…`
|
**Images** — web `sha256:95657aaf…`, server `sha256:b4db0725…`
|
||||||
|
|||||||
+3
-1
@@ -1085,7 +1085,9 @@ app.get("/api/notes", authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/notes/trash", authMiddleware, (req, res) => {
|
app.get("/api/notes/trash", authMiddleware, (req, res) => {
|
||||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
|
// `chapter_id` travels with it: a note restored from the bin belongs back in
|
||||||
|
// the chapter it was filed in, not loose in the notebook.
|
||||||
|
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
|
||||||
res.json(notes);
|
res.json(notes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+100
-15
@@ -31,16 +31,22 @@ try {
|
|||||||
await p.getByRole("button", { name: "Create", exact: true }).click();
|
await p.getByRole("button", { name: "Create", exact: true }).click();
|
||||||
await p.waitForTimeout(2000);
|
await p.waitForTimeout(2000);
|
||||||
};
|
};
|
||||||
/* One press of the button on the row, then choose. It used to be a
|
/* Filing a note is one of the things a row can have done to it, and they all
|
||||||
right-click into a submenu — two steps before the places even appeared, and
|
live in the row's own menu. A button for this one on every row put a second
|
||||||
on a phone a long press followed by a flyout. */
|
icon beside every title, for something done once in a note's life. */
|
||||||
const moveTo = async (noteTitle, target) => {
|
const moveTo = async (noteTitle, target) => {
|
||||||
const row = p.locator("[data-note-row]").filter({ hasText: noteTitle }).first();
|
const row = p.locator("[data-note-row]").filter({ hasText: noteTitle }).first();
|
||||||
await row.locator("xpath=preceding-sibling::button[@data-move-note]").first().click();
|
await row.click({ button: "right", force: true });
|
||||||
await p.waitForTimeout(700);
|
await p.waitForTimeout(600);
|
||||||
await p.locator("[data-move-target]").filter({ hasText: target }).first().click();
|
await p.locator("[data-submenu-toggle]").filter({ hasText: "Move to" }).first().click();
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
await p.locator("[data-submenu] button").filter({ hasText: target }).first().click();
|
||||||
await p.waitForTimeout(2500);
|
await p.waitForTimeout(2500);
|
||||||
};
|
};
|
||||||
|
const openNotebook = async () => {
|
||||||
|
await p.locator("button").filter({ hasText: "Haus" }).first().click();
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
};
|
||||||
|
|
||||||
await createNotebook(p, "Haus");
|
await createNotebook(p, "Haus");
|
||||||
await p.locator("button").filter({ hasText: "Haus" }).first().click();
|
await p.locator("button").filter({ hasText: "Haus" }).first().click();
|
||||||
@@ -62,8 +68,8 @@ try {
|
|||||||
|
|
||||||
/* ── filing a note ── */
|
/* ── filing a note ── */
|
||||||
check(
|
check(
|
||||||
(await p.locator("[data-move-note]").count()) === 2,
|
(await p.locator("[data-move-note]").count()) === 0,
|
||||||
"every note has a move button, without a right-click",
|
"filing is in the row's menu, not a button standing in every row",
|
||||||
String(await p.locator("[data-move-note]").count())
|
String(await p.locator("[data-move-note]").count())
|
||||||
);
|
);
|
||||||
await moveTo("Stromrechnung", "Rechnungen");
|
await moveTo("Stromrechnung", "Rechnungen");
|
||||||
@@ -73,6 +79,20 @@ try {
|
|||||||
(await titles()).join(", ")
|
(await titles()).join(", ")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ── and stays filed ──
|
||||||
|
* It did not. The server had it right, but the sync that follows replaced
|
||||||
|
* every local note with one belonging to no chapter — so about five seconds
|
||||||
|
* after being filed, the note walked back out into the notebook by itself.
|
||||||
|
* A reload is the same journey: the mirror is rebuilt from the server. */
|
||||||
|
await p.reload();
|
||||||
|
await p.waitForTimeout(4000);
|
||||||
|
await openNotebook();
|
||||||
|
check(
|
||||||
|
(await titles()).join(",") === "Zählerstand",
|
||||||
|
"and is still in it after the next sync, not back in the notebook",
|
||||||
|
(await titles()).join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
await p.locator("[data-chapter-row]").first().click();
|
await p.locator("[data-chapter-row]").first().click();
|
||||||
await p.waitForTimeout(1200);
|
await p.waitForTimeout(1200);
|
||||||
const inChapter = () =>
|
const inChapter = () =>
|
||||||
@@ -90,10 +110,37 @@ try {
|
|||||||
const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung");
|
const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung");
|
||||||
check(made?.chapter_id === filed?.chapter_id, "a note made while it is open is filed there", String(made?.chapter_id));
|
check(made?.chapter_id === filed?.chapter_id, "a note made while it is open is filed there", String(made?.chapter_id));
|
||||||
|
|
||||||
|
/* ── which of the two am I in ──
|
||||||
|
* Beside the list, a chapter does not replace the notebook: both are on
|
||||||
|
* screen at once. A new note used to go into the chapter however far away the
|
||||||
|
* user had since clicked, and nothing said which of the two was meant. */
|
||||||
|
check(
|
||||||
|
(await p.locator('[data-chapter-current="on"]').count()) === 1,
|
||||||
|
"opening a chapter makes it the place being written into, and shows it"
|
||||||
|
);
|
||||||
|
// Into the empty space under the notebook's own notes.
|
||||||
|
await p.locator("[data-note-list-body]").click({ position: { x: 140, y: 330 } });
|
||||||
|
await p.waitForTimeout(700);
|
||||||
|
check(
|
||||||
|
(await p.locator('[data-list-current="on"]').count()) === 1 &&
|
||||||
|
(await p.locator('[data-chapter-current="off"]').count()) === 1,
|
||||||
|
"clicking into the notebook puts you back in the notebook, visibly"
|
||||||
|
);
|
||||||
|
await createNote(p);
|
||||||
|
await setTitle(p, "Gartenzaun");
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
const loose = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Gartenzaun");
|
||||||
|
check(!loose?.chapter_id, "and a note made then is the notebook's, not the chapter's", String(loose?.chapter_id));
|
||||||
|
check(
|
||||||
|
(await titles()).sort().join(",") === "Gartenzaun,Zählerstand",
|
||||||
|
"so it appears in the notebook's own list",
|
||||||
|
(await titles()).join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
await p.locator("[data-chapter-close]").click();
|
await p.locator("[data-chapter-close]").click();
|
||||||
await p.waitForTimeout(1200);
|
await p.waitForTimeout(1200);
|
||||||
check(
|
check(
|
||||||
(await titles()).join(",") === "Zählerstand",
|
(await titles()).sort().join(",") === "Gartenzaun,Zählerstand",
|
||||||
"and the notebook's own list never held the chapter's notes",
|
"and the notebook's own list never held the chapter's notes",
|
||||||
(await titles()).join(", ")
|
(await titles()).join(", ")
|
||||||
);
|
);
|
||||||
@@ -117,10 +164,16 @@ try {
|
|||||||
await p.waitForTimeout(800);
|
await p.waitForTimeout(800);
|
||||||
await p.locator('[data-chapter-view="inline"]').click();
|
await p.locator('[data-chapter-view="inline"]').click();
|
||||||
await p.waitForTimeout(1500);
|
await p.waitForTimeout(1500);
|
||||||
await p.mouse.click(1380, 900); // outside the panel
|
/* Escape closes it. Only a click beside the panel did, which on one this wide
|
||||||
await p.waitForTimeout(1200);
|
means aiming at a sliver of page. */
|
||||||
await p.locator("[data-chapter-row]").first().click();
|
await p.keyboard.press("Escape");
|
||||||
await p.waitForTimeout(1200);
|
await p.waitForTimeout(1200);
|
||||||
|
check(
|
||||||
|
(await p.locator("[data-chapter-view]").count()) === 0,
|
||||||
|
"the account panel closes with Escape, not only by clicking beside it"
|
||||||
|
);
|
||||||
|
// No second click: the chapter that was open stays open, and changing the
|
||||||
|
// setting changes only where it is shown.
|
||||||
check((await p.locator("[data-chapter-notes]").count()) === 1, "the other setting unfolds it under its row");
|
check((await p.locator("[data-chapter-notes]").count()) === 1, "the other setting unfolds it under its row");
|
||||||
check((await p.locator("[data-chapter-column]").count()) === 0, "and there is no column then");
|
check((await p.locator("[data-chapter-column]").count()) === 0, "and there is no column then");
|
||||||
|
|
||||||
@@ -134,11 +187,43 @@ try {
|
|||||||
await p.waitForTimeout(300);
|
await p.waitForTimeout(300);
|
||||||
await p.locator('[data-sort-option="manual"]').click();
|
await p.locator('[data-sort-option="manual"]').click();
|
||||||
await p.waitForTimeout(1200);
|
await p.waitForTimeout(1200);
|
||||||
|
|
||||||
|
/* Rearranging is asked for, and until it is, no row wears a handle.
|
||||||
|
* Six dots stood beside every title at all times for something done once in a
|
||||||
|
* while, and a press meant to open a note could carry it off instead. */
|
||||||
check(
|
check(
|
||||||
(await p.locator("[data-reorder-grip]").count()) >= 2,
|
(await p.locator("[data-reorder-grip]").count()) === 0,
|
||||||
"a chapter has a grip like a note does",
|
"no row wears a handle until rearranging is switched on",
|
||||||
String(await p.locator("[data-reorder-grip]").count())
|
String(await p.locator("[data-reorder-grip]").count())
|
||||||
);
|
);
|
||||||
|
await p.locator("[data-reorder-toggle]").click();
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
check(
|
||||||
|
(await p.locator("[data-reorder-grip]").count()) >= 2,
|
||||||
|
"switched on, a chapter has a grip like a note does",
|
||||||
|
String(await p.locator("[data-reorder-grip]").count())
|
||||||
|
);
|
||||||
|
await p.locator("[data-reorder-toggle]").click();
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
check((await p.locator("[data-reorder-grip]").count()) === 0, "and they go away again when it is done");
|
||||||
|
|
||||||
|
/* ── a separator drawn in the notebook belongs to the notebook ──
|
||||||
|
* Opening a chapter used to take the notebook's own separators off the screen
|
||||||
|
* with it: the list still showed the notebook, but everything around it had
|
||||||
|
* quietly switched to the chapter. A line vanished for no reason anyone could
|
||||||
|
* see, and dragging in that list wrote nothing at all. */
|
||||||
|
await p.locator("[data-add-separator]").click();
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
check((await p.locator("[data-separator]").count()) === 1, "a separator can be drawn in the notebook");
|
||||||
|
await p.locator("[data-chapter-row]").first().click();
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
check(
|
||||||
|
(await p.locator("[data-separator]").count()) === 1,
|
||||||
|
"and it is still there once a chapter is opened",
|
||||||
|
String(await p.locator("[data-separator]").count())
|
||||||
|
);
|
||||||
|
await p.locator("[data-chapter-row]").first().click();
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
|
||||||
/* ── its own colour ── */
|
/* ── its own colour ── */
|
||||||
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
|
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
|
||||||
@@ -172,7 +257,7 @@ try {
|
|||||||
);
|
);
|
||||||
const after = (await titles()).sort().join(",");
|
const after = (await titles()).sort().join(",");
|
||||||
check(
|
check(
|
||||||
after === "Stromrechnung,Wasserrechnung,Zählerstand",
|
after === "Gartenzaun,Stromrechnung,Wasserrechnung,Zählerstand",
|
||||||
"and every note it held is back in the notebook",
|
"and every note it held is back in the notebook",
|
||||||
after
|
after
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ function readZip(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openExportDialog = async (page) => {
|
const openExportDialog = async (page) => {
|
||||||
await page.getByTitle("Export").click();
|
await page.getByTitle("Export", { exact: true }).click();
|
||||||
await page.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
await page.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||||
await page.waitForTimeout(800);
|
await page.waitForTimeout(800);
|
||||||
};
|
};
|
||||||
|
|||||||
+17
-2
@@ -35,6 +35,18 @@ try {
|
|||||||
await p.waitForTimeout(300);
|
await p.waitForTimeout(300);
|
||||||
await p.locator(`[data-sort-option="${mode}"]`).click();
|
await p.locator(`[data-sort-option="${mode}"]`).click();
|
||||||
await p.waitForTimeout(900);
|
await p.waitForTimeout(900);
|
||||||
|
/* Rows are only draggable while rearranging is switched on — the handles
|
||||||
|
are asked for rather than standing in every row. The suite drags a lot,
|
||||||
|
so it turns the mode on with the order it belongs to. */
|
||||||
|
if (mode === "manual") await startMoving();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startMoving = async () => {
|
||||||
|
const toggle = p.locator("[data-reorder-toggle]");
|
||||||
|
if ((await toggle.count()) && (await toggle.getAttribute("data-reorder-toggle")) === "off") {
|
||||||
|
await toggle.click();
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── three notes, made in a known order ── */
|
/* ── three notes, made in a known order ── */
|
||||||
@@ -280,7 +292,7 @@ try {
|
|||||||
});
|
});
|
||||||
check(exported, "downloads can be captured");
|
check(exported, "downloads can be captured");
|
||||||
|
|
||||||
await p.getByTitle("Export").click();
|
await p.getByTitle("Export", { exact: true }).click();
|
||||||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||||
await p.waitForTimeout(600);
|
await p.waitForTimeout(600);
|
||||||
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||||
@@ -486,7 +498,10 @@ try {
|
|||||||
const beforeTouch = await titles();
|
const beforeTouch = await titles();
|
||||||
// The last row onto the first, so the order cannot come out the same by luck.
|
// The last row onto the first, so the order cannot come out the same by luck.
|
||||||
const rowCount = (await titles()).length;
|
const rowCount = (await titles()).length;
|
||||||
check((await p.locator("[data-reorder-grip]").count()) >= rowCount, "every row has a grip to take hold of");
|
check(
|
||||||
|
(await p.locator("[data-reorder-grip]").count()) >= rowCount,
|
||||||
|
"with rearranging switched on, every row has a grip to take hold of"
|
||||||
|
);
|
||||||
await touchDrag(rowCount - 1, 0);
|
await touchDrag(rowCount - 1, 0);
|
||||||
await p.waitForTimeout(2000);
|
await p.waitForTimeout(2000);
|
||||||
check(
|
check(
|
||||||
|
|||||||
+2
-2
@@ -336,7 +336,7 @@ try {
|
|||||||
const originalBytes = readFileSync(PDF_PATH);
|
const originalBytes = readFileSync(PDF_PATH);
|
||||||
|
|
||||||
const openExport = async () => {
|
const openExport = async () => {
|
||||||
await p.getByTitle("Export").click();
|
await p.getByTitle("Export", { exact: true }).click();
|
||||||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||||
await p.waitForTimeout(1200);
|
await p.waitForTimeout(1200);
|
||||||
};
|
};
|
||||||
@@ -611,7 +611,7 @@ try {
|
|||||||
|
|
||||||
await captureDownloads(p);
|
await captureDownloads(p);
|
||||||
await clearDownloads(p);
|
await clearDownloads(p);
|
||||||
await p.getByTitle("Export").click();
|
await p.getByTitle("Export", { exact: true }).click();
|
||||||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||||
await p.waitForTimeout(800);
|
await p.waitForTimeout(800);
|
||||||
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
|
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
|
||||||
|
|||||||
+91
-35
@@ -13,7 +13,6 @@ import { I } from "./ui/icons.jsx";
|
|||||||
import { makeTheme, FONT_UI } from "./ui/theme";
|
import { makeTheme, FONT_UI } from "./ui/theme";
|
||||||
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
|
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
|
||||||
import { NameDialog } from "./ui/NameDialog.jsx";
|
import { NameDialog } from "./ui/NameDialog.jsx";
|
||||||
import { MoveNoteDialog } from "./ui/MoveNoteDialog.jsx";
|
|
||||||
import { ContextMenu } from "./ui/ContextMenu.jsx";
|
import { ContextMenu } from "./ui/ContextMenu.jsx";
|
||||||
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
|
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
|
||||||
import { AccountMenu } from "./ui/AccountMenu.jsx";
|
import { AccountMenu } from "./ui/AccountMenu.jsx";
|
||||||
@@ -68,6 +67,13 @@ export default function App() {
|
|||||||
notebook itself. A chapter is a folder inside a notebook, so this is a
|
notebook itself. A chapter is a folder inside a notebook, so this is a
|
||||||
second step of "where the user is", not a second notebook. */
|
second step of "where the user is", not a second notebook. */
|
||||||
const [activeChapter, setActiveChapter] = useState(null);
|
const [activeChapter, setActiveChapter] = useState(null);
|
||||||
|
/* Whether that chapter is the place being worked in.
|
||||||
|
*
|
||||||
|
* Open beside or below, a chapter does not replace the notebook's list — both
|
||||||
|
* are on screen at once, and "which of the two am I in" is then a real
|
||||||
|
* 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. */
|
||||||
|
const [inChapter, setInChapter] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const [showNewNb, setShowNewNb] = useState(false);
|
const [showNewNb, setShowNewNb] = useState(false);
|
||||||
@@ -91,8 +97,6 @@ export default function App() {
|
|||||||
const [ctxMenu, setCtxMenu] = useState(null);
|
const [ctxMenu, setCtxMenu] = useState(null);
|
||||||
// { title, value, onSave } while a chapter is being named or renamed.
|
// { title, value, onSave } while a chapter is being named or renamed.
|
||||||
const [namingChapter, setNamingChapter] = useState(null);
|
const [namingChapter, setNamingChapter] = useState(null);
|
||||||
// The note whose destination is being chosen, if any.
|
|
||||||
const [movingNote, setMovingNote] = useState(null);
|
|
||||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||||
// Bumped on a version restore to force a fresh editor on the restored body.
|
// Bumped on a version restore to force a fresh editor on the restored body.
|
||||||
const [restoreEpoch, setRestoreEpoch] = useState(0);
|
const [restoreEpoch, setRestoreEpoch] = useState(0);
|
||||||
@@ -114,13 +118,6 @@ export default function App() {
|
|||||||
const { notes, notebooks, noteContents, backlinks } = data;
|
const { notes, notebooks, noteContents, backlinks } = data;
|
||||||
const search = useSearchIndex(user, notes, searchQuery);
|
const search = useSearchIndex(user, notes, searchQuery);
|
||||||
const dnd = useNotebookDnd(data.reorderNotebooks);
|
const dnd = useNotebookDnd(data.reorderNotebooks);
|
||||||
/* Notes reorder through their own list, which is the only place that knows
|
|
||||||
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
|
|
||||||
hook: they are dragged with a mouse, in the sidebar and on the dashboard. */
|
|
||||||
const rearrangeNotes = useCallback(
|
|
||||||
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
|
|
||||||
[data, activeFolder, activeChapter]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
|
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
|
||||||
|
|
||||||
@@ -169,6 +166,7 @@ export default function App() {
|
|||||||
(id) => {
|
(id) => {
|
||||||
setActiveFolder(id);
|
setActiveFolder(id);
|
||||||
setActiveChapter(null);
|
setActiveChapter(null);
|
||||||
|
setInChapter(false);
|
||||||
setActiveNote(null);
|
setActiveNote(null);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
@@ -214,6 +212,33 @@ export default function App() {
|
|||||||
}));
|
}));
|
||||||
}, [data.chapters, activeFolder, notes]);
|
}, [data.chapters, activeFolder, notes]);
|
||||||
const openChapter = folderChapters.find((c) => c.id === activeChapter) || null;
|
const openChapter = folderChapters.find((c) => c.id === activeChapter) || null;
|
||||||
|
/* Which list the middle column is *showing*.
|
||||||
|
*
|
||||||
|
* Only the fallback replaces it: beside or below, the notebook's own list
|
||||||
|
* carries on and the chapter is a second thing on screen. Told apart from
|
||||||
|
* `activeChapter` after a separator drawn in a notebook vanished the moment a
|
||||||
|
* chapter was opened — the list still showed the notebook, but everything
|
||||||
|
* around it had switched to the chapter, so the notebook's own separators
|
||||||
|
* were filtered out and a drag in that list wrote nothing at all. */
|
||||||
|
const listChapter = inlineChapters || columnChapters ? null : activeChapter;
|
||||||
|
const listIsChapter = !!listChapter;
|
||||||
|
/* Where a new note is made. In the fallback the list is the chapter, so there
|
||||||
|
is nothing to choose; beside or below it follows the click. */
|
||||||
|
const targetChapter = listChapter ?? (inChapter ? activeChapter : null);
|
||||||
|
/* Both places are on screen at once, and only then is it worth saying which
|
||||||
|
one is meant. With no chapter open there is nothing to tell apart. */
|
||||||
|
const showsWhere = !!openChapter && !listIsChapter;
|
||||||
|
|
||||||
|
/* Notes reorder through their own list, which is the only place that knows
|
||||||
|
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
|
||||||
|
hook: they are dragged with a mouse, in the sidebar and on the dashboard.
|
||||||
|
Scoped to the list on screen, not to the chapter that happens to be open
|
||||||
|
beside it — otherwise a drag among the notebook's own rows is measured
|
||||||
|
against the chapter's notes, finds nothing, and does nothing. */
|
||||||
|
const rearrangeNotes = useCallback(
|
||||||
|
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, listChapter),
|
||||||
|
[data, activeFolder, listChapter]
|
||||||
|
);
|
||||||
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";
|
||||||
|
|
||||||
@@ -243,18 +268,18 @@ export default function App() {
|
|||||||
/* Only the ones drawn in the list being looked at — the notebook itself, or
|
/* Only the ones drawn in the list being looked at — the notebook itself, or
|
||||||
the chapter that is open. */
|
the chapter that is open. */
|
||||||
const separators = useMemo(
|
const separators = useMemo(
|
||||||
() => separatorsFor(allSeparators, activeChapter),
|
() => separatorsFor(allSeparators, listChapter),
|
||||||
[allSeparators, activeChapter]
|
[allSeparators, listChapter]
|
||||||
);
|
);
|
||||||
const writeSeparators = (next) =>
|
const writeSeparators = (next) =>
|
||||||
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
|
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
|
||||||
/* Written back whole: the notebook stores one list, and the ones belonging to
|
/* Written back whole: the notebook stores one list, and the ones belonging to
|
||||||
the other chapters have to survive a change made in this one. */
|
the other chapters have to survive a change made in this one. */
|
||||||
const writeVisible = (visible) =>
|
const writeVisible = (visible) =>
|
||||||
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== activeChapter), ...visible]);
|
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== listChapter), ...visible]);
|
||||||
|
|
||||||
const addSeparator = () =>
|
const addSeparator = () =>
|
||||||
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter));
|
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", listChapter));
|
||||||
const renameSeparator = (id, label) =>
|
const renameSeparator = (id, label) =>
|
||||||
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
|
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
|
||||||
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
|
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
|
||||||
@@ -293,8 +318,7 @@ export default function App() {
|
|||||||
if (activeFolder === "trash") list = trashedNotes;
|
if (activeFolder === "trash") list = trashedNotes;
|
||||||
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
|
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
|
||||||
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
|
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
|
||||||
else if (activeChapter && !inlineChapters && !columnChapters)
|
else if (listChapter) list = activeNotes.filter((n) => n.chapterId === listChapter);
|
||||||
list = activeNotes.filter((n) => n.chapterId === activeChapter);
|
|
||||||
/* In the notebook itself, a note filed into a chapter is *in* that chapter
|
/* In the notebook itself, a note filed into a chapter is *in* that chapter
|
||||||
and not loose here — otherwise it would appear twice and moving it would
|
and not loose here — otherwise it would appear twice and moving it would
|
||||||
look like it had done nothing. */
|
look like it had done nothing. */
|
||||||
@@ -314,9 +338,7 @@ export default function App() {
|
|||||||
return sortNotes(list, sortMode);
|
return sortNotes(list, sortMode);
|
||||||
}, [
|
}, [
|
||||||
activeFolder,
|
activeFolder,
|
||||||
activeChapter,
|
listChapter,
|
||||||
inlineChapters,
|
|
||||||
columnChapters,
|
|
||||||
searchQuery,
|
searchQuery,
|
||||||
search.results,
|
search.results,
|
||||||
activeNotes,
|
activeNotes,
|
||||||
@@ -468,7 +490,7 @@ export default function App() {
|
|||||||
/* A note made while a chapter is open belongs to that chapter — but only
|
/* A note made while a chapter is open belongs to that chapter — but only
|
||||||
if it is a chapter of the notebook being written into, which it is not
|
if it is a chapter of the notebook being written into, which it is not
|
||||||
when the notebook had to be asked for. */
|
when the notebook had to be asked for. */
|
||||||
const chapterId = folderId === activeFolder ? activeChapter : null;
|
const chapterId = folderId === activeFolder ? targetChapter : null;
|
||||||
const note = await data.createNote(folderId, chapterId);
|
const note = await data.createNote(folderId, chapterId);
|
||||||
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||||
openNote(note.id);
|
openNote(note.id);
|
||||||
@@ -605,6 +627,19 @@ export default function App() {
|
|||||||
* Folders inside a notebook. Named on creation, because a chapter with no name
|
* Folders inside a notebook. Named on creation, because a chapter with no name
|
||||||
* is a row nobody can tell from the next one — unlike a separator, which is a
|
* is a row nobody can tell from the next one — unlike a separator, which is a
|
||||||
* line and reads perfectly well unnamed. */
|
* line and reads perfectly well unnamed. */
|
||||||
|
/**
|
||||||
|
* Opens a chapter, or closes the one already open.
|
||||||
|
*
|
||||||
|
* Opening it also makes it the place being worked in, which is what pressing
|
||||||
|
* a chapter means; clicking back into the notebook's list undoes that without
|
||||||
|
* closing the chapter, so both stay readable side by side.
|
||||||
|
*/
|
||||||
|
const openChapterView = (id) => {
|
||||||
|
const next = activeChapter === id ? null : id;
|
||||||
|
setActiveChapter(next);
|
||||||
|
setInChapter(!!next);
|
||||||
|
};
|
||||||
|
|
||||||
const addChapter = () => {
|
const addChapter = () => {
|
||||||
if (!activeNotebook) return;
|
if (!activeNotebook) return;
|
||||||
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
|
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
|
||||||
@@ -721,8 +756,24 @@ export default function App() {
|
|||||||
const renderChapterNotes = (chapter) => (
|
const renderChapterNotes = (chapter) => (
|
||||||
<div
|
<div
|
||||||
data-chapter-notes={chapter.id}
|
data-chapter-notes={chapter.id}
|
||||||
style={{ margin: "2px 0 8px 22px", borderLeft: `2px solid ${chapter.color || T.accent}55`, paddingLeft: 8 }}
|
data-chapter-current={inChapter ? "on" : "off"}
|
||||||
|
/* Pressing inside the unfolded chapter says "I am in here" — and must not
|
||||||
|
then travel on to the list around it, which says the opposite. */
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setInChapter(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
margin: "2px 0 8px 22px",
|
||||||
|
borderLeft: `2px solid ${inChapter ? chapter.color || T.accent : `${chapter.color || T.accent}55`}`,
|
||||||
|
paddingLeft: 8,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{inChapter && (
|
||||||
|
<div style={{ fontSize: 10, color: T.accent, fontWeight: 700, padding: "2px 10px 4px" }}>
|
||||||
|
· new notes land here
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{chapterNotes.length === 0 && (
|
{chapterNotes.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>
|
||||||
)}
|
)}
|
||||||
@@ -866,14 +917,19 @@ export default function App() {
|
|||||||
/* Only in the fallback: there the list becomes the chapter, and
|
/* Only in the fallback: there the list becomes the chapter, and
|
||||||
its header is the way back. Beside or below, the notebook's own
|
its header is the way back. Beside or below, the notebook's own
|
||||||
list carries on and keeps its chapter rows. */
|
list carries on and keeps its chapter rows. */
|
||||||
openChapter={inlineChapters || columnChapters ? null : openChapter}
|
openChapter={listIsChapter ? openChapter : null}
|
||||||
onOpenChapter={setActiveChapter}
|
onOpenChapter={openChapterView}
|
||||||
onAddChapter={activeNotebook ? addChapter : null}
|
onAddChapter={activeNotebook ? addChapter : null}
|
||||||
onChapterContextMenu={chapterContextMenu}
|
onChapterContextMenu={chapterContextMenu}
|
||||||
expandedChapter={inlineChapters ? activeChapter : null}
|
expandedChapter={inlineChapters ? activeChapter : null}
|
||||||
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
|
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
|
||||||
onMoveNote={setMovingNote}
|
|
||||||
onReorder={rearrangeNotes}
|
onReorder={rearrangeNotes}
|
||||||
|
listKey={`${activeFolder}:${listChapter ?? ""}`}
|
||||||
|
isCurrent={showsWhere ? !inChapter : null}
|
||||||
|
onFocusList={showsWhere ? () => setInChapter(false) : undefined}
|
||||||
|
newNotePlace={
|
||||||
|
targetChapter ? folderChapters.find((c) => c.id === targetChapter)?.name : folderName
|
||||||
|
}
|
||||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||||
onSelectNote={openNote}
|
onSelectNote={openNote}
|
||||||
onNoteContextMenu={noteContextMenu}
|
onNoteContextMenu={noteContextMenu}
|
||||||
@@ -889,6 +945,8 @@ export default function App() {
|
|||||||
{columnChapters && openChapter && (
|
{columnChapters && openChapter && (
|
||||||
<div
|
<div
|
||||||
data-chapter-column
|
data-chapter-column
|
||||||
|
data-chapter-current={inChapter ? "on" : "off"}
|
||||||
|
onPointerDown={() => setInChapter(true)}
|
||||||
style={{
|
style={{
|
||||||
width: 268,
|
width: 268,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -896,6 +954,8 @@ export default function App() {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
borderRight: `1px solid ${T.borderLight}`,
|
borderRight: `1px solid ${T.borderLight}`,
|
||||||
background: T.panel || "transparent",
|
background: T.panel || "transparent",
|
||||||
|
// The same line the note list wears when it is the one in hand.
|
||||||
|
boxShadow: inChapter ? `inset 0 2px 0 ${T.accent}` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -924,8 +984,14 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{openChapter.name}
|
{openChapter.name}
|
||||||
</span>
|
</span>
|
||||||
|
{inChapter && (
|
||||||
|
<span style={{ fontSize: 10, color: T.accent, fontWeight: 700, flexShrink: 0 }}>· here</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveChapter(null)}
|
onClick={() => {
|
||||||
|
setActiveChapter(null);
|
||||||
|
setInChapter(false);
|
||||||
|
}}
|
||||||
title="Close chapter"
|
title="Close chapter"
|
||||||
aria-label="Close chapter"
|
aria-label="Close chapter"
|
||||||
data-chapter-close
|
data-chapter-close
|
||||||
@@ -1103,16 +1169,6 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{movingNote && (
|
|
||||||
<MoveNoteDialog
|
|
||||||
note={movingNote}
|
|
||||||
notebooks={notebooks}
|
|
||||||
chapters={data.chapters}
|
|
||||||
onMove={(id, folderId, chapterId) => data.moveNote(id, folderId, chapterId)}
|
|
||||||
onClose={() => setMovingNote(null)}
|
|
||||||
dark={dark}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{namingChapter && (
|
{namingChapter && (
|
||||||
<NameDialog
|
<NameDialog
|
||||||
title={namingChapter.title}
|
title={namingChapter.title}
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ const DropLine = ({ T }) => (
|
|||||||
*
|
*
|
||||||
* A mouse can drag a row from anywhere, and did. A finger cannot: pressing the
|
* A mouse can drag a row from anywhere, and did. A finger cannot: pressing the
|
||||||
* row has to keep scrolling the list, and holding it has to keep opening the
|
* row has to keep scrolling the list, and holding it has to keep opening the
|
||||||
* menu. So touch gets its own target, wide enough to hit and only there while a
|
* menu. So touch gets its own target, wide enough to hit.
|
||||||
* hand-made arrangement is in effect — which is the only time moving a row means
|
*
|
||||||
* anything.
|
* Only there while rearranging is switched on. Standing in every row all the
|
||||||
|
* time it was six dots of clutter beside every title, and a row could be pulled
|
||||||
|
* out of place by a press that was meant to open it.
|
||||||
*/
|
*/
|
||||||
const Grip = ({ T, ...rest }) => (
|
const Grip = ({ T, ...rest }) => (
|
||||||
<div
|
<div
|
||||||
@@ -68,36 +70,6 @@ const Grip = ({ T, ...rest }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Moving a note, in one press.
|
|
||||||
*
|
|
||||||
* It used to be a right-click into a submenu — two steps before the list of
|
|
||||||
* places appeared at all, and on a phone a long press followed by a flyout.
|
|
||||||
* Sitting on the row beside the grip, it says what it does and takes one tap.
|
|
||||||
*/
|
|
||||||
const MoveButton = ({ T, onClick }) => (
|
|
||||||
<button
|
|
||||||
data-move-note
|
|
||||||
onClick={onClick}
|
|
||||||
title="Move to another notebook or chapter"
|
|
||||||
aria-label="Move note"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: 28,
|
|
||||||
flexShrink: 0,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
color: T.faint,
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{I.move}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Width the separator's label keeps in both states, so renaming moves nothing. */
|
/** Width the separator's label keeps in both states, so renaming moves nothing. */
|
||||||
const MIDDLE_W = 132;
|
const MIDDLE_W = 132;
|
||||||
|
|
||||||
@@ -135,7 +107,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
|||||||
/* Room above it, so it reads as the end of what came before rather than
|
/* Room above it, so it reads as the end of what came before rather than
|
||||||
as decoration attached to the next note. */
|
as decoration attached to the next note. */
|
||||||
padding: "14px 8px 8px",
|
padding: "14px 8px 8px",
|
||||||
cursor: "grab",
|
cursor: grip ? "grab" : "default",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{grip}
|
{grip}
|
||||||
@@ -343,7 +315,7 @@ function SortMenu({ T, mode, modes, onChange, disabled }) {
|
|||||||
* putting something new in here — and they had grown into three buttons side by
|
* putting something new in here — and they had grown into three buttons side by
|
||||||
* side, two of which were icons you had to hover to identify.
|
* side, two of which were icons you had to hover to identify.
|
||||||
*/
|
*/
|
||||||
function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
function AddMenu({ T, place, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const box = useRef(null);
|
const box = useRef(null);
|
||||||
|
|
||||||
@@ -388,7 +360,8 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
|||||||
<div style={{ position: "relative" }} ref={box}>
|
<div style={{ position: "relative" }} ref={box}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
title="New Note"
|
// Names the place, so it is clear before pressing where the note lands.
|
||||||
|
title={place ? `New note in ${place}` : "New Note"}
|
||||||
data-add-menu={open ? "on" : "off"}
|
data-add-menu={open ? "on" : "off"}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -430,6 +403,32 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches rearranging on.
|
||||||
|
*
|
||||||
|
* Rows are dragged rarely and read constantly, so the handles are not there by
|
||||||
|
* default: the list stays a list, and taking hold of a row is something asked
|
||||||
|
* for. While it is on, and only then, every row shows its grip and can be
|
||||||
|
* carried; pressing a row does not open it by accident.
|
||||||
|
*/
|
||||||
|
const ReorderToggle = ({ T, on, onToggle }) => (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
data-reorder-toggle={on ? "on" : "off"}
|
||||||
|
title={on ? "Done rearranging" : "Rearrange the rows"}
|
||||||
|
aria-label="Rearrange the rows"
|
||||||
|
aria-pressed={on}
|
||||||
|
style={{
|
||||||
|
...iconButton(T),
|
||||||
|
color: on ? "#fff" : T.muted,
|
||||||
|
background: on ? T.accent : "transparent",
|
||||||
|
borderColor: on ? T.accent : T.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{on ? I.check : I.grip}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
/** The middle column: every note in the selected folder, in the chosen order. */
|
/** The middle column: every note in the selected folder, in the chosen order. */
|
||||||
export function NoteList({
|
export function NoteList({
|
||||||
T,
|
T,
|
||||||
@@ -459,8 +458,14 @@ export function NoteList({
|
|||||||
onChapterContextMenu,
|
onChapterContextMenu,
|
||||||
expandedChapter,
|
expandedChapter,
|
||||||
renderChapterNotes,
|
renderChapterNotes,
|
||||||
onMoveNote,
|
|
||||||
onReorder,
|
onReorder,
|
||||||
|
/** Identifies the list on screen, so rearranging does not stay on across a move to another. */
|
||||||
|
listKey,
|
||||||
|
/** Whether this pane is the one being worked in; null when there is nothing to tell apart. */
|
||||||
|
isCurrent,
|
||||||
|
onFocusList,
|
||||||
|
/** The place a new note would land in, named on the + button. */
|
||||||
|
newNotePlace,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onSelectNote,
|
onSelectNote,
|
||||||
onNoteContextMenu,
|
onNoteContextMenu,
|
||||||
@@ -469,10 +474,21 @@ export function NoteList({
|
|||||||
onImportMarkdown,
|
onImportMarkdown,
|
||||||
onEmptyTrash,
|
onEmptyTrash,
|
||||||
}) {
|
}) {
|
||||||
|
/* Rearranging is a mode, switched on for as long as it is being done.
|
||||||
|
*
|
||||||
|
* It ends by itself when the list is left: coming back to a notebook to find
|
||||||
|
* every row wearing a handle, because it was rearranged last week, is not
|
||||||
|
* where anybody left off. */
|
||||||
|
const [reordering, setReordering] = useState(false);
|
||||||
|
// Off again on the way to another list, and whenever a hand-made arrangement
|
||||||
|
// stops being in effect at all — an order the app chose cannot be dragged.
|
||||||
|
useEffect(() => setReordering(false), [listKey, reorderable]);
|
||||||
|
const moving = !!reorderable && reordering;
|
||||||
|
|
||||||
/* Reordering by pointer rather than by the HTML5 drag API, which mobile
|
/* 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
|
||||||
changed at all, with nothing on screen to say why. */
|
changed at all, with nothing on screen to say why. */
|
||||||
const dnd = useReorderDrag(!!reorderable, onReorder);
|
const dnd = useReorderDrag(moving, onReorder);
|
||||||
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
|
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
|
||||||
or the trash, which are views over notebooks rather than a place to file
|
or the trash, which are views over notebooks rather than a place to file
|
||||||
anything, and not inside a chapter, because a chapter holds notes. */
|
anything, and not inside a chapter, because a chapter holds notes. */
|
||||||
@@ -484,6 +500,7 @@ export function NoteList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-list-current={isCurrent === null || isCurrent === undefined ? undefined : isCurrent ? "on" : "off"}
|
||||||
style={{
|
style={{
|
||||||
width: isMobile ? "100%" : 300,
|
width: isMobile ? "100%" : 300,
|
||||||
minWidth: isMobile ? 0 : 300,
|
minWidth: isMobile ? 0 : 300,
|
||||||
@@ -493,6 +510,8 @@ export function NoteList({
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
background: T.bg,
|
background: T.bg,
|
||||||
fontFamily: FONT_UI,
|
fontFamily: FONT_UI,
|
||||||
|
// A line along the top of whichever pane is being worked in.
|
||||||
|
boxShadow: isCurrent ? `inset 0 2px 0 ${T.accent}` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -541,12 +560,21 @@ export function NoteList({
|
|||||||
>
|
>
|
||||||
{openChapter ? openChapter.name : title}
|
{openChapter ? openChapter.name : title}
|
||||||
</h2>
|
</h2>
|
||||||
|
{/* Said in words as well as in the line above, because a coloured edge
|
||||||
|
is only a hint until you know what it means. */}
|
||||||
|
{isCurrent && (
|
||||||
|
<span style={{ fontSize: 10, color: T.accent, fontWeight: 700, flexShrink: 0 }}>· here</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||||
|
{reorderable && (
|
||||||
|
<ReorderToggle T={T} on={reordering} onToggle={() => setReordering((v) => !v)} />
|
||||||
|
)}
|
||||||
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
|
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
|
||||||
{!isTrashView && (
|
{!isTrashView && (
|
||||||
<AddMenu
|
<AddMenu
|
||||||
T={T}
|
T={T}
|
||||||
|
place={newNotePlace}
|
||||||
onCreateNote={onCreateNote}
|
onCreateNote={onCreateNote}
|
||||||
onImportPdf={onImportPdf}
|
onImportPdf={onImportPdf}
|
||||||
onImportMarkdown={onImportMarkdown}
|
onImportMarkdown={onImportMarkdown}
|
||||||
@@ -615,12 +643,20 @@ export function NoteList({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
gap: 6,
|
gap: 6,
|
||||||
padding: "5px 10px",
|
padding: "5px 10px",
|
||||||
borderBottom: `1px solid ${T.borderLight}`,
|
borderBottom: `1px solid ${T.borderLight}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Only while it is on, and then it says what to do rather than
|
||||||
|
explaining the order the sort menu already names. */}
|
||||||
|
{moving && (
|
||||||
|
<span style={{ flex: 1, minWidth: 0, fontSize: 11, color: T.accent, fontWeight: 600 }}>
|
||||||
|
Drag the rows into place
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{/* A chapter divides the notebook; a separator divides a list. They
|
{/* A chapter divides the notebook; a separator divides a list. They
|
||||||
sit together because both are "add something that is not a note",
|
sit together because both are "add something that is not a note",
|
||||||
and apart from the + button, which makes notes. */}
|
and apart from the + button, which makes notes. */}
|
||||||
@@ -666,7 +702,16 @@ export function NoteList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
|
{/* Pressing in the list itself — on a note or on the empty space below
|
||||||
|
them — says "I am in the notebook", and a new note goes there.
|
||||||
|
Deliberately not the whole pane: the + button sits in the header, and
|
||||||
|
taking the focus back on the way to pressing it would mean a note
|
||||||
|
could never be made in the open chapter at all. */}
|
||||||
|
<div
|
||||||
|
data-note-list-body
|
||||||
|
onPointerDown={onFocusList}
|
||||||
|
style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}
|
||||||
|
>
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -718,14 +763,14 @@ export function NoteList({
|
|||||||
? mergeEntries(notes, separators, shownChapters)
|
? mergeEntries(notes, separators, shownChapters)
|
||||||
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)]
|
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)]
|
||||||
).map((entry) => {
|
).map((entry) => {
|
||||||
const showDropLine = reorderable && dnd.overId === entry.id;
|
const showDropLine = moving && dnd.overId === entry.id;
|
||||||
/* The whole row, indicator included, is the drop target.
|
/* The whole row, indicator included, is the drop target.
|
||||||
*
|
*
|
||||||
* Putting these on the inner element left the indicator itself as
|
* Putting these on the inner element left the indicator itself as
|
||||||
* a dead strip: it pushes the row down as it appears, so the
|
* 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
|
* pointer ended up over the line rather than over the row, and
|
||||||
* releasing there dropped on nothing. */
|
* releasing there dropped on nothing. */
|
||||||
const rowProps = reorderable ? dnd.rowProps(entry.id) : {};
|
const rowProps = moving ? dnd.rowProps(entry.id) : {};
|
||||||
const lifted = dnd.draggingId === entry.id;
|
const lifted = dnd.draggingId === entry.id;
|
||||||
|
|
||||||
if (entry.kind === "chapter") {
|
if (entry.kind === "chapter") {
|
||||||
@@ -733,7 +778,7 @@ export function NoteList({
|
|||||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||||
{showDropLine && <DropLine T={T} />}
|
{showDropLine && <DropLine T={T} />}
|
||||||
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||||
{reorderable && <Grip T={T} {...dnd.gripProps} />}
|
{moving && <Grip T={T} {...dnd.gripProps} />}
|
||||||
<ChapterRow
|
<ChapterRow
|
||||||
T={T}
|
T={T}
|
||||||
chapter={entry.chapter}
|
chapter={entry.chapter}
|
||||||
@@ -752,7 +797,7 @@ export function NoteList({
|
|||||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||||
{showDropLine && <DropLine T={T} />}
|
{showDropLine && <DropLine T={T} />}
|
||||||
<SeparatorRow
|
<SeparatorRow
|
||||||
grip={<Grip T={T} {...dnd.gripProps} />}
|
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
|
||||||
T={T}
|
T={T}
|
||||||
separator={entry.separator}
|
separator={entry.separator}
|
||||||
editing={renamingSeparator === entry.id}
|
editing={renamingSeparator === entry.id}
|
||||||
@@ -770,10 +815,7 @@ export function NoteList({
|
|||||||
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||||
{showDropLine && <DropLine T={T} />}
|
{showDropLine && <DropLine T={T} />}
|
||||||
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||||
{reorderable && <Grip T={T} {...dnd.gripProps} />}
|
{moving && <Grip T={T} {...dnd.gripProps} />}
|
||||||
{onMoveNote && !note.trashed && (
|
|
||||||
<MoveButton T={T} onClick={() => onMoveNote(note)} />
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
data-note-row={note.id}
|
data-note-row={note.id}
|
||||||
onClick={() => onSelectNote(note.id)}
|
onClick={() => onSelectNote(note.id)}
|
||||||
@@ -787,7 +829,7 @@ export function NoteList({
|
|||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||||
background: activeNote === note.id ? T.accentSoft : "transparent",
|
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||||||
cursor: reorderable ? "grab" : "pointer",
|
cursor: moving ? "grab" : "pointer",
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
}}
|
}}
|
||||||
@@ -848,7 +890,7 @@ export function NoteList({
|
|||||||
})}
|
})}
|
||||||
{/* Dropping below the last row moves something to the end, which
|
{/* Dropping below the last row moves something to the end, which
|
||||||
otherwise needs an awkward drop onto the very last note. */}
|
otherwise needs an awkward drop onto the very last note. */}
|
||||||
{reorderable && (
|
{moving && (
|
||||||
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 56 }}>
|
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 56 }}>
|
||||||
{dnd.overId === END_ID && <DropLine T={T} />}
|
{dnd.overId === END_ID && <DropLine T={T} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -498,6 +498,13 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
|
|||||||
id: n.id,
|
id: n.id,
|
||||||
title: n.title,
|
title: n.title,
|
||||||
folderId: n.notebook_id,
|
folderId: n.notebook_id,
|
||||||
|
/* Where it is filed inside the notebook.
|
||||||
|
*
|
||||||
|
* Missing here, a note put into a chapter came back out of it seconds later:
|
||||||
|
* the server had it right, but every sync replaces the local mirror with
|
||||||
|
* what this function returns — and it returned a note belonging to no
|
||||||
|
* chapter, so the notebook's own list took it back. */
|
||||||
|
chapterId: n.chapter_id ?? null,
|
||||||
starred: !!n.starred,
|
starred: !!n.starred,
|
||||||
trashed,
|
trashed,
|
||||||
createdAt: n.created_at,
|
createdAt: n.created_at,
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import * as sync from "../syncEngine";
|
import * as sync from "../syncEngine";
|
||||||
import { I } from "./icons.jsx";
|
import { I } from "./icons.jsx";
|
||||||
|
|
||||||
/* ═══════ ACCOUNT MENU ═══════ */
|
/* ═══════ ACCOUNT MENU ═══════ */
|
||||||
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
|
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
|
||||||
|
/* Escape closes it, like every other panel over the app. Only clicking beside
|
||||||
|
it worked, which on a panel this wide means aiming at a sliver of page. */
|
||||||
|
useEffect(() => {
|
||||||
|
const key = (e) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", key);
|
||||||
|
return () => document.removeEventListener("keydown", key);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const choosePreference = async (change) => {
|
const choosePreference = async (change) => {
|
||||||
try {
|
try {
|
||||||
onUserUpdate(await sync.updatePreferences(change));
|
onUserUpdate(await sync.updatePreferences(change));
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { I } from "./icons.jsx";
|
|
||||||
import { coverBackground } from "./constants.jsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a note should go.
|
|
||||||
*
|
|
||||||
* Moving used to live only under a right-click, behind a submenu — two steps
|
|
||||||
* before the list of places even appeared, and on a phone a long press followed
|
|
||||||
* by a flyout. This is the same list, reached by one press of a button on the
|
|
||||||
* row.
|
|
||||||
*
|
|
||||||
* Everything is on one screen: the chapters of the notebook the note is already
|
|
||||||
* in, and then the other notebooks. Sending it to another notebook takes it out
|
|
||||||
* of whatever chapter it was in, because a chapter belongs to the book it is in
|
|
||||||
* — which is worth saying here rather than leaving to be discovered.
|
|
||||||
*/
|
|
||||||
export function MoveNoteDialog({ note, notebooks, chapters, onMove, onClose, dark }) {
|
|
||||||
const T = useMemo(
|
|
||||||
() => ({
|
|
||||||
bg: dark ? "#161b22" : "#fff",
|
|
||||||
border: dark ? "#30363d" : "#d0d7de",
|
|
||||||
text: dark ? "#e6edf3" : "#1f2328",
|
|
||||||
muted: dark ? "#8b949e" : "#656d76",
|
|
||||||
accent: dark ? "#388bfd" : "#0969da",
|
|
||||||
row: dark ? "#0d1117" : "#f6f8fa",
|
|
||||||
hover: dark ? "#21262d" : "#eaeef2",
|
|
||||||
}),
|
|
||||||
[dark]
|
|
||||||
);
|
|
||||||
|
|
||||||
const here = chapters.filter((c) => c.notebookId === note.folderId);
|
|
||||||
const others = notebooks.filter((nb) => nb.id !== note.folderId);
|
|
||||||
|
|
||||||
const go = (folderId, chapterId) => {
|
|
||||||
onMove(note.id, folderId, chapterId);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const Row = ({ children, onClick, disabled, dot, icon }) => (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
data-move-target
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 10,
|
|
||||||
width: "100%",
|
|
||||||
padding: "11px 12px",
|
|
||||||
borderRadius: 10,
|
|
||||||
border: "none",
|
|
||||||
background: disabled ? T.row : "transparent",
|
|
||||||
color: disabled ? T.muted : T.text,
|
|
||||||
cursor: disabled ? "default" : "pointer",
|
|
||||||
fontSize: 13,
|
|
||||||
textAlign: "left",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => !disabled && (e.currentTarget.style.background = T.hover)}
|
|
||||||
onMouseLeave={(e) => !disabled && (e.currentTarget.style.background = "transparent")}
|
|
||||||
>
|
|
||||||
{dot && <div style={{ width: 12, height: 16, borderRadius: 3, background: dot, flexShrink: 0 }} />}
|
|
||||||
{icon && <span style={{ display: "flex", opacity: 0.7, flexShrink: 0 }}>{icon}</span>}
|
|
||||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{children}</span>
|
|
||||||
{disabled && <span style={{ marginLeft: "auto", fontSize: 11, flexShrink: 0 }}>here now</span>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const heading = (text) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: T.muted,
|
|
||||||
margin: "12px 12px 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-move-dialog
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
background: "rgba(0,0,0,0.5)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 4000,
|
|
||||||
padding: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 400,
|
|
||||||
maxHeight: "80vh",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
background: T.bg,
|
|
||||||
border: `1px solid ${T.border}`,
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: "20px 10px 12px",
|
|
||||||
fontFamily: "'DM Sans',sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 style={{ margin: "0 12px 4px", fontSize: 16, fontWeight: 700, color: T.text }}>Move note</h3>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: "0 12px 8px",
|
|
||||||
fontSize: 12,
|
|
||||||
color: T.muted,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{note.title || "Untitled"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
|
||||||
{here.length > 0 && heading("In this notebook")}
|
|
||||||
{here.length > 0 && (
|
|
||||||
<Row icon={I.note} disabled={!note.chapterId} onClick={() => go(note.folderId, null)}>
|
|
||||||
The notebook itself
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{here.map((c) => (
|
|
||||||
<Row
|
|
||||||
key={c.id}
|
|
||||||
icon={I.books}
|
|
||||||
disabled={note.chapterId === c.id}
|
|
||||||
onClick={() => go(note.folderId, c.id)}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{others.length > 0 && heading("Another notebook")}
|
|
||||||
{others.map((nb) => (
|
|
||||||
<Row key={nb.id} dot={coverBackground(nb.cover, nb.coverPattern)} onClick={() => go(nb.id, null)}>
|
|
||||||
{nb.name}
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{here.length === 0 && others.length === 0 && (
|
|
||||||
<div style={{ padding: "18px 12px", fontSize: 12.5, color: T.muted }}>
|
|
||||||
There is nowhere else to put it yet — make another notebook, or a chapter in this one.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", padding: "10px 12px 0" }}>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
borderRadius: 10,
|
|
||||||
border: `1px solid ${T.border}`,
|
|
||||||
background: "transparent",
|
|
||||||
color: T.text,
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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
|
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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
are read constantly and dragged rarely, so six dots beside every title was
|
||||||
|
clutter that also made an ordinary press able to carry a row off. Filing a note
|
||||||
|
somewhere else is not part of it: that is one of the things done to a row from
|
||||||
|
the row's own menu, like starring, exporting and discarding.
|
||||||
|
|
||||||
**The context menu** was placed with `innerWidth - 220`, a stand-in for a width
|
**The context menu** was placed with `innerWidth - 220`, a stand-in for a width
|
||||||
nobody had measured, and its submenu was pinned to `left: 100%` and opened only
|
nobody had measured, and its submenu was pinned to `left: 100%` and opened only
|
||||||
on hover. On a phone the parent already sits against the right edge, so the list
|
on hover. On a phone the parent already sits against the right edge, so the list
|
||||||
@@ -431,8 +439,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
|||||||
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
|
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
|
||||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||||
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||||
| `organise` | 52 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
|
| `organise` | 53 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
|
||||||
| `chapters` | 21 | a note filed into a chapter leaves the notebook's list, and removing the chapter gives every note back |
|
| `chapters` | 31 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging |
|
||||||
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
|
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
|
||||||
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
|
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
|
||||||
|
|
||||||
@@ -881,9 +889,28 @@ on every keystroke. They are now separate blobs; the body only keeps an id.
|
|||||||
| POST | `/api/attachments` | Upload a base64 data URL |
|
| POST | `/api/attachments` | Upload a base64 data URL |
|
||||||
| GET | `/api/attachments/:id` | Fetch the bytes (owner only) |
|
| GET | `/api/attachments/:id` | Fetch the bytes (owner only) |
|
||||||
|
|
||||||
> Removing an image from a note does not yet delete its blob: the body is
|
### Where it all actually sits
|
||||||
> encrypted, so the server cannot tell what is still referenced. Unused blobs
|
|
||||||
> cost disk space, nothing else.
|
Two directories, both bind-mounted from the host so that whatever backs up the
|
||||||
|
host backs up InkNote:
|
||||||
|
|
||||||
|
```
|
||||||
|
/srv/inknote/db/inknote.db every note, encrypted body and all
|
||||||
|
/srv/inknote/uploads/attachments/<sha> one file per distinct upload, encrypted
|
||||||
|
/srv/inknote/uploads/drawing_<id>.png legacy raster drawings
|
||||||
|
```
|
||||||
|
|
||||||
|
**There is no folder per user, and there is deliberately nothing on disk to read.**
|
||||||
|
Who owns what is a column in the database (`attachments.user_id`), not a path.
|
||||||
|
The file name is the SHA-256 of the *plaintext* — encryption uses a fresh IV each
|
||||||
|
time, so hashing the ciphertext would store the same photo twice — and the bytes
|
||||||
|
beside it are AES-256-GCM. So a file name tells you that two people uploaded the
|
||||||
|
same document, and nothing else: not who, not what, not which note.
|
||||||
|
|
||||||
|
Sorting uploads into per-user folders would name the owner in the clear on a disk
|
||||||
|
where nothing else is readable, and it would break the sharing that keeps one copy
|
||||||
|
of a file two accounts happen to have. Deleting stays safe without it: a blob goes
|
||||||
|
only when **no** row in `attachments` points at it any more, whoever owned them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -990,6 +1017,20 @@ should follow you to the phone.
|
|||||||
A phone has room for neither. There the chapter fills the list and the header
|
A phone has room for neither. There the chapter fills the list and the header
|
||||||
button becomes a way back out, which is what it did before there was a choice.
|
button becomes a way back out, which is what it did before there was a choice.
|
||||||
|
|
||||||
|
**Which list is on screen is not the same as which chapter is open.** Beside and
|
||||||
|
below, the middle column still shows the notebook; only the phone's fallback
|
||||||
|
replaces it. The two were one value for a while, and a separator drawn in a
|
||||||
|
notebook vanished the moment a chapter was opened — the list still showed the
|
||||||
|
notebook, but everything around it had switched to the chapter, so the notebook's
|
||||||
|
own separators were filtered out and a drag in that list wrote nothing at all.
|
||||||
|
|
||||||
|
**Which of the two you are in is a separate question again**, because both are on
|
||||||
|
screen at once. Opening a chapter puts you in it; pressing in the notebook's own
|
||||||
|
list puts you back in the notebook without closing the chapter. Whichever it is
|
||||||
|
wears a line along its top and the word *here*, and the + button names the place
|
||||||
|
it would make the note in. Before, a new note went into the chapter however far
|
||||||
|
away you had since clicked, and nothing on screen said so.
|
||||||
|
|
||||||
### A chapter is a row like any other
|
### A chapter is a row like any other
|
||||||
|
|
||||||
Chapters sit at the same level as notes and separators: same grip, same drag,
|
Chapters sit at the same level as notes and separators: same grip, same drag,
|
||||||
|
|||||||
Reference in new issue
Block a user