diff --git a/e2e/organise.mjs b/e2e/organise.mjs
index 4a2a3a2..78458db 100644
--- a/e2e/organise.mjs
+++ b/e2e/organise.mjs
@@ -27,8 +27,8 @@ try {
const p = session.page;
const titles = () =>
- p.locator("[data-note-row]").evaluateAll((rows) =>
- rows.map((r) => r.querySelector("span:nth-child(2)")?.textContent?.trim() || "")
+ p.locator("[data-note-row] [data-note-title]").evaluateAll((els) =>
+ els.map((e) => e.textContent.trim())
);
const sortBy = async (mode) => {
await p.locator("[data-sort-button]").click();
diff --git a/e2e/run.sh b/e2e/run.sh
index dff0848..fbf9dcd 100755
--- a/e2e/run.sh
+++ b/e2e/run.sh
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
URL="${INKNOTE_URL:-http://localhost:3000}"
SUITES=("$@")
-[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline)
+[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline sync)
mkdir -p shots
if [ ! -d node_modules ]; then
diff --git a/e2e/search-attachments.mjs b/e2e/search-attachments.mjs
index 70b088d..c087cd7 100644
--- a/e2e/search-attachments.mjs
+++ b/e2e/search-attachments.mjs
@@ -20,11 +20,11 @@ const PNG =
/* Rows in the note list carry data-note-row; matching on the emoji instead would
* also pick up the sidebar's Trash entry, which wears the same one. */
+/* By name, not by position. Reading the second line of the row's text worked
+ only while the icon was a text emoji; the moment it became an SVG the second
+ line was the excerpt. */
const titles = (p) =>
- p.$$eval("[data-note-row]", (els) =>
- // The button holds emoji, title and date on separate lines.
- els.map((e) => e.innerText.split("\n").filter(Boolean)[1]).filter(Boolean)
- );
+ p.$$eval("[data-note-row] [data-note-title]", (els) => els.map((e) => e.textContent.trim()));
const browser = await launch();
let session;
diff --git a/e2e/sync.mjs b/e2e/sync.mjs
new file mode 100644
index 0000000..e317aa7
--- /dev/null
+++ b/e2e/sync.mjs
@@ -0,0 +1,99 @@
+/* One account, two devices.
+ *
+ * Everything here is written on one machine and read on another, and the parts
+ * travel differently: a note's text is an encrypted column, a drawing is a
+ * second one, and a PDF is a file the note only points at. That last is the one
+ * worth proving — the bytes are uploaded once and fetched by id, so a device
+ * that has never seen the document has to be able to ask the server for it.
+ *
+ * A second browser context is a second device: its own storage, its own cache,
+ * its own session.
+ */
+import { newPage, signIn, check, createNotebook, launch, report, withFreshUser } from "./lib.mjs";
+import { writeFileSync } from "node:fs";
+
+const PDF = "/tmp/sync-test.pdf";
+const s1 = "BT /F1 22 Tf 60 700 Td (Handbuch Seite eins) Tj ET";
+const objs = [
+ "<< /Type /Catalog /Pages 2 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
+ `<< /Length ${s1.length} >>\nstream\n${s1}\nendstream`,
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+];
+let out = "%PDF-1.4\n"; const offs = [];
+objs.forEach((o, i) => { offs.push(out.length); out += `${i + 1} 0 obj\n${o}\nendobj\n`; });
+const xref = out.length;
+out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
+for (const o of offs) out += `${String(o).padStart(10, "0")} 00000 n \n`;
+out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
+writeFileSync(PDF, out, "latin1");
+
+const browser = await launch();
+let s;
+try {
+ s = await withFreshUser(browser, "sync");
+ const pc = s.page;
+ await createNotebook(pc, "Sync");
+ await pc.locator("button").filter({ hasText: "Sync" }).first().click();
+ await pc.waitForTimeout(700);
+
+ const chooser = pc.waitForEvent("filechooser");
+ await pc.locator("[data-add-menu]").click();
+ await pc.waitForTimeout(400);
+ await pc.locator('[data-add-option="Upload PDF"]').click();
+ await (await chooser).setFiles(PDF);
+ await pc.waitForTimeout(5000);
+ check((await pc.locator("[data-pdf-page]").count()) > 0, "device A imported the PDF");
+
+ // draw a stroke and pin a note
+ await pc.locator("[data-pdf-mode]").click();
+ await pc.waitForTimeout(500);
+ const box = await pc.locator('[data-pdf-page="1"]').boundingBox();
+ await pc.mouse.move(box.x + 80, box.y + 200);
+ await pc.mouse.down();
+ await pc.mouse.move(box.x + 260, box.y + 240, { steps: 10 });
+ await pc.mouse.up();
+ await pc.waitForTimeout(2000);
+ await pc.locator("[data-pdf-mode]").click();
+ await pc.waitForTimeout(500);
+
+ await pc.locator("[data-pdf-pin-mode]").click();
+ await pc.waitForTimeout(400);
+ await pc.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.3);
+ await pc.waitForTimeout(800);
+ await pc.getByLabel("Pinned note").fill("Auf dem PC geschrieben");
+ await pc.getByRole("button", { name: "Done" }).click();
+ await pc.waitForTimeout(3000);
+
+ /* ── a second device: its own browser context, its own storage ── */
+ const phone = await newPage(browser, 1400, 950);
+ await signIn(phone, s.user, s.pass);
+ await phone.locator("button").filter({ hasText: "Sync" }).first().click();
+ await phone.waitForTimeout(1500);
+ await phone.locator("[data-note-row]").first().click();
+ await phone.waitForTimeout(6000);
+
+ check((await phone.locator("[data-pdf-page]").count()) > 0, "and the second device gets the document itself");
+ const inked = await phone.locator('[data-pdf-ink="1"]').evaluate((c) => {
+ const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
+ let n = 0;
+ for (let i = 3; i < d.length; i += 4) if (d[i] > 40) n++;
+ return n;
+ });
+ check(inked > 100, "the drawing came with it", `${inked} inked pixels`);
+ check((await phone.locator("[data-pin]").count()) === 1, "and the pinned note");
+ check(
+ (await phone.locator("[data-pin-card]").innerText()).includes("Auf dem PC geschrieben"),
+ "with its text",
+ await phone.locator("[data-pin-card]").innerText()
+ );
+ await phone.context().close();
+} catch (e) {
+ check(false, "suite ran to the end", e.message);
+} finally {
+ await s?.cleanup();
+ const code = report();
+ await browser.close();
+ process.exit(code);
+}
diff --git a/frontend/src/editor/EditorToolbar.jsx b/frontend/src/editor/EditorToolbar.jsx
index 9839186..b220289 100644
--- a/frontend/src/editor/EditorToolbar.jsx
+++ b/frontend/src/editor/EditorToolbar.jsx
@@ -56,6 +56,7 @@ const AtIcon = () => (
export function EditorToolbar({ editor, T, icons, compact, dark }) {
const [, force] = useState(0);
const [showTable, setShowTable] = useState(false);
+ const [showMore, setShowMore] = useState(false);
const [linkDialog, setLinkDialog] = useState(null);
// ProseMirror state changes do not go through React; subscribe so the active
@@ -80,6 +81,15 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
return () => document.removeEventListener("pointerdown", close);
}, [showTable]);
+ useEffect(() => {
+ if (!showMore) return;
+ const close = (e) => {
+ if (!e.target.closest?.("[data-more-menu]")) setShowMore(false);
+ };
+ document.addEventListener("pointerdown", close);
+ return () => document.removeEventListener("pointerdown", close);
+ }, [showMore]);
+
if (!editor) return null;
const btn = (active) => ({
@@ -274,7 +284,91 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
/>
)}
- {!compact && (
+ {/* On a phone the rest goes behind one button.
+ *
+ * There were fourteen controls in a row that scrolled sideways, so the
+ * ones at the end could only be reached by dragging the bar — and nothing
+ * said they were there. A menu is one tap and shows what it holds. */}
+ {compact ? (
+
+ ) : (
<>
{divider}
+
+ {divider}
+
+
+ {divider}
+
+
>
)}
-
- {divider}
-
-
- {divider}
-
-
>
);
}
-
-export default EditorToolbar;
diff --git a/frontend/src/editor/editor.css b/frontend/src/editor/editor.css
index 57965f9..6a9a917 100644
--- a/frontend/src/editor/editor.css
+++ b/frontend/src/editor/editor.css
@@ -8,10 +8,10 @@
--ne-text: #1f2328;
--ne-muted: #656d76;
--ne-faint: #afb8c1;
- --ne-border: #ded8cd;
+ --ne-border: #d0d7de;
--ne-accent: #0969da;
- --ne-surface: #fbfaf7;
- --ne-subtle: #f2efe8;
+ --ne-surface: #ffffff;
+ --ne-subtle: #f6f8fa;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
@@ -19,28 +19,27 @@
--ne-faint: #484f58;
--ne-border: #30363d;
--ne-accent: #388bfd;
- --ne-surface: #161c24;
- --ne-subtle: #1d242e;
+ --ne-surface: #0d1117;
+ --ne-subtle: #161b22;
}
.note-editor .ProseMirror {
+ /* About 73 characters to the line at this size — measured, not guessed. The
+ text used to run the full width of the pane, past 84, which is beyond the
+ point where the eye reliably finds the start of the next line. Capped here
+ rather than on the container so it holds on a page as well as off one. */
+ max-width: 527px;
+ margin-left: auto;
+ margin-right: auto;
outline: none;
color: var(--ne-text);
font-family: "Newsreader", Georgia, serif;
- font-size: 16px;
- line-height: 1.75;
- /* A margin, the way a page has one. The old 24px let the text run to the very
- edge of the sheet, which is the one thing paper never does. */
- padding: 44px 56px 64px;
+ font-size: 15px;
+ line-height: 1.7;
+ padding: 20px 24px;
min-height: 100%;
word-break: break-word;
}
-
-@media (max-width: 767px) {
- .note-editor .ProseMirror {
- padding: 22px 20px 40px;
- }
-}
.note-editor .ProseMirror > * + * {
margin-top: 0.6em;
}
diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx
index 9ad87bf..14f55b8 100644
--- a/frontend/src/features/notes/EditorPane.jsx
+++ b/frontend/src/features/notes/EditorPane.jsx
@@ -214,10 +214,25 @@ export function EditorPane({
-