Put the interface into English, and give the notebook one way in
The interface had drifted into two languages — the frame in English, everything added later in German — which is worse than either, since the same idea appeared under two names depending on when that part was written. Sorting by title still uses the German collation, so Übung 2 comes before Übung 10: that is about the notes, not the chrome. Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ → drawings/). The importer reads both spellings, and both the old and new marker line for a drawing — archives exported before this are on disk, and an import that quietly dropped their drawings would be the worst kind of tidy-up. Making a note, uploading a PDF and importing Markdown were three buttons side by side, two of them icons you had to hover to identify. They are the same act — putting something new in this notebook — so they are now one + menu. The search box moves out of the sidebar and into the note list, labelled with the notebook it narrows. Nothing changed about what it does: it has always searched only the notebook you are in, but from the sidebar it read as searching everything. A separator no longer carries a sentence explaining the order the sort menu already names. Renaming joins removing on the right-click menu — a left-click on the label started an edit, so the row could not be touched without falling into one. 218 unit tests, 238 browser checks across eleven suites. Two of those caught real breakage from this change rather than needing a translation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a7cf26b28
commit
52bd2b8f3d
27 files changed
+454
-321
No files matched your search
+11
-1
@@ -4,7 +4,17 @@
|
||||
* typed — but the state it buries has to survive as a version, or the other
|
||||
* device's work is simply gone. That is the whole point of the check, so this
|
||||
* verifies it end to end rather than trusting the route in isolation. */
|
||||
import { api, check, createNote, createNotebook, launch, report, setTitle, typeInEditor, withFreshUser } from "./lib.mjs";
|
||||
import {
|
||||
api,
|
||||
check,
|
||||
createNote,
|
||||
createNotebook,
|
||||
launch,
|
||||
report,
|
||||
setTitle,
|
||||
typeInEditor,
|
||||
withFreshUser,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
let session;
|
||||
|
||||
+5
-5
@@ -49,18 +49,18 @@ try {
|
||||
const before = await cellCount();
|
||||
await p.locator(".ProseMirror table td").first().click({ button: "right" });
|
||||
await p.waitForTimeout(600);
|
||||
check((await p.getByText("Zeile darunter einfügen").count()) > 0, "right-click in a table opens the table menu");
|
||||
check((await p.getByText("Tabelle löschen").count()) > 0, "and offers deleting the table");
|
||||
check((await p.getByText("Insert row below").count()) > 0, "right-click in a table opens the table menu");
|
||||
check((await p.getByText("Delete table").count()) > 0, "and offers deleting the table");
|
||||
await p.screenshot({ path: `${OUT}/editor-table-menu.png` });
|
||||
|
||||
await p.getByText("Zeile darunter einfügen").click();
|
||||
await p.getByText("Insert row below").click();
|
||||
await p.waitForTimeout(1000);
|
||||
check((await cellCount()) > before, "the menu really added a row", `${before} → ${await cellCount()}`);
|
||||
|
||||
const withRow = await cellCount();
|
||||
await p.locator(".ProseMirror table td").first().click({ button: "right" });
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByText("Zeile löschen").click();
|
||||
await p.getByText("Delete row").click();
|
||||
await p.waitForTimeout(1000);
|
||||
check((await cellCount()) < withRow, "and removing one works too", `${withRow} → ${await cellCount()}`);
|
||||
|
||||
@@ -68,7 +68,7 @@ try {
|
||||
await p.locator(".ProseMirror p").first().click({ button: "right" });
|
||||
await p.waitForTimeout(500);
|
||||
check(
|
||||
(await p.getByText("Zeile darunter einfügen").count()) === 0,
|
||||
(await p.getByText("Insert row below").count()) === 0,
|
||||
"right-click outside a table shows no table menu"
|
||||
);
|
||||
await p.keyboard.press("Escape");
|
||||
|
||||
+10
-10
@@ -6,11 +6,11 @@
|
||||
* can be unpacked and read here. */
|
||||
import {
|
||||
OUT,
|
||||
captureDownloads,
|
||||
check,
|
||||
clearDownloads,
|
||||
createNote,
|
||||
createNotebook,
|
||||
captureDownloads,
|
||||
clearDownloads,
|
||||
downloads,
|
||||
insertTable,
|
||||
lastDownloadName,
|
||||
@@ -148,7 +148,7 @@ try {
|
||||
const zip = readZip(new Uint8Array(Buffer.from(files[0].base64, "base64")));
|
||||
const names = Object.keys(zip);
|
||||
check(names.some((n) => n.endsWith(".md")), "the archive holds the Markdown file", names.join(", "));
|
||||
check(names.some((n) => /bilder\/.*\.png$/.test(n)), "and the image beside it", names.join(", "));
|
||||
check(names.some((n) => /images\/.*\.png$/.test(n)), "and the image beside it", names.join(", "));
|
||||
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(600);
|
||||
@@ -170,24 +170,24 @@ try {
|
||||
await clearDownloads(p);
|
||||
await openExportDialog(p);
|
||||
check(
|
||||
(await p.getByText("Was soll mit?").count()) > 0,
|
||||
(await p.getByText("What should come along?").count()) > 0,
|
||||
"a note with a drawing is asked what should come along"
|
||||
);
|
||||
check(
|
||||
(await p.getByRole("button", { name: /Nur die Zeichnung/ }).count()) > 0,
|
||||
(await p.getByRole("button", { name: /Only the drawing/ }).count()) > 0,
|
||||
"the drawing-only option is offered"
|
||||
);
|
||||
await p.screenshot({ path: `${OUT}/export-wizard.png` });
|
||||
|
||||
// drawing only → PNG
|
||||
await p.getByRole("button", { name: /Nur die Zeichnung/ }).click();
|
||||
await p.getByRole("button", { name: /Only the drawing/ }).click();
|
||||
await p.waitForTimeout(500);
|
||||
check((await p.getByRole("button", { name: /PNG-Bild/ }).count()) > 0, "PNG is offered for the drawing");
|
||||
check((await p.getByRole("button", { name: /PNG image/ }).count()) > 0, "PNG is offered for the drawing");
|
||||
check(
|
||||
(await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).count()) === 0,
|
||||
"Markdown is not offered for a drawing, since it cannot hold one"
|
||||
);
|
||||
await p.getByRole("button", { name: /PNG-Bild/ }).click();
|
||||
await p.getByRole("button", { name: /PNG image/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(800);
|
||||
|
||||
@@ -203,9 +203,9 @@ try {
|
||||
check(png.length > 1000, "the image has actual content, not an empty canvas", `${png.length} bytes`);
|
||||
|
||||
// back, then text only → Markdown must carry no drawing
|
||||
await p.getByRole("button", { name: /Zurück/ }).click();
|
||||
await p.getByRole("button", { name: /Back/ }).click();
|
||||
await p.waitForTimeout(500);
|
||||
await p.getByRole("button", { name: /Nur den Notiztext/ }).click();
|
||||
await p.getByRole("button", { name: /Only the note text/ }).click();
|
||||
await p.waitForTimeout(500);
|
||||
await clearDownloads(p);
|
||||
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||
|
||||
+19
-1
@@ -171,12 +171,30 @@ export async function createNotebook(page, name) {
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
/** Making a note, importing a PDF and importing Markdown share one + menu. */
|
||||
export async function openAddMenu(page) {
|
||||
await page.locator("[data-add-menu]").click();
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
export async function createNote(page) {
|
||||
await page.getByTitle("New Note").click();
|
||||
await openAddMenu(page);
|
||||
await page.locator('[data-add-option="New note"]').click();
|
||||
await page.waitForSelector(".ProseMirror", { timeout: 15000 });
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
/** Opens the search box in the note list and types into it. */
|
||||
export async function search(page, query) {
|
||||
const box = page.locator('input[aria-label="Search in this notebook"]');
|
||||
if ((await box.count()) === 0) {
|
||||
await page.locator("[data-search-toggle]").click();
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
await box.fill(query);
|
||||
await page.waitForTimeout(1300);
|
||||
}
|
||||
|
||||
/** The sidebar search box is also a text input, so the title needs its own handle. */
|
||||
export async function setTitle(page, title) {
|
||||
await page.getByLabel("Note title").fill(title);
|
||||
|
||||
+3
-4
@@ -16,6 +16,7 @@ import {
|
||||
createNotebook,
|
||||
launch,
|
||||
report,
|
||||
search,
|
||||
setTitle,
|
||||
typeInEditor,
|
||||
withFreshUser,
|
||||
@@ -114,12 +115,10 @@ try {
|
||||
|
||||
/* Searching is local by design — the server holds only ciphertext — so it has
|
||||
to keep working when the server is not there at all. */
|
||||
await p.getByPlaceholder("Search notes...").fill("Kaffee");
|
||||
await p.waitForTimeout(1500);
|
||||
await search(p, "Kaffee");
|
||||
const hits = await p.locator("[data-note-row]").count();
|
||||
check(hits === 1, "search still finds a note by its text, offline", `${hits} hits`);
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(800);
|
||||
await search(p, "");
|
||||
|
||||
/* Writing offline is queued, not lost. */
|
||||
await p.locator("[data-note-row]").filter({ hasText: "Einkauf" }).first().click();
|
||||
|
||||
+26
-18
@@ -12,7 +12,9 @@ import {
|
||||
createNote,
|
||||
createNotebook,
|
||||
launch,
|
||||
openAddMenu,
|
||||
report,
|
||||
search,
|
||||
setTitle,
|
||||
typeInEditor,
|
||||
withFreshUser,
|
||||
@@ -106,13 +108,11 @@ try {
|
||||
);
|
||||
|
||||
/* The point of the whole feature. */
|
||||
await p.getByPlaceholder("Search notes...").fill("Beta");
|
||||
await p.waitForTimeout(1200);
|
||||
await search(p, "Beta");
|
||||
const searched = await titles();
|
||||
check(searched.length < 3, "searching narrows the list", searched.join(", "));
|
||||
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(1500);
|
||||
await search(p, "");
|
||||
check(
|
||||
(await titles()).join(",") === arranged.join(","),
|
||||
"and clearing the search shows exactly the arrangement again",
|
||||
@@ -150,15 +150,21 @@ try {
|
||||
await p.waitForTimeout(1800);
|
||||
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
|
||||
|
||||
/* Renaming is right-click, like removing. A left-click on the label used to
|
||||
start an edit, so the row could not be touched without falling into one. */
|
||||
const label = p.getByLabel("Separator label");
|
||||
await p.locator("[data-separator] button").first().click();
|
||||
await p.waitForTimeout(300);
|
||||
check((await label.count()) === 0, "clicking a separator does not drop straight into renaming");
|
||||
|
||||
await p.locator("[data-separator]").click({ button: "right" });
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByText("Rename", { exact: true }).click();
|
||||
await p.waitForTimeout(500);
|
||||
await label.fill("Wichtig");
|
||||
await label.press("Enter");
|
||||
await p.waitForTimeout(1800);
|
||||
check(
|
||||
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
|
||||
"and named",
|
||||
"and it can be named from the same menu",
|
||||
await p.locator("[data-separator]").innerText()
|
||||
);
|
||||
|
||||
@@ -197,14 +203,14 @@ try {
|
||||
|
||||
await p.locator("[data-separator]").click({ button: "right" });
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByText("Trenner entfernen").click();
|
||||
await p.getByText("Remove separator").click();
|
||||
await p.waitForTimeout(600);
|
||||
check(
|
||||
/Notizen bleiben/.test(await p.locator("body").innerText()),
|
||||
/notes stay/i.test(await p.locator("body").innerText()),
|
||||
"right-clicking it asks first, and says the notes are not affected"
|
||||
);
|
||||
|
||||
await p.getByRole("button", { name: "Entfernen" }).click();
|
||||
await p.getByRole("button", { name: "Remove" }).click();
|
||||
await p.waitForTimeout(2000);
|
||||
check((await p.locator("[data-separator]").count()) === 0, "and then it is gone");
|
||||
check((await titles()).length === 3, "with every note still there", (await titles()).join(", "));
|
||||
@@ -251,7 +257,7 @@ try {
|
||||
check(markdown.includes("- [x] erledigt"), "the export carries the ticked box", JSON.stringify(markdown.slice(-60)));
|
||||
check(markdown.startsWith("# Beta"), "and the title as a heading");
|
||||
|
||||
await p.locator("[data-export-dialog] button").filter({ hasText: /Schließen|Fertig|Close/ }).first().click().catch(() => {});
|
||||
await p.locator("[data-export-dialog] button").filter({ hasText: /Close|Done/ }).first().click().catch(() => {});
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
@@ -270,14 +276,15 @@ try {
|
||||
}, markdown);
|
||||
|
||||
const chooser = p.waitForEvent("filechooser");
|
||||
await p.locator("[data-import-button]").click();
|
||||
await openAddMenu(p);
|
||||
await p.locator('[data-add-option="Import Markdown"]').click();
|
||||
const fc = await chooser;
|
||||
await fc.setFiles({ name: "Beta.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) });
|
||||
await p.waitForTimeout(4000);
|
||||
|
||||
const importedText = await p.locator("body").innerText();
|
||||
check(/importiert/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " "));
|
||||
await p.getByRole("button", { name: /Fertig/ }).click();
|
||||
check(/imported/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " "));
|
||||
await p.getByRole("button", { name: /Done/ }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
const after = (await api(p, "GET", "/notes")).body.length;
|
||||
@@ -307,7 +314,7 @@ try {
|
||||
check(await field.isVisible(), "the link dialog is a real dialog in the page");
|
||||
|
||||
await field.fill("javascript:alert(1)");
|
||||
await p.getByRole("button", { name: /Einfügen/ }).click();
|
||||
await p.getByRole("button", { name: /^Insert$/ }).click();
|
||||
await p.waitForTimeout(400);
|
||||
check(
|
||||
await field.isVisible(),
|
||||
@@ -315,7 +322,7 @@ try {
|
||||
);
|
||||
|
||||
await field.fill("example.com");
|
||||
await p.getByRole("button", { name: /Einfügen/ }).click();
|
||||
await p.getByRole("button", { name: /^Insert$/ }).click();
|
||||
await p.waitForTimeout(1800);
|
||||
const href = await p.locator(".ProseMirror a[href]").first().getAttribute("href");
|
||||
check(href === "https://example.com", "a bare domain is taken as https", String(href));
|
||||
@@ -341,7 +348,8 @@ try {
|
||||
await p.keyboard.press("Escape");
|
||||
await p.locator("h2").first().click();
|
||||
await p.waitForTimeout(500);
|
||||
await p.getByTitle("New Note").click();
|
||||
await openAddMenu(p);
|
||||
await p.locator('[data-add-option="New note"]').click();
|
||||
await p.waitForTimeout(900);
|
||||
|
||||
const books0 = (await api(p, "GET", "/notebooks")).body;
|
||||
@@ -352,7 +360,7 @@ try {
|
||||
// sort order, so "the last one" is not a stable way to mean a particular book.
|
||||
const target = books0.find((b) => b.name === "Zweites Buch");
|
||||
await p.locator(`[data-notebook-option="${target.id}"]`).click();
|
||||
await p.getByRole("button", { name: /Weiter/ }).click();
|
||||
await p.getByRole("button", { name: /Continue/ }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
// By title, not by position: the list is ordered by a timestamp with one
|
||||
|
||||
+28
-32
@@ -15,7 +15,9 @@ import {
|
||||
downloads,
|
||||
lastDownloadName,
|
||||
launch,
|
||||
openAddMenu,
|
||||
report,
|
||||
search,
|
||||
withFreshUser,
|
||||
} from "./lib.mjs";
|
||||
|
||||
@@ -115,14 +117,15 @@ try {
|
||||
|
||||
/* ── import ── */
|
||||
const chooser = p.waitForEvent("filechooser");
|
||||
await p.getByTitle("Import PDF").click();
|
||||
await openAddMenu(p);
|
||||
await p.locator('[data-add-option="Upload PDF"]').click();
|
||||
await (await chooser).setFiles(PDF_PATH);
|
||||
await p.waitForTimeout(4000);
|
||||
|
||||
check((await p.locator("[data-pdf-page]").count()) > 0, "the document is shown after importing");
|
||||
const pdfNoteId = (await api(p, "GET", "/notes")).body[0].id;
|
||||
check(
|
||||
(await p.locator("[data-pdf-position]").innerText()).includes("/ 2"),
|
||||
(await p.locator("[data-pdf-position]").innerText()).includes("of 2"),
|
||||
"both pages are counted",
|
||||
await p.locator("[data-pdf-position]").innerText()
|
||||
);
|
||||
@@ -188,18 +191,15 @@ try {
|
||||
|
||||
/* ── the document is findable ── */
|
||||
await p.waitForTimeout(2500); // extraction runs after the first page is shown
|
||||
await p.getByPlaceholder("Search notes...").fill("Zebrastreifen");
|
||||
await p.waitForTimeout(1500);
|
||||
await search(p, "Zebrastreifen");
|
||||
check(
|
||||
(await p.locator("[data-note-row]").count()) === 1,
|
||||
"searching a word from inside the PDF finds the note",
|
||||
`${await p.locator("[data-note-row]").count()} rows`
|
||||
);
|
||||
await p.getByPlaceholder("Search notes...").fill("Wortdasnichtvorkommt");
|
||||
await p.waitForTimeout(1200);
|
||||
await search(p, "Wortdasnichtvorkommt");
|
||||
check((await p.locator("[data-note-row]").count()) === 0, "and a word that is not in it finds nothing");
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(1000);
|
||||
await search(p, "");
|
||||
await p.locator("[data-note-row]").first().click();
|
||||
await p.waitForTimeout(3000);
|
||||
|
||||
@@ -262,12 +262,12 @@ try {
|
||||
}, n);
|
||||
|
||||
const before = await inkBounds(1);
|
||||
await p.getByTitle("Größer").click();
|
||||
await p.getByTitle("Zoom in").click();
|
||||
await p.waitForTimeout(2500);
|
||||
const after = await inkBounds(1);
|
||||
const drift = Math.max(Math.abs(before.x - after.x), Math.abs(before.y - after.y), Math.abs(before.w - after.w));
|
||||
check(drift < 0.02, "the stroke stays on the same spot when zoomed", `drift ${drift.toFixed(3)}`);
|
||||
await p.getByTitle("Kleiner").click();
|
||||
await p.getByTitle("Zoom out").click();
|
||||
await p.waitForTimeout(1500);
|
||||
|
||||
/* ── hiding it ── */
|
||||
@@ -325,11 +325,9 @@ try {
|
||||
await p.screenshot({ path: "/out/pdf-with-notes.png" });
|
||||
|
||||
/* And they are searchable, alongside the document's own text. */
|
||||
await p.getByPlaceholder("Search notes...").fill("Randbemerkung");
|
||||
await p.waitForTimeout(1500);
|
||||
await search(p, "Randbemerkung");
|
||||
check((await p.locator("[data-note-row]").count()) === 1, "notes written beside a PDF are searchable");
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(1000);
|
||||
await search(p, "");
|
||||
await p.locator("[data-note-row]").first().click();
|
||||
await p.waitForTimeout(3000);
|
||||
|
||||
@@ -345,14 +343,14 @@ try {
|
||||
|
||||
await openExport();
|
||||
check(
|
||||
(await p.getByText("Was soll heraus?").count()) > 0,
|
||||
(await p.getByText("What should come out?").count()) > 0,
|
||||
"a PDF note is asked what should come out, not offered note formats"
|
||||
);
|
||||
await p.screenshot({ path: "/out/pdf-export-wizard.png" });
|
||||
|
||||
/* The untouched original. */
|
||||
await clearDownloads(p);
|
||||
await p.getByRole("button", { name: /PDF im Original/ }).click();
|
||||
await p.getByRole("button", { name: /The original PDF/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(900);
|
||||
let files = await downloads(p);
|
||||
@@ -369,7 +367,7 @@ try {
|
||||
/* The same document with the strokes written in. */
|
||||
await openExport();
|
||||
await clearDownloads(p);
|
||||
await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click();
|
||||
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(1500);
|
||||
files = await downloads(p);
|
||||
@@ -378,7 +376,7 @@ try {
|
||||
check(stamped.subarray(0, 5).toString() === "%PDF-", "the annotated file is a PDF", stamped.subarray(0, 8).toString());
|
||||
check(stamped.length > originalBytes.length, "and carries more than the original did", `${stamped.length} vs ${originalBytes.length}`);
|
||||
check(!stamped.equals(originalBytes), "so it is not simply a copy");
|
||||
check(/mit Notizen/.test(await lastDownloadName(p)), "named so the two cannot be confused", await lastDownloadName(p));
|
||||
check(/annotated/.test(await lastDownloadName(p)), "named so the two cannot be confused", await lastDownloadName(p));
|
||||
|
||||
/* The stored file must still be the original — drawing on it changes nothing
|
||||
on disk, or a clean copy could never be had again. */
|
||||
@@ -502,7 +500,7 @@ try {
|
||||
check(levels.cardLeft >= levels.pageRight - 2, "and beside the page rather than over it");
|
||||
|
||||
await field.fill("Drehmoment beachten");
|
||||
await p.getByRole("button", { name: "Fertig" }).click();
|
||||
await p.getByRole("button", { name: "Done" }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
/* It has to be on the passage, not near it — so the position is checked
|
||||
@@ -520,7 +518,7 @@ try {
|
||||
|
||||
/* Stored in PDF points, so zooming must not move it off its passage — the same
|
||||
property the strokes have. */
|
||||
await p.getByTitle("Größer").click();
|
||||
await p.getByTitle("Zoom in").click();
|
||||
await p.waitForTimeout(2000);
|
||||
const zoomed = await p.evaluate(() => {
|
||||
const pin = document.querySelector("[data-pin]").getBoundingClientRect();
|
||||
@@ -532,20 +530,18 @@ try {
|
||||
"and stays there when the document is zoomed",
|
||||
`${zoomed.x.toFixed(2)} / ${zoomed.y.toFixed(2)}`
|
||||
);
|
||||
await p.getByTitle("Kleiner").click();
|
||||
await p.getByTitle("Zoom out").click();
|
||||
await p.waitForTimeout(1200);
|
||||
|
||||
/* Searchable: it lives in the page overlay, not in the note body, so nothing
|
||||
else would ever index it. */
|
||||
await p.getByPlaceholder("Search notes...").fill("Drehmoment");
|
||||
await p.waitForTimeout(1500);
|
||||
await search(p, "Drehmoment");
|
||||
check(
|
||||
(await p.locator("[data-note-row]").count()) === 1,
|
||||
"a pinned note can be searched for",
|
||||
String(await p.locator("[data-note-row]").count())
|
||||
);
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(1000);
|
||||
await search(p, "");
|
||||
|
||||
/* It belongs to the note, so it has to come back from the server. */
|
||||
await p.reload({ waitUntil: "networkidle" });
|
||||
@@ -575,7 +571,7 @@ try {
|
||||
await p.waitForTimeout(400);
|
||||
await p.locator("[data-pdf-mode]").click();
|
||||
await p.waitForTimeout(500);
|
||||
await p.getByTitle("Seite leeren").click();
|
||||
await p.getByTitle("Clear page").click();
|
||||
await p.waitForTimeout(2000);
|
||||
check((await p.locator("[data-pin]").count()) === 1, "emptying the page's ink leaves the pinned note");
|
||||
await p.locator("[data-pdf-mode]").click();
|
||||
@@ -595,7 +591,7 @@ try {
|
||||
await p.mouse.click(page1b.x + page1b.width * 0.75, page1b.y + page1b.height * 0.7);
|
||||
await p.waitForTimeout(700);
|
||||
await p.getByLabel("Pinned note").fill("Randbemerkung Zebrastreifen");
|
||||
await p.getByRole("button", { name: "Fertig" }).click();
|
||||
await p.getByRole("button", { name: "Done" }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
/* The server keeps its own judgement of "is this overlay empty". It counted
|
||||
@@ -618,7 +614,7 @@ try {
|
||||
await p.getByTitle("Export").click();
|
||||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||
await p.waitForTimeout(800);
|
||||
await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click();
|
||||
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(1500);
|
||||
const withPins = Buffer.from((await downloads(p))[0].base64, "base64");
|
||||
@@ -643,7 +639,7 @@ try {
|
||||
await p.mouse.click(pageL.x + pageL.width * 0.35, pageL.y + pageL.height * 0.2);
|
||||
await p.waitForTimeout(800);
|
||||
await p.getByLabel("Pinned note").fill("Zebrastreifen nachschlagen");
|
||||
await p.getByRole("button", { name: "Fertig" }).click();
|
||||
await p.getByRole("button", { name: "Done" }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
await p.locator("[data-pdf-toggle-notes]").click();
|
||||
@@ -671,7 +667,7 @@ try {
|
||||
await linkEl.getAttribute("data-kind")
|
||||
);
|
||||
check(
|
||||
(await linkEl.innerText()).includes("S. 1"),
|
||||
(await linkEl.innerText()).includes("p. 1"),
|
||||
"labelled with the page it points at",
|
||||
await linkEl.innerText()
|
||||
);
|
||||
@@ -788,7 +784,7 @@ try {
|
||||
await p.mouse.click(pageM.x + pageM.width * 0.5, pageM.y + pageM.height * 0.4);
|
||||
await p.waitForTimeout(800);
|
||||
await p.getByLabel("Pinned note").fill("Am Rand notiert");
|
||||
await p.getByRole("button", { name: "Fertig" }).click();
|
||||
await p.getByRole("button", { name: "Done" }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
const wideMargins = await p.locator("[data-pdf-margin]").count();
|
||||
@@ -865,7 +861,7 @@ try {
|
||||
`top ${Math.round(sheet.y)} of 820`
|
||||
);
|
||||
|
||||
await p.getByRole("button", { name: "Fertig" }).click();
|
||||
await p.getByRole("button", { name: "Done" }).click();
|
||||
await p.waitForTimeout(800);
|
||||
check((await p.locator("[data-pin-sheet]").count()) === 0, "and closes again");
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createNotebook,
|
||||
launch,
|
||||
report,
|
||||
search,
|
||||
setTitle,
|
||||
typeInEditor,
|
||||
withFreshUser,
|
||||
@@ -47,23 +48,23 @@ try {
|
||||
// Give the background backfill time to index the bodies.
|
||||
await p.waitForTimeout(3000);
|
||||
|
||||
const search = async (q) => {
|
||||
await p.getByPlaceholder("Search notes...").fill(q);
|
||||
/** Types into the note list's search box and reports what is left showing. */
|
||||
const find = async (q) => {
|
||||
await search(p, q);
|
||||
await p.waitForTimeout(1200);
|
||||
return titles(p);
|
||||
};
|
||||
|
||||
check(JSON.stringify(await search("Zebrastreifen")) === '["Notiz C"]', "a word only in the body finds its note", JSON.stringify(await titles(p)));
|
||||
const hits = await search("Dichtung");
|
||||
check(JSON.stringify(await find("Zebrastreifen")) === '["Notiz C"]', "a word only in the body finds its note", JSON.stringify(await titles(p)));
|
||||
const hits = await find("Dichtung");
|
||||
check(hits.length === 2, "a word in one title and another body finds both", JSON.stringify(hits));
|
||||
check(hits[0] === "Dichtung", "the title match ranks first", JSON.stringify(hits));
|
||||
check(JSON.stringify(await search("Belege Ordner")) === '["Notiz B"]', "multiple terms are an AND");
|
||||
check((await search("Belege Kühlschrank")).length === 0, "terms from different notes match nothing");
|
||||
check(JSON.stringify(await search("kuhlschrank")) === '["Notiz A"]', "search ignores case and umlauts");
|
||||
check(JSON.stringify(await find("Belege Ordner")) === '["Notiz B"]', "multiple terms are an AND");
|
||||
check((await find("Belege Kühlschrank")).length === 0, "terms from different notes match nothing");
|
||||
check(JSON.stringify(await find("kuhlschrank")) === '["Notiz A"]', "search ignores case and umlauts");
|
||||
await p.screenshot({ path: `${OUT}/search-results.png` });
|
||||
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(1000);
|
||||
await search(p, "");
|
||||
check((await titles(p)).length === seed.length, "clearing the box restores the full list");
|
||||
|
||||
/* ── attachments ── */
|
||||
|
||||
+20
-13
@@ -71,6 +71,9 @@ export default function App() {
|
||||
store a choice — "All Notes" is not a thing that can carry a setting — so it
|
||||
lasts for the session and no longer. */
|
||||
const [virtualSort, setVirtualSort] = useState({});
|
||||
// Which separator is being renamed. Held here because the context menu that
|
||||
// starts it lives here too.
|
||||
const [renamingSeparator, setRenamingSeparator] = useState(null);
|
||||
const [historyFor, setHistoryFor] = useState(null);
|
||||
const [ctxMenu, setCtxMenu] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||
@@ -209,17 +212,19 @@ export default function App() {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
items: [
|
||||
{ label: "Rename", icon: I.edit, action: () => setRenamingSeparator(separator.id) },
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Trenner entfernen",
|
||||
label: "Remove separator",
|
||||
icon: I.trash,
|
||||
danger: true,
|
||||
action: () =>
|
||||
confirm({
|
||||
title: "Trenner entfernen",
|
||||
title: "Remove separator",
|
||||
message: separator.label
|
||||
? `Trennlinie "${separator.label}" entfernen? Die Notizen bleiben.`
|
||||
: "Diese Trennlinie entfernen? Die Notizen bleiben.",
|
||||
confirmLabel: "Entfernen",
|
||||
? `Remove the separator "${separator.label}"? The notes stay.`
|
||||
: "Remove this separator? The notes stay.",
|
||||
confirmLabel: "Remove",
|
||||
onConfirm: () => removeSeparator(separator.id),
|
||||
}),
|
||||
},
|
||||
@@ -354,28 +359,28 @@ export default function App() {
|
||||
|
||||
const reportFailure = (message) =>
|
||||
setConfirmDialog({
|
||||
title: "Import fehlgeschlagen",
|
||||
title: "Import failed",
|
||||
message,
|
||||
confirmLabel: "OK",
|
||||
onConfirm: () => setConfirmDialog(null),
|
||||
});
|
||||
|
||||
const importPdf = () =>
|
||||
withNotebook("PDF importieren", (folderId) =>
|
||||
withNotebook("Upload PDF", (folderId) =>
|
||||
chooseFiles("application/pdf,.pdf", false, async ([file]) => {
|
||||
try {
|
||||
const note = await data.importPdf(folderId, file);
|
||||
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||
openNote(note.id);
|
||||
} catch (e) {
|
||||
reportFailure(e?.message || "Die Datei konnte nicht importiert werden.");
|
||||
reportFailure(e?.message || "The file could not be imported.");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/** Markdown files, or a ZIP written by this app's export. */
|
||||
const importMarkdown = () =>
|
||||
withNotebook("Notizen importieren", (folderId) =>
|
||||
withNotebook("Import notes", (folderId) =>
|
||||
chooseFiles(".md,.markdown,.txt,.zip,text/markdown,application/zip", true, (files) => {
|
||||
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||
setImporting({ files, folderId });
|
||||
@@ -383,7 +388,7 @@ export default function App() {
|
||||
);
|
||||
|
||||
const createNote = () =>
|
||||
withNotebook("Neue Notiz", async (folderId) => {
|
||||
withNotebook("New note", async (folderId) => {
|
||||
const note = await data.createNote(folderId);
|
||||
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||
openNote(note.id);
|
||||
@@ -586,8 +591,6 @@ export default function App() {
|
||||
isMobile={isMobile}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
activeFolder={activeFolder}
|
||||
onSelectFolder={openFolder}
|
||||
notebooks={notebooks}
|
||||
@@ -632,12 +635,16 @@ export default function App() {
|
||||
isTrashView={isTrashView}
|
||||
trashedCount={trashedNotes.length}
|
||||
snippetFor={search.snippetFor}
|
||||
query={searchQuery}
|
||||
onQueryChange={setSearchQuery}
|
||||
sortMode={sortMode}
|
||||
sortModes={sortModesFor(!!activeNotebook)}
|
||||
onSortModeChange={setSortMode}
|
||||
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
|
||||
separators={separators}
|
||||
onAddSeparator={addSeparator}
|
||||
renamingSeparator={renamingSeparator}
|
||||
onStartRenameSeparator={setRenamingSeparator}
|
||||
onRenameSeparator={renameSeparator}
|
||||
onSeparatorContextMenu={separatorContextMenu}
|
||||
dnd={noteDnd}
|
||||
@@ -700,7 +707,7 @@ export default function App() {
|
||||
}}
|
||||
onExportAll={() => {
|
||||
setShowAccount(false);
|
||||
setExporting({ label: "Alle Notizen", notes: activeNotes });
|
||||
setExporting({ label: "All Notes", notes: activeNotes });
|
||||
}}
|
||||
dark={dark}
|
||||
/>
|
||||
|
||||
@@ -33,10 +33,10 @@ export function tableMenuItems(editor) {
|
||||
};
|
||||
|
||||
const items = [
|
||||
{ label: "Zeile darüber einfügen", action: run((c) => c.addRowBefore()) },
|
||||
{ label: "Zeile darunter einfügen", action: run((c) => c.addRowAfter()) },
|
||||
{ label: "Spalte links einfügen", action: run((c) => c.addColumnBefore()) },
|
||||
{ label: "Spalte rechts einfügen", action: run((c) => c.addColumnAfter()) },
|
||||
{ label: "Insert row above", action: run((c) => c.addRowBefore()) },
|
||||
{ label: "Insert row below", action: run((c) => c.addRowAfter()) },
|
||||
{ label: "Insert column left", action: run((c) => c.addColumnBefore()) },
|
||||
{ label: "Insert column right", action: run((c) => c.addColumnAfter()) },
|
||||
{ divider: true },
|
||||
{ label: "Kopfzeile an/aus", action: run((c) => c.toggleHeaderRow()) },
|
||||
];
|
||||
@@ -48,9 +48,9 @@ export function tableMenuItems(editor) {
|
||||
|
||||
items.push(
|
||||
{ divider: true },
|
||||
{ label: "Zeile löschen", danger: true, action: run((c) => c.deleteRow()) },
|
||||
{ label: "Spalte löschen", danger: true, action: run((c) => c.deleteColumn()) },
|
||||
{ label: "Tabelle löschen", danger: true, action: run((c) => c.deleteTable()) }
|
||||
{ label: "Delete row", danger: true, action: run((c) => c.deleteRow()) },
|
||||
{ label: "Delete column", danger: true, action: run((c) => c.deleteColumn()) },
|
||||
{ label: "Delete table", danger: true, action: run((c) => c.deleteTable()) }
|
||||
);
|
||||
|
||||
return items;
|
||||
|
||||
@@ -75,7 +75,7 @@ async function loadNote(meta: NoteMeta): Promise<Loaded> {
|
||||
|
||||
/** Unique filename per note — two notes may well share a title. */
|
||||
function nameFor(meta: NoteMeta, taken: Set<string>): string {
|
||||
const base = safeFilename(meta.title, "Notiz");
|
||||
const base = safeFilename(meta.title, "Note");
|
||||
let name = base;
|
||||
for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${base} (${i})`;
|
||||
taken.add(name.toLowerCase());
|
||||
@@ -92,9 +92,9 @@ function nameFor(meta: NoteMeta, taken: Set<string>): string {
|
||||
* so a clean copy stays available however often it has been drawn on.
|
||||
*/
|
||||
export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise<void> {
|
||||
if (!meta.pdfAttachmentId) throw new Error("Zu dieser Notiz gehört keine PDF-Datei.");
|
||||
if (!meta.pdfAttachmentId) throw new Error("This note has no PDF attached.");
|
||||
const bytes = await sync.loadPdfBytes(meta.pdfAttachmentId);
|
||||
const name = safeFilename(meta.title, "Dokument");
|
||||
const name = safeFilename(meta.title, "Document");
|
||||
|
||||
if (!withInk) {
|
||||
download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`);
|
||||
@@ -111,7 +111,7 @@ export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise<v
|
||||
}
|
||||
|
||||
const stamped = await stampStrokesIntoPdf(bytes, ink);
|
||||
download(bytesToBlob(stamped, "application/pdf"), `${name} (mit Notizen).pdf`);
|
||||
download(bytesToBlob(stamped, "application/pdf"), `${name} (annotated).pdf`);
|
||||
}
|
||||
|
||||
/** True when the PDF carries drawings or pinned notes, so the wizard can say so. */
|
||||
@@ -143,7 +143,7 @@ export async function noteHasDrawing(meta: NoteMeta): Promise<boolean> {
|
||||
export async function exportDrawingAsPng(meta: NoteMeta): Promise<void> {
|
||||
const { content } = await loadNote(meta);
|
||||
const png = await drawingToPng(content.drawingStrokes);
|
||||
if (!png) throw new Error("Diese Notiz enthält keine Zeichnung.");
|
||||
if (!png) throw new Error("This note has no drawing.");
|
||||
download(bytesToBlob(png, "image/png"), `${safeFilename(meta.title)}.png`);
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
|
||||
title: meta.title,
|
||||
updatedAt: meta.updatedAt,
|
||||
hasDrawing,
|
||||
attachmentPath: (id) => `${safeFilename(meta.title)}-bilder/${id}.png`,
|
||||
attachmentPath: (id) => `${safeFilename(meta.title)}-images/${id}.png`,
|
||||
notePath: () => null, // a lone note has nothing to link to
|
||||
});
|
||||
|
||||
@@ -171,10 +171,10 @@ export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
|
||||
const entries: ZipEntry[] = [{ name: `${folder}.md`, data: md }];
|
||||
for (const id of ids) {
|
||||
const bytes = await fetchAttachment(id);
|
||||
if (bytes) entries.push({ name: `${folder}-bilder/${id}.png`, data: bytes });
|
||||
if (bytes) entries.push({ name: `${folder}-images/${id}.png`, data: bytes });
|
||||
}
|
||||
const png = await drawingToPng(content.drawingStrokes);
|
||||
if (png) entries.push({ name: `${folder}-zeichnung.png`, data: png });
|
||||
if (png) entries.push({ name: `${folder}-drawing.png`, data: png });
|
||||
|
||||
download(createZip(entries), `${folder}.zip`);
|
||||
}
|
||||
@@ -229,7 +229,7 @@ export async function exportNotesAsPdf(
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.({ done: total, total, label: "Druckansicht wird geöffnet" });
|
||||
onProgress?.({ done: total, total, label: "Opening the print view" });
|
||||
await printNotes(printable, title);
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ export async function exportNotesAsZip({
|
||||
const index: string[] = [
|
||||
`# ${label}`,
|
||||
"",
|
||||
`${notes.length} Notizen, exportiert am ${new Date().toLocaleString("de-DE")}`,
|
||||
`${notes.length} notes, exported ${new Date().toLocaleString()}`,
|
||||
"",
|
||||
];
|
||||
const byNotebook = new Map<string | null, NoteMeta[]>();
|
||||
@@ -300,24 +300,24 @@ export async function exportNotesAsZip({
|
||||
title: meta.title,
|
||||
updatedAt: meta.updatedAt,
|
||||
hasDrawing,
|
||||
attachmentPath: (id) => `bilder/${id}.png`,
|
||||
attachmentPath: (id) => `images/${id}.png`,
|
||||
notePath,
|
||||
}),
|
||||
});
|
||||
|
||||
for (const id of attachmentIds(doc)) {
|
||||
if (entries.some((e) => e.name === `bilder/${id}.png`)) continue;
|
||||
if (entries.some((e) => e.name === `images/${id}.png`)) continue;
|
||||
const bytes = await fetchAttachment(id);
|
||||
if (bytes) entries.push({ name: `bilder/${id}.png`, data: bytes });
|
||||
if (bytes) entries.push({ name: `images/${id}.png`, data: bytes });
|
||||
}
|
||||
const png = await drawingToPng(content.drawingStrokes);
|
||||
if (png) entries.push({ name: `zeichnungen/${name}.png`, data: png });
|
||||
if (png) entries.push({ name: `drawings/${name}.png`, data: png });
|
||||
} catch (err) {
|
||||
// A note that cannot be read is named in the index rather than skipped in
|
||||
// silence — an export that quietly drops things is worse than useless.
|
||||
entries.push({
|
||||
name: `${name}.md`,
|
||||
data: `# ${meta.title}\n\n> Diese Notiz konnte beim Export nicht gelesen werden.\n> (${(err as Error).message})\n`,
|
||||
data: `# ${meta.title}\n\n> This note could not be read when exporting.\n> (${(err as Error).message})\n`,
|
||||
});
|
||||
}
|
||||
done++;
|
||||
@@ -325,7 +325,7 @@ export async function exportNotesAsZip({
|
||||
|
||||
for (const [notebookId, group] of byNotebook) {
|
||||
const nb = notebooks.find((b) => b.id === notebookId);
|
||||
index.push("", `## ${nb?.name || "Ohne Notizbuch"}`, "");
|
||||
index.push("", `## ${nb?.name || "No notebook"}`, "");
|
||||
for (const n of group) index.push(`- [${n.title || "Untitled"}](${notePath(n.id)})`);
|
||||
}
|
||||
entries.unshift({ name: "README.md", data: index.join("\n") + "\n" });
|
||||
|
||||
@@ -183,7 +183,7 @@ describe("document framing", () => {
|
||||
|
||||
it("says when a drawing could not come along, instead of dropping it silently", () => {
|
||||
const out = md(doc(para(text("Text"))), { hasDrawing: true });
|
||||
expect(out).toContain("Zeichnung");
|
||||
expect(out).toContain("drawing");
|
||||
});
|
||||
|
||||
it("collapses the blank lines the block rules emit generously", () => {
|
||||
@@ -208,7 +208,7 @@ describe("safeFilename", () => {
|
||||
});
|
||||
|
||||
it("falls back when nothing usable is left", () => {
|
||||
expect(safeFilename("")).toBe("Notiz");
|
||||
expect(safeFilename("")).toBe("Note");
|
||||
expect(safeFilename("///")).toBe("---");
|
||||
});
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str
|
||||
if (options.title) {
|
||||
lines.push(`# ${options.title}`, "");
|
||||
if (options.updatedAt) {
|
||||
lines.push(`*${new Date(options.updatedAt).toLocaleString("de-DE")}*`, "");
|
||||
lines.push(`*${new Date(options.updatedAt).toLocaleString()}*`, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,8 +227,8 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str
|
||||
lines.push(...blocks);
|
||||
|
||||
if (options.hasDrawing) {
|
||||
lines.push("", "> ✏️ Diese Notiz enthält eine Zeichnung, die Markdown nicht abbilden kann.");
|
||||
lines.push("> Sie liegt als PNG neben der Datei.");
|
||||
lines.push("", "> ✏️ This note has a drawing, which Markdown cannot hold.");
|
||||
lines.push("> It sits beside this file as a PNG.");
|
||||
}
|
||||
|
||||
// Collapse runs of blank lines; the block rules emit them generously.
|
||||
@@ -239,7 +239,7 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str
|
||||
}
|
||||
|
||||
/** Filename-safe version of a note title. */
|
||||
export function safeFilename(title: string, fallback = "Notiz"): string {
|
||||
export function safeFilename(title: string, fallback = "Note"): string {
|
||||
const cleaned = (title || "")
|
||||
.replace(/[/\\?%*:|"<>]/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
|
||||
@@ -44,17 +44,20 @@ const MIDDLE_W = 132;
|
||||
* Draggable like a note, because a divider that cannot move with what it
|
||||
* divides is worse than none.
|
||||
*
|
||||
* Removed by right-click rather than by a × on the row. The × sat a few pixels
|
||||
* from the label you click to rename, so the gesture that tidies the list and
|
||||
* the gesture that destroys part of it were neighbours — and right-click is
|
||||
* already how everything else in this list is acted on.
|
||||
* Both renaming and removing are right-click, which is how everything else in
|
||||
* this list is acted on. A left-click on the label started renaming, so the row
|
||||
* could not be clicked — to select it, to drag from it — without falling into an
|
||||
* edit; and the × for removal sat a few pixels from that same target.
|
||||
*/
|
||||
function SeparatorRow({ T, separator, onRename, onContextMenu }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu }) {
|
||||
const [draft, setDraft] = useState(separator.label);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) setDraft(separator.label);
|
||||
}, [editing, separator.label]);
|
||||
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
onStartRename(null);
|
||||
if (draft !== separator.label) onRename(separator.id, draft);
|
||||
};
|
||||
|
||||
@@ -86,7 +89,7 @@ function SeparatorRow({ T, separator, onRename, onContextMenu }) {
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Escape") {
|
||||
setDraft(separator.label);
|
||||
setEditing(false);
|
||||
onStartRename(null);
|
||||
}
|
||||
}}
|
||||
aria-label="Separator label"
|
||||
@@ -109,12 +112,8 @@ function SeparatorRow({ T, separator, onRename, onContextMenu }) {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft(separator.label);
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Umbenennen"
|
||||
<div
|
||||
title="Right-click for options"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid transparent",
|
||||
@@ -125,15 +124,14 @@ function SeparatorRow({ T, separator, onRename, onContextMenu }) {
|
||||
letterSpacing: 0.6,
|
||||
textTransform: "uppercase",
|
||||
textAlign: "center",
|
||||
cursor: "text",
|
||||
padding: "1px 4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{separator.label || "Benennen"}
|
||||
</button>
|
||||
{separator.label || "Unnamed"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
|
||||
@@ -221,6 +219,100 @@ function SortMenu({ T, mode, modes, onChange, disabled }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One way into the notebook.
|
||||
*
|
||||
* Making a note, importing a PDF and importing Markdown are the same act —
|
||||
* 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.
|
||||
*/
|
||||
function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e) => {
|
||||
if (!box.current?.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", close);
|
||||
return () => document.removeEventListener("pointerdown", close);
|
||||
}, [open]);
|
||||
|
||||
const item = (label, icon, action) => (
|
||||
<button
|
||||
data-add-option={label}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
action();
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
cursor: "pointer",
|
||||
fontSize: 12.5,
|
||||
textAlign: "left",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: T.muted }}>{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }} ref={box}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="New Note"
|
||||
data-add-menu={open ? "on" : "off"}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
cursor: "pointer",
|
||||
color: "#fff",
|
||||
boxShadow: `0 2px 8px ${T.accent}44`,
|
||||
}}
|
||||
>
|
||||
{I.plus}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 38,
|
||||
right: 0,
|
||||
minWidth: 190,
|
||||
background: T.card,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 12,
|
||||
padding: 6,
|
||||
zIndex: 300,
|
||||
boxShadow: "0 10px 26px rgba(0,0,0,.28)",
|
||||
}}
|
||||
>
|
||||
{item("New note", I.plus, onCreateNote)}
|
||||
{item("Upload PDF", I.pdf, onImportPdf)}
|
||||
{item("Import Markdown", I.import, onImportMarkdown)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The middle column: every note in the selected folder, in the chosen order. */
|
||||
export function NoteList({
|
||||
T,
|
||||
@@ -234,9 +326,13 @@ export function NoteList({
|
||||
sortMode,
|
||||
sortModes,
|
||||
onSortModeChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
reorderable,
|
||||
separators,
|
||||
onAddSeparator,
|
||||
renamingSeparator,
|
||||
onStartRenameSeparator,
|
||||
onRenameSeparator,
|
||||
onSeparatorContextMenu,
|
||||
dnd,
|
||||
@@ -248,6 +344,10 @@ export function NoteList({
|
||||
onImportMarkdown,
|
||||
onEmptyTrash,
|
||||
}) {
|
||||
// Opened by the button and kept open while there is something typed in it, so
|
||||
// a search never disappears because the box collapsed under it.
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -307,39 +407,22 @@ export function NoteList({
|
||||
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={onImportMarkdown}
|
||||
title="Notizen importieren (Markdown oder ZIP)"
|
||||
data-import-button
|
||||
style={iconButton(T)}
|
||||
onClick={() => setSearching((v) => !v)}
|
||||
data-search-toggle={searching || query ? "on" : "off"}
|
||||
title="Search this notebook"
|
||||
aria-label="Search notes"
|
||||
style={{ ...iconButton(T), color: searching || query ? T.accent : T.muted }}
|
||||
>
|
||||
{I.import}
|
||||
{I.search}
|
||||
</button>
|
||||
)}
|
||||
{!isTrashView && (
|
||||
<button onClick={onImportPdf} title="Import PDF" style={iconButton(T)}>
|
||||
{I.pdf}
|
||||
</button>
|
||||
)}
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={onCreateNote}
|
||||
title="New Note"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
cursor: "pointer",
|
||||
color: "#fff",
|
||||
boxShadow: `0 2px 8px ${T.accent}44`,
|
||||
}}
|
||||
>
|
||||
{I.plus}
|
||||
</button>
|
||||
<AddMenu
|
||||
T={T}
|
||||
onCreateNote={onCreateNote}
|
||||
onImportPdf={onImportPdf}
|
||||
onImportMarkdown={onImportMarkdown}
|
||||
/>
|
||||
)}
|
||||
{isTrashView && trashedCount > 0 && (
|
||||
<button
|
||||
@@ -365,30 +448,52 @@ export function NoteList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Said once, where it matters: the arrangement is being edited, and the
|
||||
search box would otherwise silently take that ability away. */}
|
||||
{sortMode === "manual" && !isTrashView && (
|
||||
{/* The search box lives with the list it narrows, not in the sidebar.
|
||||
Searching has always been scoped to the notebook you are in; from the
|
||||
sidebar it read as searching everything, which it never did. */}
|
||||
{(searching || query) && !isTrashView && (
|
||||
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.borderLight}` }}>
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
onQueryChange("");
|
||||
setSearching(false);
|
||||
}
|
||||
}}
|
||||
placeholder={`Search in ${title}...`}
|
||||
aria-label="Search in this notebook"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "7px 10px",
|
||||
borderRadius: 9,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.inputBg,
|
||||
color: T.text,
|
||||
fontSize: 12.5,
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Just the one control. The sentence that used to sit here explained the
|
||||
order the sort menu already names, on every visit, forever. */}
|
||||
{reorderable && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "5px 10px 5px 16px",
|
||||
fontSize: 10.5,
|
||||
color: T.faint,
|
||||
justifyContent: "flex-end",
|
||||
padding: "5px 10px",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>
|
||||
{reorderable
|
||||
? "Eigene Reihenfolge — zum Umsortieren ziehen"
|
||||
: "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"}
|
||||
</span>
|
||||
{reorderable && (
|
||||
<button
|
||||
onClick={onAddSeparator}
|
||||
data-add-separator
|
||||
title="Trennlinie einfügen"
|
||||
title="Insert a separator line"
|
||||
style={{
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
@@ -400,9 +505,8 @@ export function NoteList({
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
+ Trenner
|
||||
+ Separator
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -477,6 +581,8 @@ export function NoteList({
|
||||
<SeparatorRow
|
||||
T={T}
|
||||
separator={entry.separator}
|
||||
editing={renamingSeparator === entry.id}
|
||||
onStartRename={onStartRenameSeparator}
|
||||
onRename={onRenameSeparator}
|
||||
onContextMenu={onSeparatorContextMenu}
|
||||
/>
|
||||
|
||||
@@ -17,11 +17,11 @@ import type { NoteMeta, Separator, SortMode } from "../../types";
|
||||
export const DEFAULT_SORT: SortMode = "updated";
|
||||
|
||||
export const SORT_LABELS: Record<SortMode, string> = {
|
||||
manual: "Eigene Reihenfolge",
|
||||
updated: "Zuletzt geändert",
|
||||
oldest: "Am ältesten",
|
||||
created: "Zuletzt erstellt",
|
||||
title: "Titel A–Z",
|
||||
manual: "My own order",
|
||||
updated: "Last changed",
|
||||
oldest: "Oldest first",
|
||||
created: "Newest created",
|
||||
title: "Title A–Z",
|
||||
};
|
||||
|
||||
export const SORT_MODES = Object.keys(SORT_LABELS) as SortMode[];
|
||||
|
||||
@@ -107,7 +107,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
|
||||
}
|
||||
}}
|
||||
aria-label="Pinned note"
|
||||
placeholder="Notiz zu dieser Stelle…"
|
||||
placeholder="Note on this passage…"
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -136,7 +136,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Entfernen
|
||||
Remove
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button
|
||||
@@ -155,7 +155,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
|
||||
padding: "3px 10px",
|
||||
}}
|
||||
>
|
||||
Fertig
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -173,7 +173,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{note.text || "Leer — zum Schreiben antippen"}
|
||||
{note.text || "Empty — tap to write"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
<span style={{ fontSize: 11.5, color: T.muted, flex: 1 }}>Notiz zu dieser Stelle</span>
|
||||
<span style={{ fontSize: 11.5, color: T.muted, flex: 1 }}>Note on this passage</span>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
data-pin-remove
|
||||
@@ -81,7 +81,7 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Entfernen
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,7 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
aria-label="Pinned note"
|
||||
placeholder="Notiz zu dieser Stelle…"
|
||||
placeholder="Note on this passage…"
|
||||
rows={3}
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -124,7 +124,7 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
|
||||
padding: "9px 0",
|
||||
}}
|
||||
>
|
||||
Fertig
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -84,7 +84,7 @@ export function PdfNotesPanel({
|
||||
<button
|
||||
data-pdf-back-to-document
|
||||
onClick={onBackToDocument}
|
||||
title="Zurück zum Dokument"
|
||||
title="Back to the document"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -99,11 +99,11 @@ export function PdfNotesPanel({
|
||||
padding: "3px 8px",
|
||||
}}
|
||||
>
|
||||
{I.back} Dokument
|
||||
{I.back} Document
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}>
|
||||
{I.text} Notizen
|
||||
{I.text} Notes
|
||||
</span>
|
||||
)}
|
||||
{editor && <EditorToolbar editor={editor} T={T} icons={I} compact dark={dark} />}
|
||||
|
||||
@@ -376,7 +376,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
/* ── load ── */
|
||||
useEffect(() => {
|
||||
if (!note?.pdfAttachmentId) {
|
||||
setState({ status: "error", message: "Zu dieser Notiz gehört keine PDF-Datei." });
|
||||
setState({ status: "error", message: "This note has no PDF attached." });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -404,8 +404,8 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
status: "error",
|
||||
message:
|
||||
err?.message === "AUTH"
|
||||
? "Sitzung abgelaufen — bitte neu anmelden."
|
||||
: "Die Datei konnte nicht geöffnet werden. Offline und noch nie geladen?",
|
||||
? "Session expired — please sign in again."
|
||||
: "The file could not be opened. Offline and never loaded?",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -575,18 +575,18 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<button onClick={() => goToPage(Math.max(1, pageNo - 1))} title="Vorherige Seite" style={barButton}>
|
||||
<button onClick={() => goToPage(Math.max(1, pageNo - 1))} title="Previous page" style={barButton}>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => goToPage(Math.min(state.pageCount || 1, pageNo + 1))}
|
||||
title="Nächste Seite"
|
||||
title="Next page"
|
||||
style={barButton}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<span data-pdf-position style={{ minWidth: 74 }}>
|
||||
{state.status === "ready" ? `Seite ${pageNo} / ${state.pageCount}` : "Lädt…"}
|
||||
{state.status === "ready" ? `Page ${pageNo} of ${state.pageCount}` : "Loading…"}
|
||||
</span>
|
||||
<div style={{ width: 1, height: 18, background: T.border, margin: "0 2px" }} />
|
||||
|
||||
@@ -598,7 +598,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
setDrawing((v) => !v);
|
||||
setPlacing(false);
|
||||
}}
|
||||
title={drawing ? "Zeichnen beenden" : "Zeichnen"}
|
||||
title={drawing ? "Stop drawing" : "Draw"}
|
||||
style={{
|
||||
...barButton,
|
||||
width: "auto",
|
||||
@@ -609,7 +609,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{I.pen} {drawing ? "Zeichnen" : "Lesen"}
|
||||
{I.pen} {drawing ? "Drawing" : "Reading"}
|
||||
</button>
|
||||
|
||||
{drawing && (
|
||||
@@ -619,7 +619,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
key={t}
|
||||
data-pdf-tool={t}
|
||||
onClick={() => setTool(t)}
|
||||
title={t === "pen" ? "Stift" : t === "highlighter" ? "Marker" : "Radierer"}
|
||||
title={t === "pen" ? "Pen" : t === "highlighter" ? "Highlighter" : "Eraser"}
|
||||
style={{ ...barButton, background: tool === t ? T.accent + "22" : "transparent" }}
|
||||
>
|
||||
{t === "pen" ? I.pen : t === "highlighter" ? I.highlight : I.eraser}
|
||||
@@ -629,7 +629,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setShowPalette((v) => !v)}
|
||||
title="Farbe"
|
||||
title="Colour"
|
||||
style={{ ...barButton, width: 22, height: 22, background: color, borderRadius: "50%" }}
|
||||
/>
|
||||
{showPalette && (
|
||||
@@ -673,7 +673,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
<select
|
||||
value={sizeIdx}
|
||||
onChange={(e) => setSizeIdx(Number(e.target.value))}
|
||||
title="Strichstärke"
|
||||
title="Stroke width"
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: T.muted,
|
||||
@@ -690,10 +690,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button onClick={undo} title="Rückgängig" style={barButton}>
|
||||
<button onClick={undo} title="Undo" style={barButton}>
|
||||
{I.undo}
|
||||
</button>
|
||||
<button onClick={() => clearPage(pageNo)} title="Seite leeren" style={barButton}>
|
||||
<button onClick={() => clearPage(pageNo)} title="Clear page" style={barButton}>
|
||||
{I.trash}
|
||||
</button>
|
||||
</>
|
||||
@@ -707,7 +707,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
setDrawing(false);
|
||||
setOpenPin(null);
|
||||
}}
|
||||
title={placing ? "Stelle anklicken — oder abbrechen" : "Notiz an eine Stelle heften"}
|
||||
title={placing ? "Click the passage — or cancel" : "Pin a note to a passage"}
|
||||
style={{
|
||||
...barButton,
|
||||
width: "auto",
|
||||
@@ -718,17 +718,17 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{I.pin} {placing ? "Stelle wählen" : "Randnotiz"}
|
||||
{I.pin} {placing ? "Pick a spot" : "Margin note"}
|
||||
</button>
|
||||
|
||||
{notesToggle}
|
||||
|
||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<button onClick={() => stepZoom(-1)} title="Kleiner" style={barButton}>
|
||||
<button onClick={() => stepZoom(-1)} title="Zoom out" style={barButton}>
|
||||
−
|
||||
</button>
|
||||
<span style={{ minWidth: 42, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
|
||||
<button onClick={() => stepZoom(1)} title="Größer" style={barButton}>
|
||||
<button onClick={() => stepZoom(1)} title="Zoom in" style={barButton}>
|
||||
+
|
||||
</button>
|
||||
|
||||
@@ -745,10 +745,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
<button
|
||||
data-pdf-hide-ink={hideInk ? "on" : "off"}
|
||||
onClick={() => setHideInk((v) => !v)}
|
||||
title={hideInk ? "Zeichnung einblenden" : "Zeichnung ausblenden"}
|
||||
title={hideInk ? "Show drawing" : "Hide drawing"}
|
||||
style={visibilityButton(T, !hideInk)}
|
||||
>
|
||||
{hideInk ? I.eyeOff : I.eye} Zeichnung
|
||||
{hideInk ? I.eyeOff : I.eye} Drawing
|
||||
</button>
|
||||
)}
|
||||
{totalPinnedNotes(ink) > 0 && (
|
||||
@@ -758,10 +758,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
setShowPins((v) => !v);
|
||||
setOpenPin(null);
|
||||
}}
|
||||
title={showPins ? "Randnotizen ausblenden" : "Randnotizen einblenden"}
|
||||
title={showPins ? "Hide margin notes" : "Show margin notes"}
|
||||
style={visibilityButton(T, showPins)}
|
||||
>
|
||||
{showPins ? I.eye : I.eyeOff} Randnotizen
|
||||
{showPins ? I.eye : I.eyeOff} Margin notes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -772,7 +772,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
||||
<div
|
||||
style={{ color: T.muted, fontSize: 13, fontFamily: FONT_UI, textAlign: "center", padding: 40 }}
|
||||
>
|
||||
PDF wird geladen…
|
||||
Loading PDF…
|
||||
</div>
|
||||
)}
|
||||
{state.status === "ready" && (
|
||||
|
||||
@@ -52,7 +52,7 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
|
||||
|
||||
const pinLabel = useCallback((pin) => {
|
||||
const text = (pin.text || "").trim().replace(/\s+/g, " ");
|
||||
return text ? `S. ${pin.page}: ${text.slice(0, 40)}` : `S. ${pin.page}: Randnotiz`;
|
||||
return text ? `p. ${pin.page}: ${text.slice(0, 40)}` : `p. ${pin.page}: margin note`;
|
||||
}, []);
|
||||
|
||||
const searchTargets = useCallback(
|
||||
@@ -94,7 +94,7 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
|
||||
<button
|
||||
data-pdf-toggle-notes={showNotes ? "on" : "off"}
|
||||
onClick={() => setShowNotes((v) => !v)}
|
||||
title={showNotes ? "Notizen ausblenden" : "Notizen einblenden"}
|
||||
title={showNotes ? "Hide notes" : "Show notes"}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -111,7 +111,7 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{I.text} Notizen
|
||||
{I.text} Notes
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
@@ -90,22 +90,22 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
await exportPdfNote(notes[0], kind === "pdf-stamped");
|
||||
setDone(
|
||||
kind === "pdf-stamped"
|
||||
? "PDF mit deinen Anmerkungen wurde heruntergeladen."
|
||||
: "Das unveränderte Original wurde heruntergeladen."
|
||||
? "The PDF with your annotations was downloaded."
|
||||
: "The untouched original was downloaded."
|
||||
);
|
||||
} else if (kind === "png") {
|
||||
await exportDrawingAsPng(notes[0]);
|
||||
setDone("Zeichnung wurde als PNG heruntergeladen.");
|
||||
setDone("The drawing was downloaded as a PNG.");
|
||||
} else if (kind === "markdown") {
|
||||
if (single) await exportNoteAsMarkdown(notes[0]);
|
||||
else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress });
|
||||
setDone(single ? "Datei wurde heruntergeladen." : "Archiv wurde heruntergeladen.");
|
||||
setDone(single ? "The file was downloaded." : "The archive was downloaded.");
|
||||
} else {
|
||||
await exportNotesAsPdf(notes, label, setProgress, parts || "both");
|
||||
setDone("Druckansicht war geöffnet — wähle dort „Als PDF sichern“.");
|
||||
setDone("The print view was opened — choose “Save as PDF” there.");
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e?.message || "Export fehlgeschlagen");
|
||||
setErr(e?.message || "Export failed");
|
||||
}
|
||||
setBusy(null);
|
||||
setProgress(null);
|
||||
@@ -145,22 +145,22 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
/* ── a PDF note: the document, or the notes written beside it ── */
|
||||
const pdfOptions = () => (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>Was soll heraus?</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>What should come out?</div>
|
||||
{card(
|
||||
() => run("pdf-stamped"),
|
||||
busy === "pdf-stamped",
|
||||
"PDF mit deinen Anmerkungen",
|
||||
"PDF with your annotations",
|
||||
[
|
||||
"Striche und Randnotizen werden fest ins Dokument gerechnet.",
|
||||
hasInk === false ? "Auf diesem Dokument ist noch nichts angemerkt." : null,
|
||||
"Strokes and margin notes are written into the document.",
|
||||
hasInk === false ? "Nothing has been marked on this document yet." : null,
|
||||
],
|
||||
hasInk === false
|
||||
)}
|
||||
{card(() => run("pdf-original"), busy === "pdf-original", "PDF im Original", [
|
||||
"Genau die Datei, die du importiert hast — ohne deine Anmerkungen.",
|
||||
{card(() => run("pdf-original"), busy === "pdf-original", "The original PDF", [
|
||||
"Exactly the file you imported — without your annotations.",
|
||||
])}
|
||||
{card(() => run("markdown"), busy === "markdown", "Nur die Notizen daneben (Markdown)", [
|
||||
"Was du neben dem Dokument geschrieben hast, ohne das PDF selbst.",
|
||||
{card(() => run("markdown"), busy === "markdown", "Only the notes beside it (Markdown)", [
|
||||
"What you wrote next to the document, without the PDF itself.",
|
||||
])}
|
||||
</>
|
||||
);
|
||||
@@ -168,15 +168,15 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
/* ── step one: what should come along ── */
|
||||
const stepParts = () => (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>Was soll mit?</div>
|
||||
{card(() => setParts("both"), parts === "both", "Notiz mit Zeichnung", [
|
||||
"Text und Zeichnung in einem Dokument — nur als PDF möglich.",
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>What should come along?</div>
|
||||
{card(() => setParts("both"), parts === "both", "Note with the drawing", [
|
||||
"Text and drawing in one document — only possible as a PDF.",
|
||||
])}
|
||||
{card(() => setParts("text"), parts === "text", "Nur den Notiztext", [
|
||||
"Ohne die Zeichnung. Als PDF oder als Markdown.",
|
||||
{card(() => setParts("text"), parts === "text", "Only the note text", [
|
||||
"Without the drawing. As a PDF or as Markdown.",
|
||||
])}
|
||||
{card(() => setParts("drawing"), parts === "drawing", "Nur die Zeichnung", [
|
||||
"Als PNG-Bild oder als PDF-Seite.",
|
||||
{card(() => setParts("drawing"), parts === "drawing", "Only the drawing", [
|
||||
"As a PNG image or as a PDF page.",
|
||||
])}
|
||||
</>
|
||||
);
|
||||
@@ -189,27 +189,27 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
card(
|
||||
() => run("markdown"),
|
||||
busy === "markdown",
|
||||
single ? "Markdown (.md)" : "Markdown-Archiv (ZIP)",
|
||||
single ? "Markdown (.md)" : "Markdown archive (ZIP)",
|
||||
[
|
||||
single
|
||||
? "Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken kommen mit."
|
||||
: "Je Notiz eine .md-Datei, dazu Bilder und ein Inhaltsverzeichnis.",
|
||||
parts === "both" ? "Die Zeichnung liegt als PNG daneben — Markdown kann sie nicht halten." : null,
|
||||
? "Stays editable elsewhere. Tables and task boxes come along."
|
||||
: "One .md file per note, plus the images and a table of contents.",
|
||||
parts === "both" ? "The drawing sits beside it as a PNG — Markdown cannot hold one." : null,
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
if (parts === "drawing") {
|
||||
options.push(
|
||||
card(() => run("png"), busy === "png", "PNG-Bild (.png)", [
|
||||
"Die Zeichnung in voller Auflösung, auf weißem Grund.",
|
||||
card(() => run("png"), busy === "png", "PNG image (.png)", [
|
||||
"The drawing at full resolution, on white paper.",
|
||||
])
|
||||
);
|
||||
}
|
||||
options.push(
|
||||
card(() => run("pdf"), busy === "pdf", "PDF", [
|
||||
"Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.",
|
||||
parts === "both" ? "Die Zeichnung steht mit im Dokument." : null,
|
||||
"Opens the print dialog — choose “Save as PDF” there.",
|
||||
parts === "both" ? "The drawing is in the document too." : null,
|
||||
])
|
||||
);
|
||||
return (
|
||||
@@ -232,10 +232,10 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{I.back} Zurück
|
||||
{I.back} Back
|
||||
</button>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>In welcher Form?</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>In what form?</div>
|
||||
{options}
|
||||
</>
|
||||
);
|
||||
@@ -368,7 +368,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
)}
|
||||
|
||||
<div style={{ padding: "8px 22px 18px", fontSize: 11, color: T.faint, lineHeight: 1.5 }}>
|
||||
Alles läuft im Browser — es geht nichts an einen fremden Dienst.
|
||||
Everything happens in the browser — nothing goes to an outside service.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
setResult(r);
|
||||
onDone?.(r);
|
||||
})
|
||||
.catch((e) => setError(e?.message || "Der Import ist fehlgeschlagen."));
|
||||
.catch((e) => setError(e?.message || "The import failed."));
|
||||
}, [files, folderId, onDone]);
|
||||
|
||||
const done = result || error;
|
||||
@@ -85,7 +85,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
fontFamily: "'Newsreader',Georgia,serif",
|
||||
}}
|
||||
>
|
||||
{done ? "Import abgeschlossen" : "Notizen werden importiert…"}
|
||||
{done ? "Import finished" : "Importing notes…"}
|
||||
</div>
|
||||
|
||||
{!done && (
|
||||
@@ -101,7 +101,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginTop: 8 }}>
|
||||
{progress.total ? `${progress.done} von ${progress.total}` : "Archiv wird gelesen…"}
|
||||
{progress.total ? `${progress.done} of ${progress.total}` : "Reading the archive…"}
|
||||
{progress.label ? ` — ${progress.label}` : ""}
|
||||
</div>
|
||||
</>
|
||||
@@ -113,29 +113,29 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
|
||||
<div>
|
||||
<strong>{result.notes.length}</strong>{" "}
|
||||
{result.notes.length === 1 ? "Notiz importiert" : "Notizen importiert"}
|
||||
{result.notes.length === 1 ? "note imported" : "notes imported"}
|
||||
</div>
|
||||
|
||||
{result.flattenedDrawings > 0 && (
|
||||
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
|
||||
{result.flattenedDrawings === 1
|
||||
? "Eine Zeichnung kam als Bild zurück"
|
||||
: `${result.flattenedDrawings} Zeichnungen kamen als Bilder zurück`}{" "}
|
||||
— im Archiv liegen sie als PNG, und aus Pixeln lassen sich die Striche nicht zurückrechnen.
|
||||
Sie sind sichtbar und exportierbar, aber nicht mehr mit dem Stift zu bearbeiten.
|
||||
? "One drawing came back as a picture"
|
||||
: `${result.flattenedDrawings} drawings came back as pictures`}{" "}
|
||||
— the archive holds them as PNGs, and strokes cannot be recovered from pixels. They are
|
||||
visible and exportable, but the pen can no longer edit them.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.missingImages > 0 && (
|
||||
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
|
||||
{result.missingImages} {result.missingImages === 1 ? "Bild lag" : "Bilder lagen"} nicht im
|
||||
Archiv. Die Stelle in der Notiz bleibt erhalten, das Bild fehlt.
|
||||
{result.missingImages} {result.missingImages === 1 ? "image was" : "images were"} not in the
|
||||
archive. Its place in the note is kept; the picture is missing.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.failed.length > 0 && (
|
||||
<div style={{ color: T.danger, marginTop: 10, fontSize: 12.5 }}>
|
||||
Nicht gelesen: {result.failed.join(", ")}
|
||||
Could not be read: {result.failed.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -156,7 +156,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{done ? "Fertig" : "Läuft…"}
|
||||
{done ? "Done" : "Working…"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,14 +52,14 @@ export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark
|
||||
e?.preventDefault();
|
||||
const value = url.trim();
|
||||
if (!value) {
|
||||
setError("Bitte eine Adresse eingeben — oder den Link entfernen.");
|
||||
setError("Enter an address — or remove the link.");
|
||||
return;
|
||||
}
|
||||
// A bare "example.com" is what people type; assume https rather than
|
||||
// refusing something whose meaning is perfectly clear.
|
||||
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`;
|
||||
if (!ALLOWED.test(candidate)) {
|
||||
setError("Nur http-, https- und mailto-Adressen sind erlaubt.");
|
||||
setError("Only http, https and mailto addresses are allowed.");
|
||||
return;
|
||||
}
|
||||
onSubmit(candidate);
|
||||
@@ -113,7 +113,7 @@ export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark
|
||||
fontFamily: "'Newsreader',Georgia,serif",
|
||||
}}
|
||||
>
|
||||
{initialUrl ? "Link bearbeiten" : "Link einfügen"}
|
||||
{initialUrl ? "Edit link" : "Insert link"}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -140,20 +140,20 @@ export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark
|
||||
}}
|
||||
/>
|
||||
<div style={{ minHeight: 18, marginTop: 6, fontSize: 11.5, color: error ? T.danger : T.muted }}>
|
||||
{error || "http, https oder mailto"}
|
||||
{error || "http, https or mailto"}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 12 }}>
|
||||
{initialUrl && (
|
||||
<button type="button" onClick={onRemove} style={{ ...button("danger"), marginRight: "auto" }}>
|
||||
Link entfernen
|
||||
Remove link
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onClose} style={button()}>
|
||||
Abbrechen
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" style={button("primary")}>
|
||||
{initialUrl ? "Ändern" : "Einfügen"}
|
||||
{initialUrl ? "Change" : "Insert"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -108,8 +108,8 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
|
||||
Ältere Fassungen werden erst gelöscht, wenn neue sie verdrängen — nicht nach Zeit. Leer lassen für
|
||||
die Vorgabe des Servers.
|
||||
Older versions are only deleted once newer ones push them out — never by age. Leave empty for the
|
||||
server's default.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
|
||||
@@ -32,7 +32,7 @@ const recalledNotebook = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPick, onClose, dark }) {
|
||||
export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", onPick, onClose, dark }) {
|
||||
const remembered = recalledNotebook();
|
||||
const [selected, setSelected] = useState(
|
||||
() => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null
|
||||
@@ -103,7 +103,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPi
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 14 }}>In welches Notizbuch soll es?</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 14 }}>Which notebook should it go in?</div>
|
||||
|
||||
<div
|
||||
role="listbox"
|
||||
@@ -147,9 +147,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPi
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{nb.name}
|
||||
</span>
|
||||
{nb.id === remembered && (
|
||||
<span style={{ fontSize: 10, color: T.muted }}>zuletzt benutzt</span>
|
||||
)}
|
||||
{nb.id === remembered && <span style={{ fontSize: 10, color: T.muted }}>last used</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -168,7 +166,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPi
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirm}
|
||||
|
||||
@@ -67,8 +67,6 @@ export function Sidebar({
|
||||
isMobile,
|
||||
open,
|
||||
onClose,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
activeFolder,
|
||||
onSelectFolder,
|
||||
notebooks,
|
||||
@@ -157,36 +155,9 @@ export function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0 14px 12px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
background: T.inputBg,
|
||||
border: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: T.faint }}>{I.search}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search notes..."
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
outline: "none",
|
||||
flex: 1,
|
||||
color: T.text,
|
||||
fontSize: 13,
|
||||
fontFamily: FONT_UI,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* The search box moved into the note list. From up here it read as
|
||||
searching everything, while it has always narrowed only the notebook
|
||||
you are in — the box now sits with the list it actually filters. */}
|
||||
|
||||
<div style={{ padding: "0 10px" }}>
|
||||
<button
|
||||
|
||||
@@ -368,6 +368,24 @@ state of their own, so they can be read on their own.
|
||||
|
||||
---
|
||||
|
||||
## Interface language
|
||||
|
||||
English throughout. It had drifted into two languages — the frame in English,
|
||||
everything added later in German — which is worse than either: the same idea
|
||||
appeared under two names depending on when that part was written.
|
||||
|
||||
The one place German is deliberately kept is sorting by title, which compares
|
||||
with the German collation so that *Übung 2* comes before *Übung 10* and umlauts
|
||||
sort where a German reader expects. That is about the notes, not the chrome.
|
||||
|
||||
Two archive folders were renamed with the interface (`bilder/` → `images/`,
|
||||
`zeichnungen/` → `drawings/`). **The importer still reads both**, along with the
|
||||
old German marker line for a drawing — archives exported before the change are
|
||||
on people's disks, and an import that quietly dropped their drawings would be the
|
||||
worst kind of tidy-up.
|
||||
|
||||
---
|
||||
|
||||
## Note Content
|
||||
|
||||
The body of a note is a **TipTap/ProseMirror document** stored as JSON:
|
||||
@@ -739,10 +757,11 @@ questions asked of every notebook at once, so there is nothing for an
|
||||
arrangement to belong to and nowhere to keep it.
|
||||
|
||||
**Separators** are lines drawn between notes, optionally named, stored on the
|
||||
notebook beside the sort mode. One is removed by right-clicking it and
|
||||
confirming — a × on the row would sit a few pixels from the label you click to
|
||||
rename, putting the gesture that tidies the list next to the one that destroys
|
||||
part of it. They move by dragging like a note does — a
|
||||
notebook beside the sort mode. Renaming and removing are both right-click, which
|
||||
is how everything else in that list is acted on: a left-click on the label used
|
||||
to start renaming, so the row could not be clicked at all without falling into an
|
||||
edit, and the × for removal sat a few pixels from that same target. Removal asks
|
||||
first, and says the notes are not affected. They move by dragging like a note does — a
|
||||
divider that cannot move with what it divides is worse than none — and their
|
||||
position is recorded as "how many notes come before me", so they stay put as the
|
||||
notes around them are renamed, edited or opened. They are shown only in the
|
||||
|
||||
Reference in new issue
Block a user