diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs index bd7010b..dd4200b 100644 --- a/e2e/pdf.mjs +++ b/e2e/pdf.mjs @@ -480,11 +480,26 @@ try { await p.mouse.click(page1.x + page1.width * 0.4, page1.y + page1.height * 0.3); await p.waitForTimeout(800); - check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a pin"); - const bubble = p.getByLabel("Pinned note"); - check(await bubble.isVisible(), "and opens straight into writing"); + check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a marker on the spot"); + check( + (await p.locator("[data-pdf-margin]").count()) > 0, + "and the note itself belongs in the margin beside the page, not on it" + ); + const field = p.getByLabel("Pinned note"); + check(await field.isVisible(), "which opens straight into writing"); - await bubble.fill("Drehmoment beachten"); + /* The card has to be level with the passage — that is the whole point of a + margin note, and the reason it is not simply a list under the document. */ + const levels = await p.evaluate(() => { + const marker = document.querySelector("[data-pin]").getBoundingClientRect(); + const card = document.querySelector("[data-pin-card]").getBoundingClientRect(); + const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect(); + return { gap: Math.abs(marker.top - card.top), pageRight: page.right, cardLeft: card.left }; + }); + check(levels.gap < 40, "the card sits level with its marker", `${Math.round(levels.gap)}px apart`); + check(levels.cardLeft >= levels.pageRight - 2, "and beside the page rather than over it"); + + await field.fill("Drehmoment beachten"); await p.getByRole("button", { name: "Fertig" }).click(); await p.waitForTimeout(2500); @@ -539,11 +554,17 @@ try { await p.waitForTimeout(4000); check((await p.locator("[data-pin]").count()) === 1, "the pin survives a reload"); + check( + (await p.locator("[data-pin-card]").innerText()).includes("Drehmoment"), + "with the text showing in the margin without being opened first", + await p.locator("[data-pin-card]").innerText() + ); + await p.locator("[data-pin]").click(); await p.waitForTimeout(600); check( (await p.getByLabel("Pinned note").inputValue()) === "Drehmoment beachten", - "with the text that was written into it" + "and the same text when opened for editing" ); /* Clearing a page's ink must not take its pinned notes: same page, different @@ -610,6 +631,95 @@ try { + /* ── on a phone there is no margin to write in ── */ + // A page narrow enough to read leaves no white space beside it, so taking a + // column for notes would make the document itself unreadable. The note comes + // up from the bottom instead — over the page, but only while it is open. + await p.locator("[data-pdf-pin-mode]").click(); + await p.waitForTimeout(400); + const pageM = await p.locator('[data-pdf-page="1"]').boundingBox(); + await p.mouse.click(pageM.x + pageM.width * 0.5, pageM.y + pageM.height * 0.4); + await p.waitForTimeout(800); + await p.getByLabel("Pinned note").fill("Am Rand notiert"); + await p.getByRole("button", { name: "Fertig" }).click(); + await p.waitForTimeout(2500); + + const wideMargins = await p.locator("[data-pdf-margin]").count(); + check(wideMargins > 0, "on a wide screen the note is in the margin", String(wideMargins)); + const pageWithMargin = (await p.locator('[data-pdf-page="1"]').boundingBox()).width; + + await p.setViewportSize({ width: 420, height: 820 }); + await p.waitForTimeout(2000); + // Below the phone breakpoint the list and the editor take turns, so the note + // has to be opened again to be looking at the document at all. + // Shrinking a desktop window past the breakpoint leaves the sidebar and its + // backdrop up, which no real phone ever sees. Dismissed here so the rest of + // the section is testing the document and not an overlay. + if ((await p.locator("[data-pdf-page]").count()) === 0) { + await p.evaluate(() => document.querySelector("[data-note-row]")?.click()); + await p.waitForTimeout(4500); + } + await p.mouse.click(410, 800); + await p.waitForTimeout(800); + check( + (await p.locator("[data-pdf-margin]").count()) === 0, + "on a narrow one the margin is gone", + String(await p.locator("[data-pdf-margin]").count()) + ); + check((await p.locator("[data-pin]").count()) > 0, "but the markers are still on their spots"); + + // The widest page should fill what the screen has, rather than share it with + // a column. (Page 1 is portrait in a document whose widest page is landscape, + // so it is legitimately narrower — page 2 is the one that sets the scale.) + void pageWithMargin; + const widest = (await p.locator('[data-pdf-page="2"]').boundingBox()).width; + check( + widest > 420 - 60, + "and the page gets the width the column was taking", + `${Math.round(widest)}px of 420` + ); + + /* Reserving a column made the viewer wider, which made there be room for the + column — the page ended up at 254px inside a 420px phone while the layout + insisted it had 611px. A flex item is as wide as its content unless told + otherwise. */ + const fits = await p.evaluate(() => { + let el = document.querySelector("[data-pdf-page]")?.parentElement; + while (el && getComputedStyle(el).overflow !== "auto") el = el.parentElement; + return { scroller: el?.clientWidth ?? 0, window: window.innerWidth }; + }); + check( + fits.scroller <= fits.window, + "and the document area does not push itself past the window", + `${fits.scroller} vs ${fits.window}` + ); + + const marker = p.locator("[data-pin]").first(); + const markerText = await marker.getAttribute("title"); + await marker.scrollIntoViewIfNeeded(); + await marker.click(); + await p.waitForTimeout(800); + check((await p.locator("[data-pin-sheet]").count()) === 1, "tapping the marker opens a sheet"); + check( + (await p.getByLabel("Pinned note").inputValue()) === markerText, + "with the note that was written on the desktop", + markerText + ); + + const sheet = await p.locator("[data-pin-sheet]").boundingBox(); + check( + sheet.y + sheet.height > 700, + "at the bottom, where a thumb reaches", + `top ${Math.round(sheet.y)} of 820` + ); + + await p.getByRole("button", { name: "Fertig" }).click(); + await p.waitForTimeout(800); + check((await p.locator("[data-pin-sheet]").count()) === 0, "and closes again"); + + await p.setViewportSize({ width: 1400, height: 950 }); + await p.waitForTimeout(2000); + /* ── the notes panel lives in the same toolbar now ── */ check( (await p.locator("[data-pdf-toggle-notes]").count()) === 1, diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx index cc03867..9ad87bf 100644 --- a/frontend/src/features/notes/EditorPane.jsx +++ b/frontend/src/features/notes/EditorPane.jsx @@ -169,7 +169,18 @@ export function EditorPane({ ); return ( -
+
-
+
{note.type === "pdf" ? ( a.y - b.y) + .map((note) => { + const anchor = (note.y / pageHeight) * cssHeight - 12; + const top = Math.max(anchor, previousBottom + CARD_GAP); + previousBottom = top + CARD_H; + return { note, top }; + }); +} + +function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) { + const [draft, setDraft] = useState(note.text); + const areaRef = useRef(null); + + // Adopt text that arrived from elsewhere — another device, or an undo — but + // never while this card is the one being typed into. + useEffect(() => { + if (!open) setDraft(note.text); + }, [note.text, open]); + + useEffect(() => { + if (open) areaRef.current?.focus(); + }, [open]); + + // Committed on the way out, not per keystroke: every save rewrites the whole + // overlay, and one per character would be absurd. + const commit = () => { + if (draft !== note.text) onChange(draft); + }; + + return ( +
!open && onOpen(note.id)} + style={{ + position: "absolute", + left: 0, + right: 0, + top, + zIndex: open ? 5 : 1, + display: "flex", + gap: 6, + padding: 8, + borderRadius: 10, + border: `1px solid ${open ? T.accent : T.border}`, + background: T.bar, + boxShadow: open ? "0 8px 22px rgba(0,0,0,.28)" : "none", + cursor: open ? "default" : "pointer", + }} + > + + {number} + + +
+ {open ? ( + <> +