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