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 ? ( +
+ + {showMore && ( +
+ {[ + [ + "Body text", + () => editor.chain().focus().setParagraph().run(), + editor.isActive("paragraph"), + ], + [ + "Heading 1", + () => editor.chain().focus().toggleHeading({ level: 1 }).run(), + editor.isActive("heading", { level: 1 }), + ], + [ + "Heading 2", + () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + editor.isActive("heading", { level: 2 }), + ], + [ + "Heading 3", + () => editor.chain().focus().toggleHeading({ level: 3 }).run(), + editor.isActive("heading", { level: 3 }), + ], + [null], + [ + "Quote", + () => editor.chain().focus().toggleBlockquote().run(), + editor.isActive("blockquote"), + ], + [ + "Code block", + () => editor.chain().focus().toggleCodeBlock().run(), + editor.isActive("codeBlock"), + ], + [null], + ["Undo", () => editor.chain().focus().undo().run(), false], + ["Redo", () => editor.chain().focus().redo().run(), false], + ].map(([label, action, active], i) => + label === null ? ( +
+ ) : ( + + ) + )} +
+ )} +
+ ) : ( <> {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({ - -
diff --git a/frontend/src/features/notes/NoteEditor.jsx b/frontend/src/features/notes/NoteEditor.jsx index 4647696..2ccec85 100644 --- a/frontend/src/features/notes/NoteEditor.jsx +++ b/frontend/src/features/notes/NoteEditor.jsx @@ -20,6 +20,7 @@ import { sizeCanvas, } from "../drawing/strokes.js"; import { COLORS } from "../../ui/constants.jsx"; + import { I } from "../../ui/icons.jsx"; import { BacklinksPanel } from "../../ui/BacklinksPanel.jsx"; @@ -67,6 +68,15 @@ export function NoteEditor({ // new vector strokes so migrating a note never loses what was already drawn. const legacyRaster = note.drawingData && !hideDrawing; + /* Whether this note needs a page at all. + * + * The fixed 3:4 stage exists for the canvas: a stroke has to land in the same + * place on every screen, which needs one agreed page. Writing needs no such + * thing, and paid for it with a bordered box and an empty page below two lines + * of text. So the page appears exactly when something is on it — or about to + * be — and plain text is simply text. */ + const needsPage = mode === "draw" || hasStrokes || !!legacyRaster; + const commitStrokes = (next) => { strokesRef.current = next; setStrokes(next); @@ -75,15 +85,6 @@ export function NoteEditor({ const T = useMemo( () => ({ bg: dark ? "#0d1117" : "#ffffff", - /* The sheet. - * - * In daylight it is warm off-white — plain white next to a white toolbar - * is not a sheet, it is an absence of one. At night it is a raised surface - * rather than a white rectangle: a page that glows brighter than the room - * is what makes an app unusable in the evening, and "paper" in a dark room - * still means "the surface I write on", not "white". */ - paper: dark ? "#1b2430" : "#fbfaf7", - paperEdge: dark ? "#333f4e" : "#e4dfd5", toolbar: dark ? "#161b22" : "#f6f8fa", border: dark ? "#30363d" : "#d0d7de", text: dark ? "#e6edf3" : "#1f2328", @@ -163,13 +164,7 @@ export function NoteEditor({ const mobile = window.innerWidth < 768; setIsMobileViewport(mobile); const sidePad = mobile ? 10 : 28; - /* How wide a sheet gets. - * - * With 56px of inner margin this leaves a text column of about 68 - * characters at the body size — measured, not guessed. The old 760 ran to - * nearly 90, past the width at which the eye reliably finds the start of - * the next line, and the single biggest reason the surface read badly. */ - const desktopMax = 620; + const desktopMax = 760; const minW = mobile ? 220 : 320; const maxAllowed = Math.max(220, W - sidePad * 2); const targetW = mobile ? maxAllowed : Math.min(desktopMax, maxAllowed); @@ -460,9 +455,11 @@ export function NoteEditor({ * width a property of the window, which is what it always looked like. */ flex: 1, minWidth: 0, - // The desk the sheet lies on, a touch darker than the sheet so the - // shadow has something to fall onto. - background: isMobileViewport ? T.paper : dark ? "#0b0f14" : "#eceae5", + background: isMobileViewport + ? T.bg + : dark + ? "linear-gradient(180deg,#0f141b 0%,#0d1117 60%)" + : "linear-gradient(180deg,#f8fafd 0%,#eef3f8 70%)", }} > {/* Main toolbar */} @@ -671,25 +668,22 @@ export function NoteEditor({
diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 48f4454..1cce7b3 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -524,7 +524,9 @@ export function NoteList({ padding: 30, }} > -
{isTrashView ? "🗑️" : "📝"}
+
+ {isTrashView ? I.trash : I.note} +
{isTrashView ? "Trash is empty" : "No notes yet"}
@@ -603,7 +605,7 @@ export function NoteList({ display: "flex", flexDirection: "column", width: "100%", - padding: "12px 14px", + padding: "9px 12px", borderRadius: 10, border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`, background: activeNote === note.id ? T.accentSoft : "transparent", @@ -621,10 +623,11 @@ export function NoteList({ width: "100%", }} > - - {note.trashed ? "🗑️" : note.type === "pdf" ? "📄" : "📝"} + + {note.trashed ? I.trash : note.type === "pdf" ? I.pdf : I.note} {note.starred && !note.trashed && ( - ⭐ + {I.starFill} )}
{snippet && ( @@ -657,7 +660,7 @@ export function NoteList({ {snippet}
)} -
+
{fmtDate(note.updatedAt)}
diff --git a/frontend/src/ui/Sidebar.jsx b/frontend/src/ui/Sidebar.jsx index 9645187..be2047a 100644 --- a/frontend/src/ui/Sidebar.jsx +++ b/frontend/src/ui/Sidebar.jsx @@ -164,7 +164,7 @@ export function Sidebar({ onClick={() => onSelectFolder("dashboard")} style={{ ...folderButton(T, activeFolder === "dashboard"), padding: "9px 12px", fontWeight: 600 }} > - 📚 + {I.books} Dashboard
@@ -176,7 +176,7 @@ export function Sidebar({ onClick={() => onSelectFolder(f.id)} style={folderButton(T, activeFolder === f.id)} > - {f.icon} + {I[f.icon]} {f.name} {f.id === "all" && {activeNotes.length}} {f.id === "trash" && trashedCount > 0 && ( diff --git a/frontend/src/ui/constants.jsx b/frontend/src/ui/constants.jsx index 742c3cf..15251b4 100644 --- a/frontend/src/ui/constants.jsx +++ b/frontend/src/ui/constants.jsx @@ -40,8 +40,11 @@ export const COVERS = [ { bg: "linear-gradient(135deg,#1f1c2c,#928dab)", spine: "#15131f", accent: "#b39ddb" }, { bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" }, ]; +/* The icon is a key rather than an emoji: the sidebar draws it from the app's + * own set, so it matches the stroke weight of everything around it and looks + * the same on every machine. */ export const DEFAULT_FOLDERS = [ - { id: "all", name: "All Notes", icon: "📋" }, - { id: "starred", name: "Starred", icon: "⭐" }, - { id: "trash", name: "Trash", icon: "🗑️" }, + { id: "all", name: "All Notes", icon: "stack" }, + { id: "starred", name: "Starred", icon: "star" }, + { id: "trash", name: "Trash", icon: "trash" }, ]; diff --git a/frontend/src/ui/icons.jsx b/frontend/src/ui/icons.jsx index 0222cf0..0f66230 100644 --- a/frontend/src/ui/icons.jsx +++ b/frontend/src/ui/icons.jsx @@ -506,6 +506,68 @@ export const I = { ), + /* Line icons for the places that used to carry emoji. + * + * Emoji are drawn by the operating system: they look different on every + * machine, sit at a different baseline, and are loud — a row's picture caught + * the eye before its title did. These match the stroke weight of everything + * else here. */ + note: ( + + + + + + + ), + books: ( + + + + + + ), + stack: ( + + + + + + ), + dots: ( + + + + + + ), pin: (