Write pinned notes in the margin, and give the phone a sheet instead
A note about a passage belongs beside the passage, not on top of it. Only a numbered marker stays on the page now, with a hairline out to a card in the margin, level with its anchor. Cards keep one height so two notes close together can be laid out by pushing the second down; the one being written in grows and rises above the rest. A phone has no margin to write in — a page narrow enough to read leaves no white space beside it, and taking a column would make the document unreadable. There the note comes up from the bottom instead: over the page, only while open, within reach of a thumb, with the passage still visible above it. Reserving the column exposed a loop worth remembering. A flex item is as wide as its content unless told otherwise, so the 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. The viewer and the editor pane now say minWidth: 0, and the toolbar scrolls rather than cutting its last controls off on a narrow screen. Splitting each row into page and margin also broke which pages counted as visible — the check read data-pdf-page off the row, found nothing, and cancelled every text layer before it rendered. It reads the row's own attribute now. 215 unit tests, 219 browser checks across eleven suites, including the phone layout at 420px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6438648313
commit
011ba82a94
7 files changed
+621
-176
No files matched your search
+115
-5
@@ -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,
|
||||
|
||||
@@ -169,7 +169,18 @@ export function EditorPane({
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: T.bg, fontFamily: FONT_UI }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
// A flex item is otherwise at least as wide as its content, so a wide
|
||||
// pane — a PDF beside its margin — could push itself past the window.
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: T.bg,
|
||||
fontFamily: FONT_UI,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -210,7 +221,7 @@ export function EditorPane({
|
||||
{I.trash}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", minHeight: 0 }}>
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", minHeight: 0, minWidth: 0 }}>
|
||||
{note.type === "pdf" ? (
|
||||
<PdfWorkspace
|
||||
note={note}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* The notes for a page, written in the margin beside it.
|
||||
*
|
||||
* This is where a reader has always put remarks: level with the passage, in the
|
||||
* white space, not over the words. Writing them onto the page would cover the
|
||||
* sentence the note is about, which is the one thing a note about a sentence
|
||||
* must not do.
|
||||
*
|
||||
* Each card sits at the height of its anchor. Cards keep one height so that two
|
||||
* notes close together can be laid out without measuring: they are simply pushed
|
||||
* down until they no longer overlap, and the marker's hairline still says which
|
||||
* spot each belongs to. The card being written in grows and rises above the
|
||||
* others, because that one is being read closely and the rest are not.
|
||||
*/
|
||||
|
||||
export const MARGIN_W = 208;
|
||||
const CARD_H = 74;
|
||||
const CARD_GAP = 6;
|
||||
|
||||
/** Cards at their anchors, pushed down where they would sit on top of each other. */
|
||||
function layout(notes, pageHeight, cssHeight) {
|
||||
let previousBottom = -Infinity;
|
||||
return notes
|
||||
.slice()
|
||||
.sort((a, b) => 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 (
|
||||
<div
|
||||
data-pin-card={note.id}
|
||||
onClick={() => !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",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: "50%",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{open ? (
|
||||
<>
|
||||
<textarea
|
||||
ref={areaRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
commit();
|
||||
onOpen(null);
|
||||
}
|
||||
}}
|
||||
aria-label="Pinned note"
|
||||
placeholder="Notiz zu dieser Stelle…"
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 8,
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.5,
|
||||
padding: "5px 7px",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center", marginTop: 5 }}>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
data-pin-remove
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: T.danger,
|
||||
cursor: "pointer",
|
||||
fontSize: 10.5,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button
|
||||
onClick={() => {
|
||||
commit();
|
||||
onOpen(null);
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
borderRadius: 7,
|
||||
cursor: "pointer",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 600,
|
||||
padding: "3px 10px",
|
||||
}}
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.45,
|
||||
color: note.text ? T.text : T.muted,
|
||||
maxHeight: CARD_H - 22,
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{note.text || "Leer — zum Schreiben antippen"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PdfMargin({ T, notes, pageHeight, cssHeight, openId, onOpen, onChange, onRemove, numberOf }) {
|
||||
const placed = layout(notes, pageHeight, cssHeight);
|
||||
// Cards near the foot of a page may need more room than the page is tall.
|
||||
const needed = placed.length ? placed[placed.length - 1].top + CARD_H : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-pdf-margin
|
||||
style={{
|
||||
position: "relative",
|
||||
width: MARGIN_W,
|
||||
flexShrink: 0,
|
||||
minHeight: Math.max(cssHeight, needed),
|
||||
}}
|
||||
>
|
||||
{placed.map(({ note, top }) => (
|
||||
<Card
|
||||
key={note.id}
|
||||
T={T}
|
||||
note={note}
|
||||
top={top}
|
||||
number={numberOf(note.id)}
|
||||
open={openId === note.id}
|
||||
onOpen={onOpen}
|
||||
onChange={(text) => onChange(note.id, text)}
|
||||
onRemove={() => onRemove(note.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* A pinned note on a phone.
|
||||
*
|
||||
* There is no margin to write in: a page narrow enough to read on a phone leaves
|
||||
* no white space beside it, and stealing 200px for a column would make the
|
||||
* document itself unreadable. So the note comes up from the bottom instead —
|
||||
* over the page rather than beside it, but only while it is being read or
|
||||
* written, and within reach of a thumb rather than at the top of the screen.
|
||||
*
|
||||
* The page stays visible above it, so the passage the note is about is still
|
||||
* there while writing about it.
|
||||
*/
|
||||
export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
|
||||
const [draft, setDraft] = useState(note.text);
|
||||
const areaRef = useRef(null);
|
||||
|
||||
useEffect(() => setDraft(note.text), [note.id]);
|
||||
|
||||
const commit = () => {
|
||||
if (draft !== note.text) onChange(draft);
|
||||
};
|
||||
const close = () => {
|
||||
commit();
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
data-pin-sheet={note.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 60,
|
||||
background: T.bar,
|
||||
borderTop: `1px solid ${T.border}`,
|
||||
borderRadius: "14px 14px 0 0",
|
||||
boxShadow: "0 -8px 26px rgba(0,0,0,.3)",
|
||||
padding: "10px 12px calc(10px + env(safe-area-inset-bottom))",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
<span style={{ fontSize: 11.5, color: T.muted, flex: 1 }}>Notiz zu dieser Stelle</span>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
data-pin-remove
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: T.danger,
|
||||
cursor: "pointer",
|
||||
fontSize: 11.5,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={areaRef}
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
aria-label="Pinned note"
|
||||
placeholder="Notiz zu dieser Stelle…"
|
||||
rows={3}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "none",
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 8,
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
padding: "8px 10px",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={close}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginTop: 8,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
padding: "9px 0",
|
||||
}}
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +1,15 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
/**
|
||||
* Notes pinned to a spot on a PDF page.
|
||||
* The markers for notes pinned to a page.
|
||||
*
|
||||
* The panel beside the document is for notes about the document; this is for
|
||||
* notes about a *passage* — the paragraph you were reading when you thought of
|
||||
* it. A note that has to say "on page 14, third paragraph" is a note that has
|
||||
* lost its place.
|
||||
* Only the anchor lives on the document — a small numbered dot on the spot the
|
||||
* note is about. The note itself is written in the margin beside the page, or on
|
||||
* a phone in a sheet from the bottom. Writing *onto* the page covers the
|
||||
* sentence the note is about, which is the one thing it must not do.
|
||||
*
|
||||
* Positions are stored in PDF points, the same space as the drawing, so a pin
|
||||
* stays on its sentence at every zoom and on every screen.
|
||||
*/
|
||||
|
||||
/** Where a pin's bubble opens, kept inside the page rather than off its edge. */
|
||||
function bubbleSide(xFraction) {
|
||||
return xFraction > 0.6 ? "right" : "left";
|
||||
}
|
||||
|
||||
function PinBubble({ T, pin, onChange, onClose, onRemove, side }) {
|
||||
const ref = useRef(null);
|
||||
const [draft, setDraft] = useState(pin.text);
|
||||
|
||||
// Committed on the way out rather than on every keystroke: each save is a
|
||||
// write of the whole overlay, and one per character would be absurd.
|
||||
const commit = () => {
|
||||
if (draft !== pin.text) onChange(draft);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const away = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
commit();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
// Deferred, or the click that opened the bubble closes it again.
|
||||
const t = setTimeout(() => document.addEventListener("pointerdown", away), 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
document.removeEventListener("pointerdown", away);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-pin-bubble={pin.id}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 26,
|
||||
[side]: 0,
|
||||
width: 230,
|
||||
zIndex: 30,
|
||||
background: T.bar,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: "0 10px 26px rgba(0,0,0,.3)",
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation(); // the viewer's shortcuts are not wanted in here
|
||||
if (e.key === "Escape") {
|
||||
commit();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
aria-label="Pinned note"
|
||||
placeholder="Notiz zu dieser Stelle…"
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 8,
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
padding: "6px 8px",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6 }}>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
data-pin-remove
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: T.danger,
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button
|
||||
onClick={() => {
|
||||
commit();
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: "4px 12px",
|
||||
}}
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pins on one page.
|
||||
*
|
||||
* Sits above the ink layer but below nothing else, and only takes the pointer
|
||||
* where a pin actually is — the rest of the page must stay selectable and
|
||||
* scrollable.
|
||||
* Positions are in PDF points, the same space as the drawing, so a marker stays
|
||||
* on its sentence at every zoom and on every screen.
|
||||
*/
|
||||
export function PdfPins({
|
||||
T,
|
||||
@@ -143,11 +20,10 @@ export function PdfPins({
|
||||
notes,
|
||||
placing,
|
||||
openId,
|
||||
withMargin,
|
||||
onOpen,
|
||||
onPlace,
|
||||
onChange,
|
||||
onMove,
|
||||
onRemove,
|
||||
numberOf,
|
||||
}) {
|
||||
const layerRef = useRef(null);
|
||||
@@ -161,9 +37,9 @@ export function PdfPins({
|
||||
};
|
||||
};
|
||||
|
||||
/* Dragging a pin is tracked here rather than with the HTML drag API: that one
|
||||
* cannot report a position continuously, and a pin has to follow the finger to
|
||||
* be placed on a line of text. */
|
||||
/* Dragging is tracked with pointer events rather than the HTML drag API:
|
||||
* that one cannot report a position continuously, and a marker has to follow
|
||||
* the finger to be put on a line of text. */
|
||||
const startDrag = (e, pin) => {
|
||||
if (placing) return;
|
||||
e.preventDefault();
|
||||
@@ -198,8 +74,8 @@ export function PdfPins({
|
||||
width: cssWidth,
|
||||
height: cssHeight,
|
||||
zIndex: 12,
|
||||
// Only the pins are targets unless a note is being placed, or the page
|
||||
// could neither be scrolled nor its text selected.
|
||||
// Only the markers are targets unless a note is being placed, or the
|
||||
// page could neither be scrolled nor its text selected.
|
||||
pointerEvents: placing ? "auto" : "none",
|
||||
cursor: placing ? "crosshair" : "default",
|
||||
}}
|
||||
@@ -207,16 +83,30 @@ export function PdfPins({
|
||||
{notes.map((pin) => {
|
||||
const xf = pin.x / pageSize.width;
|
||||
const yf = pin.y / pageSize.height;
|
||||
const open = openId === pin.id;
|
||||
return (
|
||||
<div
|
||||
key={pin.id}
|
||||
style={{ position: "absolute", left: `${xf * 100}%`, top: `${yf * 100}%`, pointerEvents: "auto" }}
|
||||
>
|
||||
{/* A hairline out to the margin, so it is visible which note belongs
|
||||
to which spot without having to click one to find out. */}
|
||||
{withMargin && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${xf * 100}%`,
|
||||
top: `${yf * 100}%`,
|
||||
pointerEvents: "auto",
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: 1,
|
||||
width: `calc(${(1 - xf) * cssWidth}px)`,
|
||||
// Barely there until the note is open: it crosses the page's
|
||||
// own text, and a permanent line over the sentence defeats
|
||||
// the point of writing beside the page rather than on it.
|
||||
background: open ? T.accent : T.accent + "26",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
data-pin={pin.id}
|
||||
title={pin.text || "Notiz zu dieser Stelle"}
|
||||
@@ -227,32 +117,22 @@ export function PdfPins({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 22,
|
||||
height: 22,
|
||||
width: open ? 24 : 20,
|
||||
height: open ? 24 : 20,
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${T.bar}`,
|
||||
border: `2px solid ${open ? "#fff" : T.bar}`,
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
cursor: "grab",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,.35)",
|
||||
boxShadow: open ? `0 0 0 3px ${T.accent}44` : "0 2px 6px rgba(0,0,0,.35)",
|
||||
transform: "translate(-50%, -50%)",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
{numberOf(pin.id)}
|
||||
</button>
|
||||
{openId === pin.id && (
|
||||
<PinBubble
|
||||
T={T}
|
||||
pin={pin}
|
||||
side={bubbleSide(xf)}
|
||||
onChange={(text) => onChange(pin.id, text)}
|
||||
onClose={() => onOpen(null)}
|
||||
onRemove={() => onRemove(pin.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,12 +7,15 @@ import { extractAndCache, indexPdfText } from "./pdfText";
|
||||
import "./textLayer.css";
|
||||
import { PdfDrawLayer } from "./PdfDrawLayer.jsx";
|
||||
import { PdfPins } from "./PdfPins.jsx";
|
||||
import { MARGIN_W, PdfMargin } from "./PdfMargin.jsx";
|
||||
import { PdfNoteSheet } from "./PdfNoteSheet.jsx";
|
||||
import {
|
||||
allPinnedNotes,
|
||||
asPdfStrokeDoc,
|
||||
hasOverlay,
|
||||
notesForPage,
|
||||
strokesForPage,
|
||||
totalPinnedNotes,
|
||||
totalStrokes,
|
||||
withPage,
|
||||
withPageNotes,
|
||||
@@ -68,13 +71,19 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
|
||||
}, [doc, index, width, visible]);
|
||||
|
||||
return (
|
||||
/* The page and its margin are one row, so a note card can be placed at the
|
||||
height of its anchor without knowing where the page sits in the scroll. */
|
||||
<div
|
||||
data-pdf-row={index + 1}
|
||||
style={{ display: "flex", justifyContent: "center", gap: 12, marginBottom: 16 }}
|
||||
>
|
||||
<div
|
||||
data-pdf-page={index + 1}
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
margin: "0 auto 16px",
|
||||
flexShrink: 0,
|
||||
background: "#fff",
|
||||
boxShadow: "0 2px 10px rgba(0,0,0,0.25)",
|
||||
borderRadius: 2,
|
||||
@@ -106,10 +115,24 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
|
||||
notes={pins.notes}
|
||||
placing={pins.placing}
|
||||
openId={pins.openId}
|
||||
withMargin={pins.withMargin}
|
||||
onOpen={pins.onOpen}
|
||||
onPlace={(at) => pins.onPlace(index + 1, size, at)}
|
||||
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
|
||||
onMove={(id, x, y) => pins.onMove(index + 1, size, id, x, y)}
|
||||
numberOf={pins.numberOf}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pins.withMargin && !pins.hidden && (
|
||||
<PdfMargin
|
||||
T={pins.T}
|
||||
notes={pins.notes}
|
||||
pageHeight={size.height}
|
||||
cssHeight={height}
|
||||
openId={pins.openId}
|
||||
onOpen={pins.onOpen}
|
||||
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
|
||||
onRemove={(id) => pins.onRemove(index + 1, size, id)}
|
||||
numberOf={pins.numberOf}
|
||||
/>
|
||||
@@ -343,7 +366,30 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
// The widest page decides the scale, so a document mixing portrait and
|
||||
// landscape does not make the narrow pages jump about as you scroll.
|
||||
const widestPt = sizes.reduce((w, s) => Math.max(w, s.width), 0) || 1;
|
||||
const fitWidth = Math.max(240, Math.min(containerWidth - 32, 1100));
|
||||
|
||||
/* Whether there is room to write beside the page.
|
||||
*
|
||||
* On a phone there is not: a page narrow enough to read leaves no white space
|
||||
* next to it, and taking 200px for a column would make the document itself
|
||||
* unreadable. There the notes come up from the bottom instead.
|
||||
*
|
||||
* The column is kept as soon as the document has any pinned note, or while one
|
||||
* is being placed — so it is already there before the first note exists and
|
||||
* the page does not jump sideways at the moment of writing it. */
|
||||
const roomForMargin = containerWidth >= MARGIN_W + 380;
|
||||
const withMargin = roomForMargin && (placing || totalPinnedNotes(ink) > 0);
|
||||
|
||||
/* The open note, with the page it belongs to — the sheet needs the page size
|
||||
to write a change back into the right page's overlay. */
|
||||
const openSheet = (() => {
|
||||
if (!openPin) return null;
|
||||
const found = allPinnedNotes(ink).find((n) => n.id === openPin);
|
||||
const size = found ? sizes[found.page - 1] : null;
|
||||
return found && size ? { note: found, page: found.page, size } : null;
|
||||
})();
|
||||
|
||||
const reserved = withMargin ? MARGIN_W + 12 : 0;
|
||||
const fitWidth = Math.max(240, Math.min(containerWidth - 32 - reserved, 1100));
|
||||
const pageWidth = Math.round(fitWidth * zoom);
|
||||
const scale = pageWidth / widestPt;
|
||||
|
||||
@@ -359,7 +405,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
let current = 1;
|
||||
|
||||
for (const child of inner.children) {
|
||||
const n = Number(child.dataset.pdfPage);
|
||||
// The row, not the page: the page sits inside it next to its margin, and
|
||||
// reading `dataset.pdfPage` off the row found nothing — so every page was
|
||||
// "out of view", and the text layers were cancelled before they rendered.
|
||||
const n = Number(child.dataset.pdfRow);
|
||||
if (!n) continue;
|
||||
const start = child.offsetTop;
|
||||
const end = start + child.offsetHeight;
|
||||
@@ -428,7 +477,21 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0, background: T.bg }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
/* Without this a flex item is at least as wide as its content, so the
|
||||
margin column made the viewer wider, which made there be room for the
|
||||
margin column — the page shrank to 254px inside a 420px phone while
|
||||
the layout insisted it was 611px wide. */
|
||||
minWidth: 0,
|
||||
background: T.bg,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -441,6 +504,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
fontSize: 12,
|
||||
color: T.muted,
|
||||
flexShrink: 0,
|
||||
/* On a phone the controls are wider than the screen. Scrolling the bar
|
||||
keeps every one of them reachable; without it the ones at the end
|
||||
were simply cut off. */
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<button onClick={() => goToPage(Math.max(1, pageNo - 1))} title="Vorherige Seite" style={barButton}>
|
||||
@@ -629,7 +696,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} style={{ flex: 1, overflow: "auto", padding: 16, minHeight: 0 }}>
|
||||
<div ref={scrollRef} style={{ flex: 1, overflow: "auto", padding: 16, minHeight: 0, minWidth: 0 }}>
|
||||
{state.status === "loading" && (
|
||||
<div
|
||||
style={{ color: T.muted, fontSize: 13, fontFamily: FONT_UI, textAlign: "center", padding: 40 }}
|
||||
@@ -667,6 +734,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
placing,
|
||||
openId: openPin,
|
||||
hidden: hideInk,
|
||||
withMargin,
|
||||
onOpen: setOpenPin,
|
||||
onPlace: placeNote,
|
||||
onChange: changeNote,
|
||||
@@ -679,6 +747,19 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No margin to write in — the note comes up from the bottom instead, over
|
||||
the page but within reach of a thumb, and only while it is open. */}
|
||||
{!withMargin && openSheet && (
|
||||
<PdfNoteSheet
|
||||
T={T}
|
||||
note={openSheet.note}
|
||||
number={pinNumbers.get(openSheet.note.id) ?? ""}
|
||||
onChange={(text) => changeNote(openSheet.page, openSheet.size, openSheet.note.id, text)}
|
||||
onRemove={() => removeNote(openSheet.page, openSheet.size, openSheet.note.id)}
|
||||
onClose={() => setOpenPin(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -332,7 +332,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
||||
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
||||
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
|
||||
| `pdf` | 57 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `pdf` | 70 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
|
||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
@@ -760,12 +760,30 @@ The panel beside a PDF is for notes about the document. A note that belongs to
|
||||
third paragraph" has already lost its place. So a note can be pinned to a spot:
|
||||
click **Randnotiz**, click the passage, write.
|
||||
|
||||
Pins are stored in the same per-page overlay as the drawings, in **PDF points**.
|
||||
That is what keeps one on its sentence at every zoom, on every screen, and in the
|
||||
exported file — the same property the strokes have, for the same reason.
|
||||
**The note is written beside the page, not on it.** Only a small numbered marker
|
||||
sits on the document, with a hairline out to a card in the margin, level with the
|
||||
passage. Writing onto the page would cover the sentence the note is about, which
|
||||
is the one thing it must not do. Cards keep one height, so two notes close
|
||||
together are laid out by pushing the second down until they no longer overlap;
|
||||
the one being written in grows and rises above the rest.
|
||||
|
||||
Being in the overlay rather than in the note body has three consequences, each of
|
||||
which needed handling rather than assuming:
|
||||
**On a phone there is no margin.** A page narrow enough to read leaves no white
|
||||
space beside it, and taking 200px for a column would make the document itself
|
||||
unreadable. There, tapping a marker brings the note up from the bottom — over the
|
||||
page, but only while it is open, within reach of a thumb, and with the passage
|
||||
still visible above it.
|
||||
|
||||
The column is kept as soon as the document has one pinned note, or while one is
|
||||
being placed, so the page does not jump sideways at the moment of writing the
|
||||
first. Reserving it also exposed a loop worth remembering: a flex item is as wide
|
||||
as its content unless told otherwise, so the 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.
|
||||
|
||||
Positions are stored in **PDF points**, the same space as the drawings, so a
|
||||
marker stays on its sentence at every zoom and on every screen. Being in the
|
||||
overlay rather than in the note body has three consequences, each of which needed
|
||||
handling rather than assuming:
|
||||
|
||||
- **Search.** Nothing else would index them, so the viewer feeds them to the
|
||||
local index itself, with their page numbers — finding the words is only half of
|
||||
|
||||
Reference in new issue
Block a user