Colour, a page you can see the edge of, and a document on its own
Five of the nine things on the list, and one finding that came out of them. Colour: a marker pen and an ink colour, written as marks here rather than installed — the schema of a note decides what can be stored in it forever. A highlight is a <mark> so a reader that drops the colour still shows the word as marked, and marked text is painted dark whatever the theme. Both travel as HTML in Markdown, like underline, and both are read back. The PDF fills the screen: the browser's own full screen for the window frame, an overlay for the sidebar and the list, Escape and a visible button for the way out. The dashboard leads with the recent notes, sorted by time whatever order the notebooks are in. A tap on the blank part of a page puts the caret at the nearest position instead of nowhere. A drag from the left edge shows the sidebar. The page has an edge on a phone, where it had none. The finding: at the speed a robot types, a long note loses keystrokes between the editor and the pagination measuring after each one — 87 of 220 paragraphs. Nothing is lost at 50 characters a second, so the page-count check now pastes its bulk text rather than measuring typing throughput. And the history suite asked for "the first note in the list" without waiting for the note to reach the server; it asks by name now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9148540a8
commit
9507761bce
23 files changed
+1091
-109
No files matched your search
@@ -14,6 +14,49 @@ a rebuild is not bit-identical.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-25 — Colour, a page you can see the edge of, and a document on its own
|
||||
|
||||
**Images** — web `sha256:3e1b47e1…`, server `sha256:89e474cc…`
|
||||
(earlier that day: web `783ccf92…`, `d7c66235…`; server `2141f145…`, `308ed1b7…`)
|
||||
|
||||
The server changed earlier in the day for the bar's runtime configuration; this
|
||||
last pair carries everything below and no backend change of its own. The two
|
||||
entries under it are the same day's earlier rounds, kept apart because they are
|
||||
about different things.
|
||||
|
||||
**Added**
|
||||
- **A marker pen and a colour for the letters**, both under one button in the
|
||||
toolbar — two buttons would be two panels for one question. A highlight is a
|
||||
`<mark>`, the element that *means* highlighted, and marked text is painted with
|
||||
dark ink whatever the theme, the way a real marker leaves the writing alone.
|
||||
Markdown has no colour, so both travel as HTML like underline does, and both
|
||||
are read back — including a bare `<mark>`, which is what most other writers
|
||||
emit.
|
||||
- **The PDF fills the screen.** The button asks the browser for its own full
|
||||
screen *and* lays the document out over the app; either alone is half the job,
|
||||
and a browser that refuses the first still gets the second. Escape leaves, and
|
||||
the button stays visible while full — a way out that is only known is not a way
|
||||
out.
|
||||
- **Dragging in from the left edge shows the sidebar** on a phone or tablet, and
|
||||
dragging it back hides it. It must start at the edge, travel far enough to be
|
||||
meant, and be more sideways than downwards; without the first rule every
|
||||
sideways drag inside a note opens it, without the third every scroll does.
|
||||
|
||||
**Changed**
|
||||
- **The dashboard leads with what you were last writing**, the shelf of notebooks
|
||||
second — and "Recent" is sorted by time whatever order the notebooks are in.
|
||||
It is a question about time, and a hand-made arrangement is the wrong answer.
|
||||
- **Tapping the blank part of a page starts writing there.** The text is only as
|
||||
tall as what has been written, so most of a fresh page is beside the editable
|
||||
area rather than in it, and a tap down there landed on nothing at all: no
|
||||
caret, and on a phone no keyboard. The caret goes to the position nearest the
|
||||
tap rather than to the end.
|
||||
- **The page has an edge on a phone.** The paper runs to the edges of the screen
|
||||
there, but it was drawn with no border and no shadow, so nothing said where one
|
||||
page ended and the next began.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-25 — The bar that ties the apps together, and Ctrl over a PDF
|
||||
|
||||
**Images** — web `sha256:783ccf92…`, server `sha256:2141f145…`
|
||||
|
||||
+61
-7
@@ -173,13 +173,22 @@ try {
|
||||
);
|
||||
|
||||
await p.locator(".ProseMirror").click();
|
||||
// Enough for several pages. With only two, the middle of the window never
|
||||
// leaves the first one — a window is shorter than a page — so the check would
|
||||
// be testing arithmetic rather than behaviour.
|
||||
for (let i = 0; i < 220; i++) {
|
||||
await p.keyboard.type(`Absatz ${i + 1}: Dichtungen altern durch Wärme.`);
|
||||
await p.keyboard.press("Enter");
|
||||
}
|
||||
/* Enough for several pages. With only two, the middle of the window never
|
||||
leaves the first one — a window is shorter than a page — so the check would
|
||||
be testing arithmetic rather than behaviour.
|
||||
Pasted rather than typed: at the speed a robot types, a long note loses
|
||||
keystrokes between the editor and the pagination measuring after each one,
|
||||
and the page count then measures typing throughput instead of pagination.
|
||||
(At any human speed nothing is lost — checked at 50 characters a second.) */
|
||||
await p.keyboard.type("Absatz 1: Dichtungen altern durch Wärme.");
|
||||
await p.keyboard.press("Enter");
|
||||
await p.evaluate((text) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData("text/plain", text);
|
||||
document
|
||||
.querySelector(".ProseMirror")
|
||||
.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||
}, Array.from({ length: 200 }, (_, i) => `Absatz ${i + 2}: Dichtungen altern durch Wärme.`).join("\n"));
|
||||
await p.waitForTimeout(3500);
|
||||
|
||||
const position = () => p.locator("[data-note-position]").innerText();
|
||||
@@ -295,6 +304,51 @@ try {
|
||||
await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)
|
||||
);
|
||||
|
||||
/* ── the marker pen and the ink ──
|
||||
* Two marks the schema had no room for until now, so the thing worth checking
|
||||
* is that they reach the document and come back out of it: a colour that only
|
||||
* exists on screen is a colour that is lost on the next reload. */
|
||||
await createNote(p);
|
||||
await setTitle(p, "Farbe");
|
||||
await typeInEditor(p, "Wichtig und bunt");
|
||||
await p.waitForTimeout(1200);
|
||||
|
||||
// Select the whole line, the way somebody marks a sentence they just wrote.
|
||||
await p.locator(".ProseMirror").click();
|
||||
await p.keyboard.press("Control+a");
|
||||
await p.locator("[data-toolbar-colour]").click();
|
||||
await p.waitForTimeout(400);
|
||||
check((await p.locator("[data-colour-panel]").count()) === 1, "the toolbar offers colour");
|
||||
const swatches = await p.locator("[data-colour-swatch]").count();
|
||||
check(swatches >= 8, "with a palette for both kinds", `${swatches} swatches`);
|
||||
|
||||
await p.locator('[data-colour-swatch="#fff3a3"]').click();
|
||||
await p.waitForTimeout(1600);
|
||||
check((await p.locator(".ProseMirror mark").count()) === 1, "a highlight lands on the selected words");
|
||||
|
||||
await p.keyboard.press("Control+a");
|
||||
await p.locator('[data-colour-swatch="#d1242f"]').click();
|
||||
await p.waitForTimeout(1800);
|
||||
const inked = await p.locator(".ProseMirror mark span").first().evaluate((el) => getComputedStyle(el).color);
|
||||
check(inked === "rgb(209, 36, 47)", "and a text colour on top of it", inked);
|
||||
|
||||
await reopenNote(p, "Farbe");
|
||||
check((await p.locator(".ProseMirror mark").count()) === 1, "the highlight is still there after a reload");
|
||||
check(
|
||||
(await p.locator(".ProseMirror [style*=color]").count()) >= 1,
|
||||
"and so is the colour of the letters"
|
||||
);
|
||||
|
||||
/* Pressing the same colour again takes it off — the button that put it there
|
||||
is the one people reach for to remove it. */
|
||||
await p.locator(".ProseMirror").click();
|
||||
await p.keyboard.press("Control+a");
|
||||
await p.locator("[data-toolbar-colour]").click();
|
||||
await p.waitForTimeout(400);
|
||||
await p.locator('[data-colour-swatch="#fff3a3"]').click();
|
||||
await p.waitForTimeout(1500);
|
||||
check((await p.locator(".ProseMirror mark").count()) === 0, "and pressing the same colour again removes it");
|
||||
|
||||
/* ── a quote that holds more than a sentence ──
|
||||
* Markdown lets a quote hold whole blocks, and so does the editor. What it did
|
||||
* not do was give them any room: inside a quote nothing had a margin, so a
|
||||
|
||||
+20
-4
@@ -27,12 +27,28 @@ try {
|
||||
await typeInEditor(p, LONG);
|
||||
check((await p.locator(".ProseMirror").innerText()).includes("erste Entwurf"), "the long draft is in the note");
|
||||
|
||||
/* The note by its name, and not before the server has it.
|
||||
*
|
||||
* This used to take the first row of `/api/notes` the moment the text was
|
||||
* typed, which is two assumptions: that the first save had already landed, and
|
||||
* that the newest note is the one meant. Under load neither holds — the id
|
||||
* belonged to another note, and the version this suite is about was then
|
||||
* looked for in the wrong place. */
|
||||
const id = await p.evaluate(async () => {
|
||||
const r = await fetch("/api/notes", {
|
||||
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
|
||||
});
|
||||
return (await r.json())[0].id;
|
||||
const mine = async () => {
|
||||
const r = await fetch("/api/notes", {
|
||||
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
|
||||
});
|
||||
return (await r.json()).find((n) => n.title === "Mit Verlauf");
|
||||
};
|
||||
for (let tries = 0; tries < 20; tries++) {
|
||||
const note = await mine();
|
||||
if (note) return note.id;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
check(!!id, "the note reached the server", String(id));
|
||||
|
||||
/* Replace it with a stub. Shrinking a note by more than half is treated as a
|
||||
* large deletion and bypasses the 5-minute coalescing window, so a version is
|
||||
|
||||
+43
@@ -270,6 +270,49 @@ try {
|
||||
await p.getByTitle("Zoom out").click();
|
||||
await p.waitForTimeout(1500);
|
||||
|
||||
/* ── the document, and nothing else ──
|
||||
* Two things at once: the browser's own full screen, which puts the page past
|
||||
* the window frame, and an overlay over the app, which is what actually
|
||||
* removes the sidebar, the list and the title bar. The second is the half that
|
||||
* can be measured here — and the half a browser can refuse. */
|
||||
const workspaceBox = () =>
|
||||
p.locator("[data-pdf-workspace]").evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
|
||||
});
|
||||
const windowBox = () => p.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }));
|
||||
|
||||
const normal = await workspaceBox();
|
||||
check(normal.y > 0 || normal.x > 0, "in the ordinary layout the document shares the screen", JSON.stringify(normal));
|
||||
|
||||
await p.locator("[data-pdf-fullscreen]").click();
|
||||
await p.waitForTimeout(1200);
|
||||
const filled = await workspaceBox();
|
||||
const win = await windowBox();
|
||||
check(
|
||||
filled.x === 0 && filled.y === 0 && filled.w >= win.w - 1 && filled.h >= win.h - 1,
|
||||
"full screen leaves the document the whole window",
|
||||
`${JSON.stringify(filled)} vs ${JSON.stringify(win)}`
|
||||
);
|
||||
check(
|
||||
(await p.locator("[data-pdf-position]").count()) === 1,
|
||||
"with its toolbar still there — the page number, the zoom, the way out"
|
||||
);
|
||||
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(1000);
|
||||
const back = await workspaceBox();
|
||||
check(back.y === normal.y && back.x === normal.x, "and Escape gives the app back", JSON.stringify(back));
|
||||
|
||||
await p.locator("[data-pdf-fullscreen]").click();
|
||||
await p.waitForTimeout(1000);
|
||||
await p.locator("[data-pdf-fullscreen]").click();
|
||||
await p.waitForTimeout(1000);
|
||||
check(
|
||||
(await workspaceBox()).y === normal.y,
|
||||
"and so does pressing the button again — the way out is visible, not only known"
|
||||
);
|
||||
|
||||
/* ── Ctrl and the wheel ──
|
||||
* The claim is not only "the number changes". It is that the browser is kept
|
||||
* out of it — an unprevented Ctrl+wheel zooms the whole application, toolbar
|
||||
|
||||
+1
-1
@@ -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 oidc editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync)
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin oidc shell editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync)
|
||||
|
||||
mkdir -p shots
|
||||
if [ ! -d node_modules ]; then
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/* The frame around the writing: the dashboard, the sidebar on a phone, and the
|
||||
* paper itself.
|
||||
*
|
||||
* These are the things that are only ever wrong on a screen — a heading in the
|
||||
* wrong order, a page you cannot see the edge of, a tap that does nothing — so
|
||||
* they are checked in a browser at the two sizes they are used at.
|
||||
*/
|
||||
import { BASE, check, createNote, createNotebook, launch, report, setTitle, typeInEditor, withFreshUser } from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
let session;
|
||||
try {
|
||||
session = await withFreshUser(browser, "shell");
|
||||
const p = session.page;
|
||||
|
||||
await createNotebook(p, "Rahmen");
|
||||
await createNote(p);
|
||||
await setTitle(p, "Zuerst");
|
||||
await typeInEditor(p, "Ein kurzer Satz.");
|
||||
await p.waitForTimeout(1600);
|
||||
await createNote(p);
|
||||
await setTitle(p, "Zuletzt");
|
||||
await typeInEditor(p, "Der jüngere.");
|
||||
await p.waitForTimeout(1800);
|
||||
|
||||
/* ── the dashboard ──
|
||||
* What you were last writing is what you usually came back for; the shelf of
|
||||
* notebooks is for choosing where to go next. */
|
||||
await p.locator("button").filter({ hasText: "Dashboard" }).first().click();
|
||||
await p.waitForTimeout(1500);
|
||||
const headings = await p.evaluate(() => [...document.querySelectorAll("h1,h2")].map((h) => h.innerText.trim()));
|
||||
check(
|
||||
headings.indexOf("Recent Notes") < headings.indexOf("My Notebooks"),
|
||||
"the recent notes come before the notebooks",
|
||||
headings.join(" | ")
|
||||
);
|
||||
|
||||
/* "Recent" is a question about time, and it has to answer it whatever order
|
||||
the notebook it came from happens to be in — which is a hand-made one here. */
|
||||
const recent = await p.evaluate(() => {
|
||||
const head = [...document.querySelectorAll("h2")].find((h) => h.innerText.trim() === "Recent Notes");
|
||||
return [...head.nextElementSibling.querySelectorAll("button")].map((b) => b.innerText.split("\n")[0].trim());
|
||||
});
|
||||
check(recent[0] === "Zuletzt", "and the most recently edited is first", recent.join(" | "));
|
||||
|
||||
/* ── tapping the blank part of the page ──
|
||||
* The text is only as tall as what has been written, so most of a fresh page
|
||||
* is beside the editable area rather than in it. A tap there used to land on
|
||||
* nothing: no caret, and on a phone no keyboard. */
|
||||
await p.locator("button").filter({ hasText: "Rahmen" }).first().click();
|
||||
await p.waitForTimeout(1200);
|
||||
await p.locator("[data-note-row]").first().click();
|
||||
await p.waitForTimeout(2200);
|
||||
const before = await p.locator(".ProseMirror").innerText();
|
||||
const sheet = await p.locator("[data-page-sheet]").first().boundingBox();
|
||||
// Below the writing and inside the window — the sheet is taller than both.
|
||||
await p.mouse.click(sheet.x + sheet.width / 2, Math.min(sheet.y + sheet.height * 0.8, 900));
|
||||
await p.waitForTimeout(500);
|
||||
check(
|
||||
(await p.locator(".ProseMirror-focused").count()) === 1,
|
||||
"tapping the blank part of the page starts writing there"
|
||||
);
|
||||
await p.keyboard.type("ANGEHÄNGT");
|
||||
await p.waitForTimeout(900);
|
||||
const after = await p.locator(".ProseMirror").innerText();
|
||||
check(after.includes("ANGEHÄNGT") && after.startsWith(before.slice(0, 10)), "and what is typed lands in the note", after.slice(0, 60));
|
||||
|
||||
/* ── the phone ── */
|
||||
const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
const m = await phone.newPage();
|
||||
await m.goto(BASE, { waitUntil: "networkidle" });
|
||||
await m.getByPlaceholder("Username").fill(session.user);
|
||||
await m.getByPlaceholder("Password").fill(session.pass);
|
||||
await m.getByRole("button", { name: "Sign In" }).click();
|
||||
await m.waitForTimeout(4000);
|
||||
|
||||
/* Dragging in from the left edge shows the sidebar. The button that opens it
|
||||
sits in the top corner, which is the one part of a tall screen a thumb
|
||||
cannot reach. */
|
||||
const swipe = (from, to, y = 500) =>
|
||||
m.evaluate(
|
||||
([x1, x2, yy]) => {
|
||||
// Real Touch objects: a plain object is refused by the constructor.
|
||||
const touch = (x) => [new Touch({ identifier: 1, target: document.body, clientX: x, clientY: yy })];
|
||||
const fire = (type, x) =>
|
||||
document.dispatchEvent(
|
||||
new TouchEvent(type, { touches: type === "touchend" ? [] : touch(x), changedTouches: touch(x), bubbles: true })
|
||||
);
|
||||
fire("touchstart", x1);
|
||||
fire("touchmove", (x1 + x2) / 2);
|
||||
fire("touchmove", x2);
|
||||
fire("touchend", x2);
|
||||
},
|
||||
[from, to, y]
|
||||
);
|
||||
|
||||
/* Measured on the panel itself: closed, it is a strip of zero width with the
|
||||
contents clipped inside it, so the buttons in there still have a size and a
|
||||
place — asking one of them proves nothing. */
|
||||
const sidebarShown = async () =>
|
||||
(await m.locator("[data-sidebar]").evaluate((el) => el.getBoundingClientRect().width)) > 100;
|
||||
|
||||
check(!(await sidebarShown()), "on a phone the sidebar starts out of the way");
|
||||
|
||||
await swipe(4, 200);
|
||||
await m.waitForTimeout(900);
|
||||
check(await sidebarShown(), "a drag in from the left edge brings it out");
|
||||
|
||||
await swipe(200, 10);
|
||||
await m.waitForTimeout(900);
|
||||
check(!(await sidebarShown()), "and pushing it back sends it away again");
|
||||
|
||||
// A drag that starts in the middle of the page is not that gesture — it is a
|
||||
// selection, or a table being scrolled.
|
||||
await swipe(220, 340);
|
||||
await m.waitForTimeout(700);
|
||||
check(!(await sidebarShown()), "a drag that does not start at the edge leaves it alone");
|
||||
|
||||
/* The paper on a phone runs to the edges, but it has an edge — without one
|
||||
there was nothing to see where one page ended and the next began.
|
||||
Opened from the dashboard's own shelf: the sidebar holds a row of the same
|
||||
name, and while it is closed that row is clipped to nothing. */
|
||||
await m.locator("button").filter({ hasText: "Rahmen" }).last().click();
|
||||
await m.waitForTimeout(1800);
|
||||
await m.locator("[data-note-row]").first().click();
|
||||
await m.waitForTimeout(2500);
|
||||
const edge = await m.locator("[data-page-sheet]").first().evaluate((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return { width: cs.borderTopWidth, style: cs.borderTopStyle };
|
||||
});
|
||||
check(
|
||||
parseFloat(edge.width) >= 1 && edge.style === "solid",
|
||||
"the page has a visible edge on a phone too",
|
||||
JSON.stringify(edge)
|
||||
);
|
||||
await phone.close();
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.stack || e.message);
|
||||
} finally {
|
||||
await session?.cleanup?.();
|
||||
await browser.close();
|
||||
process.exit(report());
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useResponsive } from "./hooks/useResponsive";
|
||||
import { usePwaInstall } from "./hooks/usePwaInstall";
|
||||
import { useNotebookDnd } from "./hooks/useNotebookDnd";
|
||||
import { useNavigationHistory } from "./hooks/useNavigationHistory";
|
||||
import { useEdgeSwipe } from "./hooks/useEdgeSwipe";
|
||||
|
||||
import { CHAPTER_COLORS, DEFAULT_FOLDERS, SEPARATOR_STYLES, coverBackground } from "./ui/constants.jsx";
|
||||
import { I } from "./ui/icons.jsx";
|
||||
@@ -165,6 +166,15 @@ export default function App() {
|
||||
|
||||
const T = useMemo(() => makeTheme(dark), [dark]);
|
||||
const { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView } = useResponsive();
|
||||
|
||||
/* Dragging in from the left edge shows the sidebar, dragging it back hides it
|
||||
— only where it is an overlay, since on a desktop it is always there. */
|
||||
useEdgeSwipe({
|
||||
enabled: isMobile,
|
||||
isOpen: sidebarOpen,
|
||||
onOpen: useCallback(() => setSidebarOpen(true), [setSidebarOpen]),
|
||||
onClose: useCallback(() => setSidebarOpen(false), [setSidebarOpen]),
|
||||
});
|
||||
const { canInstall, reason: installReason, promptInstall } = usePwaInstall();
|
||||
|
||||
// Presentation only, and it lives on the account so it follows to the phone.
|
||||
|
||||
@@ -53,10 +53,32 @@ const AtIcon = () => (
|
||||
* Reads its active states straight off the editor, so the buttons reflect the
|
||||
* cursor even when formatting changed by keyboard shortcut.
|
||||
*/
|
||||
|
||||
/* What the marker pen offers, and what the letters can be.
|
||||
*
|
||||
* Highlights are pale enough for dark ink to sit on them — a marked word is
|
||||
* painted dark whatever the theme, the way a real marker leaves the writing
|
||||
* alone. The ink colours are the other way round: they must be legible on both
|
||||
* papers, so nothing paler than the mid tones.
|
||||
*/
|
||||
const HIGHLIGHTS = ["#fff3a3", "#c6f6c2", "#bfe3ff", "#ffc9de", "#ffd8a8", "#e2d4ff"];
|
||||
const INK_COLOURS = ["#d1242f", "#bc4c00", "#1a7f37", "#0969da", "#8250df", "#6e7781"];
|
||||
|
||||
/** Two overlapping drops: colour, without saying which of the two kinds. */
|
||||
function ColourIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 2.5c1.8 2 3 3.6 3 5a3 3 0 0 1-6 0c0-1.4 1.2-3 3-5Z" />
|
||||
<path d="M3 13.5h10" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorToolbar({ editor, T, icons, compact, dark }) {
|
||||
const [, force] = useState(0);
|
||||
const [showTable, setShowTable] = useState(false);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [showColour, setShowColour] = useState(false);
|
||||
const [linkDialog, setLinkDialog] = useState(null);
|
||||
|
||||
// ProseMirror state changes do not go through React; subscribe so the active
|
||||
@@ -81,6 +103,15 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
|
||||
return () => document.removeEventListener("pointerdown", close);
|
||||
}, [showTable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showColour) return undefined;
|
||||
const close = (e) => {
|
||||
if (!e.target.closest?.("[data-colour-menu]")) setShowColour(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", close);
|
||||
return () => document.removeEventListener("pointerdown", close);
|
||||
}, [showColour]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMore) return;
|
||||
const close = (e) => {
|
||||
@@ -155,6 +186,108 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
|
||||
>
|
||||
{icons.underline}
|
||||
</button>
|
||||
|
||||
{/* Colour, both kinds, behind one button.
|
||||
Two buttons would be two panels for one question — "what colour should
|
||||
this be" — and the answer is nearly always one or the other, never both
|
||||
at once. The panel says which is which. */}
|
||||
<div style={{ position: "relative" }} data-colour-menu>
|
||||
<button
|
||||
style={btn(editor.isActive("highlight") || editor.isActive("textColor") || showColour)}
|
||||
title="Highlight and text colour"
|
||||
data-toolbar-colour={showColour ? "on" : "off"}
|
||||
onClick={() => setShowColour((v) => !v)}
|
||||
>
|
||||
<ColourIcon />
|
||||
</button>
|
||||
{showColour && (
|
||||
<div
|
||||
data-colour-panel
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 36,
|
||||
left: 0,
|
||||
width: 212,
|
||||
background: T.toolbar,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
zIndex: 200,
|
||||
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
["Highlight", HIGHLIGHTS, "highlight", (c) => editor.chain().focus().toggleHighlight(c).run(), () => editor.chain().focus().unsetHighlight().run()],
|
||||
["Text", INK_COLOURS, "textColor", (c) => editor.chain().focus().toggleTextColor(c).run(), () => editor.chain().focus().unsetTextColor().run()],
|
||||
].map(([label, palette, markName, apply, clear]) => (
|
||||
<div key={label} style={{ marginBottom: label === "Highlight" ? 10 : 0 }}>
|
||||
{/* Deliberately not shaped like the swatches: a heading built
|
||||
like the things under it is a button that does nothing. */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.7,
|
||||
textTransform: "uppercase",
|
||||
color: T.muted,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{palette.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
data-colour-swatch={c}
|
||||
title={c}
|
||||
onClick={() => apply(c)}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 7,
|
||||
cursor: "pointer",
|
||||
border: editor.isActive(markName, { color: c })
|
||||
? `2px solid ${T.active}`
|
||||
: `1px solid ${T.border}`,
|
||||
background: markName === "highlight" ? c : "transparent",
|
||||
color: c,
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
lineHeight: 1,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{markName === "highlight" ? "" : "A"}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-colour-clear={markName}
|
||||
title={`No ${label.toLowerCase()}`}
|
||||
onClick={() => clear()}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 7,
|
||||
cursor: "pointer",
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
color: T.muted,
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
—
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{divider}
|
||||
<button
|
||||
style={btn(editor.isActive("bulletList"))}
|
||||
|
||||
@@ -136,6 +136,15 @@
|
||||
.note-editor .ProseMirror blockquote > * + * {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
/* A marker pen leaves the writing alone: dark ink on the colour whatever the
|
||||
theme, or a light-grey word on pale yellow would be unreadable on the dark
|
||||
one. An explicit text colour is an inline style and still wins over this. */
|
||||
.note-editor .ProseMirror mark {
|
||||
background-color: #fff3a3;
|
||||
color: #1f2328;
|
||||
padding: 0 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.note-editor .ProseMirror code {
|
||||
background: var(--ne-subtle);
|
||||
border: 1px solid var(--ne-border);
|
||||
|
||||
@@ -13,6 +13,7 @@ import TableCell from "@tiptap/extension-table-cell";
|
||||
|
||||
import { NoteLink } from "./noteLink.js";
|
||||
import { TextAlign } from "./textAlign.js";
|
||||
import { Highlight, TextColor } from "./textMarks.js";
|
||||
import { Pagination } from "./pagination.js";
|
||||
import { AttachmentImage } from "./attachmentImage.js";
|
||||
|
||||
@@ -48,6 +49,10 @@ export function buildExtensions({
|
||||
TableCell,
|
||||
NoteLink.configure({ search }),
|
||||
TextAlign.configure({ types: ["paragraph", "heading"] }),
|
||||
// A marker pen and a colour for the letters. Marks rather than a node, so
|
||||
// they nest with bold and italic like every other emphasis.
|
||||
Highlight,
|
||||
TextColor,
|
||||
Pagination.configure({ getGeometry }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||
|
||||
/*
|
||||
* Two ways to colour a word: behind it, and on it.
|
||||
*
|
||||
* Written here rather than installed, for the same reason the alignment
|
||||
* extension is: the schema of a note decides what can be stored in it forever,
|
||||
* and that belongs in files this project owns. Both are a few dozen lines,
|
||||
* because a mark with one attribute is a few dozen lines.
|
||||
*
|
||||
* Neither keeps a list of allowed colours in the schema. The palette is an
|
||||
* interface question — the toolbar offers what it offers — while the document
|
||||
* only has to be able to hold what was chosen, including a colour picked before
|
||||
* the palette last changed.
|
||||
*/
|
||||
|
||||
/** Anything that could be a CSS colour and nothing that could be a stylesheet. */
|
||||
const cleanColour = (value) => {
|
||||
const v = String(value ?? "").trim();
|
||||
return /^(#[0-9a-fA-F]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/]+\)|[a-zA-Z]{3,20})$/.test(v) ? v : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A marker pen: colour behind the text.
|
||||
*
|
||||
* `<mark>` rather than a span with a background, because that is the element
|
||||
* that means "highlighted" — it survives being pasted elsewhere, it is what a
|
||||
* screen reader announces, and it still reads as marked in a reader that drops
|
||||
* the colour.
|
||||
*/
|
||||
export const Highlight = Mark.create({
|
||||
name: "highlight",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
color: {
|
||||
default: null,
|
||||
parseHTML: (el) => cleanColour(el.getAttribute("data-color") || el.style.backgroundColor),
|
||||
renderHTML: (attrs) =>
|
||||
attrs.color
|
||||
? { "data-color": attrs.color, style: `background-color: ${attrs.color}` }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "mark" }, { style: "background-color", getAttrs: (v) => (cleanColour(v) ? {} : false) }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["mark", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setHighlight:
|
||||
(color) =>
|
||||
({ commands }) =>
|
||||
commands.setMark(this.name, { color: cleanColour(color) }),
|
||||
unsetHighlight:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.unsetMark(this.name),
|
||||
/* Pressing the colour a word already wears takes it off again. Without
|
||||
this the only way back is the eraser button, and the button that put it
|
||||
there is the one people press. */
|
||||
toggleHighlight:
|
||||
(color) =>
|
||||
({ editor, commands }) =>
|
||||
editor.isActive(this.name, { color: cleanColour(color) })
|
||||
? commands.unsetMark(this.name)
|
||||
: commands.setMark(this.name, { color: cleanColour(color) }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** Colour of the letters themselves. */
|
||||
export const TextColor = Mark.create({
|
||||
name: "textColor",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
color: {
|
||||
default: null,
|
||||
parseHTML: (el) => cleanColour(el.style.color),
|
||||
renderHTML: (attrs) => (attrs.color ? { style: `color: ${attrs.color}` } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
// Only a span that actually carries a colour: every other span in a pasted
|
||||
// document would otherwise become a mark with nothing in it.
|
||||
return [{ tag: "span[style*=color]", getAttrs: (el) => (cleanColour(el.style.color) ? {} : false) }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["span", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setTextColor:
|
||||
(color) =>
|
||||
({ commands }) =>
|
||||
commands.setMark(this.name, { color: cleanColour(color) }),
|
||||
unsetTextColor:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.unsetMark(this.name),
|
||||
toggleTextColor:
|
||||
(color) =>
|
||||
({ editor, commands }) =>
|
||||
editor.isActive(this.name, { color: cleanColour(color) })
|
||||
? commands.unsetMark(this.name)
|
||||
: commands.setMark(this.name, { color: cleanColour(color) }),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -207,9 +207,27 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
|
||||
e.preventDefault();
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Tapping the blank part of the sheet starts writing there.
|
||||
*
|
||||
* The text itself is only as tall as what has been written, so most of a
|
||||
* fresh page is *beside* the editable area rather than in it, and a tap
|
||||
* down there landed on nothing at all: no caret, and on a phone no
|
||||
* keyboard either. Which is precisely where somebody taps when they open
|
||||
* a note to jot something down.
|
||||
*
|
||||
* The nearest position to where the finger went, not simply the end: on a
|
||||
* page with several paragraphs, "nearest" is what the tap meant. Only the
|
||||
* end when the point is past everything, which is the empty-page case. */
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
if (e.target.closest(".ProseMirror")) return; // a real click in the text
|
||||
const at = editor.view.posAtCoords({ left: e.clientX, top: e.clientY });
|
||||
if (at) editor.chain().focus().setTextSelection(at.pos).run();
|
||||
else editor.chain().focus("end").run();
|
||||
},
|
||||
[onOpenNote]
|
||||
[editor, onOpenNote]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -218,7 +236,8 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
|
||||
data-theme={dark ? "dark" : "light"}
|
||||
data-font={font || "serif"}
|
||||
onClick={handleClick}
|
||||
style={{ minHeight }}
|
||||
// The whole sheet is somewhere to write, so it says so under the pointer.
|
||||
style={{ minHeight, cursor: "text" }}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,22 @@ describe("text and marks", () => {
|
||||
expect(md(doc(para(text("unter", [{ type: "underline" }]))))).toBe("<u>unter</u>");
|
||||
});
|
||||
|
||||
it("carries a highlight as <mark>, with its colour when it has one", () => {
|
||||
expect(md(doc(para(text("gelb", [{ type: "highlight", attrs: { color: "#fff3a3" } }]))))).toBe(
|
||||
'<mark style="background-color: #fff3a3">gelb</mark>'
|
||||
);
|
||||
// A marker without a colour is still a marker, and that is what most other
|
||||
// writers emit.
|
||||
expect(md(doc(para(text("markiert", [{ type: "highlight" }]))))).toBe("<mark>markiert</mark>");
|
||||
});
|
||||
|
||||
it("carries a text colour as a span, and nothing at all without one", () => {
|
||||
expect(md(doc(para(text("rot", [{ type: "textColor", attrs: { color: "#d1242f" } }]))))).toBe(
|
||||
'<span style="color: #d1242f">rot</span>'
|
||||
);
|
||||
expect(md(doc(para(text("schlicht", [{ type: "textColor" }]))))).toBe("schlicht");
|
||||
});
|
||||
|
||||
it("keeps a web link", () => {
|
||||
const out = md(doc(para(text("hier", [{ type: "link", attrs: { href: "https://example.com" } }]))));
|
||||
expect(out).toBe("[hier](https://example.com)");
|
||||
|
||||
@@ -87,6 +87,17 @@ function inlineNode(n: DocNode, o: MarkdownOptions): string {
|
||||
// Markdown has no underline; HTML is the least surprising stand-in.
|
||||
out = `<u>${out}</u>`;
|
||||
break;
|
||||
case "highlight":
|
||||
/* Markdown has no colour either. `<mark>` is the element that means
|
||||
* "highlighted", so a reader that drops the colour still shows the word
|
||||
* as marked rather than losing the emphasis altogether. */
|
||||
out = mark.attrs?.color
|
||||
? `<mark style="background-color: ${mark.attrs.color}">${out}</mark>`
|
||||
: `<mark>${out}</mark>`;
|
||||
break;
|
||||
case "textColor":
|
||||
out = mark.attrs?.color ? `<span style="color: ${mark.attrs.color}">${out}</span>` : out;
|
||||
break;
|
||||
case "link":
|
||||
out = `[${out}](${mark.attrs?.href || ""})`;
|
||||
break;
|
||||
|
||||
@@ -42,6 +42,10 @@ function inline(nodes: DocNode[] | undefined, resolveImage: (id: string) => stri
|
||||
else if (mark.type === "underline") html = `<u>${html}</u>`;
|
||||
else if (mark.type === "strike") html = `<s>${html}</s>`;
|
||||
else if (mark.type === "code") html = `<code>${html}</code>`;
|
||||
else if (mark.type === "highlight")
|
||||
html = `<mark${mark.attrs?.color ? ` style="background-color: ${esc(mark.attrs.color)}"` : ""}>${html}</mark>`;
|
||||
else if (mark.type === "textColor" && mark.attrs?.color)
|
||||
html = `<span style="color: ${esc(mark.attrs.color)}">${html}</span>`;
|
||||
else if (mark.type === "link") html = `<a href="${esc(mark.attrs?.href)}">${html}</a>`;
|
||||
}
|
||||
return html;
|
||||
@@ -136,6 +140,10 @@ const STYLES = `
|
||||
pre { background: #f4f4f4; padding: 3mm; border-radius: 2mm; overflow-wrap: anywhere;
|
||||
white-space: pre-wrap; font-size: 9.5pt; }
|
||||
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 9.5pt; }
|
||||
/* Dark ink on a highlight whatever the page colour, the way a marker leaves
|
||||
the writing alone — and printed at all, which browsers skip by default. */
|
||||
mark { background-color: #fff3a3; color: #111; padding: 0 1px;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 0 0 3mm; page-break-inside: avoid; }
|
||||
th, td { border: 1px solid #bbb; padding: 1.6mm 2.2mm; text-align: left; vertical-align: top; }
|
||||
th { background: #f2f2f2; font-weight: 600; }
|
||||
|
||||
@@ -7,6 +7,17 @@ import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||
const RECENT_COUNT = 6;
|
||||
|
||||
/** Landing view: every notebook as a cover, plus the notes touched most recently. */
|
||||
/**
|
||||
* The six most recently edited, whatever order the list arrives in.
|
||||
*
|
||||
* `activeNotes` carries whichever arrangement the notebook it came from was in —
|
||||
* a hand-made order, or a title sort — and under this heading that is simply the
|
||||
* wrong list: "Recent" is a question about time, and it must answer it even when
|
||||
* every notebook is arranged by hand.
|
||||
*/
|
||||
const byLastEdited = (notes, count) =>
|
||||
[...notes].sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))).slice(0, count);
|
||||
|
||||
export function Dashboard({
|
||||
T,
|
||||
isMobile,
|
||||
@@ -20,6 +31,7 @@ export function Dashboard({
|
||||
onNotebookContextMenu,
|
||||
dnd,
|
||||
}) {
|
||||
const recentlyEdited = byLastEdited(activeNotes, RECENT_COUNT);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -66,11 +78,112 @@ export function Dashboard({
|
||||
fontFamily: FONT_SERIF,
|
||||
}}
|
||||
>
|
||||
My Notebooks
|
||||
Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: isMobile ? "0 16px 24px" : "0 36px 36px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
marginBottom: 14,
|
||||
fontFamily: FONT_SERIF,
|
||||
}}
|
||||
>
|
||||
Recent Notes
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill,minmax(260px,1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{recentlyEdited.map((note) => {
|
||||
const nb = notebooks.find((b) => b.id === note.folderId);
|
||||
return (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => onOpenNote(note)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "14px 16px",
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.card,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6, width: "100%" }}
|
||||
>
|
||||
{nb && (
|
||||
<div
|
||||
style={{
|
||||
width: 14,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
background: coverBackground(nb.cover, nb.coverPattern),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.starred && <span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6 }}>
|
||||
<span style={{ fontSize: 11, color: T.faint }}>{fmtDate(note.updatedAt)}</span>
|
||||
{nb && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: T.faint,
|
||||
background: T.accentSoft,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{nb.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The covers come second now. What you were last writing is what you
|
||||
usually came back for; the shelf is for choosing where to go next. */}
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
margin: 0,
|
||||
padding: isMobile ? "0 16px 14px" : "0 36px 14px",
|
||||
fontFamily: FONT_SERIF,
|
||||
}}
|
||||
>
|
||||
My Notebooks
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
padding: isMobile ? "0 16px 24px" : "0 36px 32px",
|
||||
@@ -142,93 +255,6 @@ export function Dashboard({
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: isMobile ? "0 16px 24px" : "0 36px 36px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
marginBottom: 14,
|
||||
fontFamily: FONT_SERIF,
|
||||
}}
|
||||
>
|
||||
Recent Notes
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill,minmax(260px,1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{activeNotes.slice(0, RECENT_COUNT).map((note) => {
|
||||
const nb = notebooks.find((b) => b.id === note.folderId);
|
||||
return (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => onOpenNote(note)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "14px 16px",
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.card,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6, width: "100%" }}
|
||||
>
|
||||
{nb && (
|
||||
<div
|
||||
style={{
|
||||
width: 14,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
background: coverBackground(nb.cover, nb.coverPattern),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.starred && <span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6 }}>
|
||||
<span style={{ fontSize: 11, color: T.faint }}>{fmtDate(note.updatedAt)}</span>
|
||||
{nb && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: T.faint,
|
||||
background: T.accentSoft,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{nb.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -828,9 +828,13 @@ export function NoteEditor({
|
||||
height: stageSize.h,
|
||||
pointerEvents: "none",
|
||||
background: spare ? "transparent" : T.bg,
|
||||
border: isMobileViewport && !spare ? "none" : `1px dashed ${T.border}`,
|
||||
/* On a phone the sheet used to be drawn with no edge at all:
|
||||
full width, no border, no shadow — which left nothing to
|
||||
see where one page ended and the next began. The paper
|
||||
still runs to the edges there, but it has an outline, so
|
||||
the division is visible. */
|
||||
borderStyle: spare ? "dashed" : "solid",
|
||||
borderWidth: isMobileViewport && !spare ? 0 : 1,
|
||||
borderWidth: 1,
|
||||
borderColor: T.border,
|
||||
borderRadius: isMobileViewport && !spare ? 0 : 4,
|
||||
boxShadow: isMobileViewport || spare ? "none" : "0 2px 10px rgba(0,0,0,0.22)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { PdfViewer } from "./PdfViewer.jsx";
|
||||
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
|
||||
@@ -7,6 +7,18 @@ import { allPinnedNotes, asPdfStrokeDoc } from "./pageStrokes";
|
||||
|
||||
const MOBILE_BREAKPOINT = 900;
|
||||
|
||||
/** Four corners pushed out, and the same four pulled back in. */
|
||||
const ExpandIcon = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
|
||||
</svg>
|
||||
);
|
||||
const ShrinkIcon = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M2 6h4V2M14 6h-4V2M2 10h4v4M14 10h-4v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* A PDF and the notes taken beside it.
|
||||
*
|
||||
@@ -30,6 +42,8 @@ export function PdfWorkspace({
|
||||
resolveLinkIcon,
|
||||
}) {
|
||||
const [showNotes, setShowNotes] = useState(false);
|
||||
const [full, setFull] = useState(false);
|
||||
const rootRef = useRef(null);
|
||||
// Set when a link in the notes points at a marker; the viewer clears it once
|
||||
// it has scrolled there.
|
||||
const [focusPin, setFocusPin] = useState(null);
|
||||
@@ -45,8 +59,52 @@ export function PdfWorkspace({
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
// The same paper-coloured ground the viewer lays its pages on, so filling
|
||||
// the screen does not put a strip of app colour around the document.
|
||||
bg: dark ? "#0d1117" : "#f0f2f5",
|
||||
};
|
||||
|
||||
/* ── the document, and nothing else ──
|
||||
*
|
||||
* Two things at once, because either alone is half a job. The browser's own
|
||||
* full screen puts the document past the address bar and the window frame,
|
||||
* which is the point of asking on a laptop or a tablet; and the app is laid
|
||||
* out as an overlay over itself, which is what actually removes the sidebar,
|
||||
* the note list and the title bar. A browser that refuses the first — an
|
||||
* iPhone does — still gets the second.
|
||||
*/
|
||||
const enterFull = () => {
|
||||
setFull(true);
|
||||
rootRef.current?.requestFullscreen?.().catch(() => {
|
||||
/* refused or unavailable: the overlay is the whole feature anyway */
|
||||
});
|
||||
};
|
||||
const leaveFull = useCallback(() => {
|
||||
setFull(false);
|
||||
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
||||
}, []);
|
||||
|
||||
/* Leaving the browser's full screen — with its own Escape, with F11, by any
|
||||
route — must take the overlay with it, or the app would be left pretending
|
||||
to be full screen inside an ordinary window. */
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
if (!document.fullscreenElement) setFull(false);
|
||||
};
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
}, []);
|
||||
|
||||
/* And Escape works even where there was never a browser full screen to leave. */
|
||||
useEffect(() => {
|
||||
if (!full) return undefined;
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") leaveFull();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [full, leaveFull]);
|
||||
|
||||
// On a narrow screen the two are alternatives, not neighbours.
|
||||
const showPdf = !narrow || !showNotes;
|
||||
|
||||
@@ -99,6 +157,22 @@ export function PdfWorkspace({
|
||||
[narrow, onOpenNote]
|
||||
);
|
||||
|
||||
const barButton = (on) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
height: 28,
|
||||
padding: "0 10px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: on ? T.accent + "22" : "transparent",
|
||||
color: on ? T.accent : T.muted,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
const notesToggle = (
|
||||
<button
|
||||
data-pdf-toggle-notes={showNotes ? "on" : "off"}
|
||||
@@ -124,15 +198,44 @@ export function PdfWorkspace({
|
||||
</button>
|
||||
);
|
||||
|
||||
/* Both controls in the viewer's own toolbar, beside the zoom — a second row
|
||||
for two buttons would cost a strip of reading height on every screen. */
|
||||
const toolbarExtras = (
|
||||
<>
|
||||
<button
|
||||
data-pdf-fullscreen={full ? "on" : "off"}
|
||||
onClick={() => (full ? leaveFull() : enterFull())}
|
||||
title={full ? "Leave full screen (Esc)" : "Full screen"}
|
||||
style={barButton(full)}
|
||||
>
|
||||
{full ? <ShrinkIcon /> : <ExpandIcon />}
|
||||
</button>
|
||||
{notesToggle}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0, minWidth: 0 }}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-pdf-workspace
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
...(full
|
||||
? { position: "fixed", inset: 0, zIndex: 3000, background: T.bg }
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
{showPdf && (
|
||||
<div style={{ flex: 1, display: "flex", minWidth: 0, minHeight: 0 }}>
|
||||
<PdfViewer
|
||||
note={note}
|
||||
dark={dark}
|
||||
onUpdate={onUpdate}
|
||||
notesToggle={notesToggle}
|
||||
notesToggle={toolbarExtras}
|
||||
focusPin={focusPin}
|
||||
onFocusHandled={() => setFocusPin(null)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/** How close to the left edge a swipe must start to count as opening one. */
|
||||
const EDGE_PX = 28;
|
||||
/** How far it must travel sideways before it is a swipe rather than a tap. */
|
||||
const TRAVEL_PX = 60;
|
||||
/** And how straight: past this much vertical movement it is a scroll. */
|
||||
const SLOPE = 1.2;
|
||||
|
||||
interface EdgeSwipe {
|
||||
enabled: boolean;
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dragging in from the left edge shows the sidebar; dragging it back hides it.
|
||||
*
|
||||
* The gesture every app on a phone has, and the reason it has to be a gesture
|
||||
* here too: the button that opens the sidebar sits in the top corner, which is
|
||||
* the one part of a tall screen a thumb cannot reach.
|
||||
*
|
||||
* Three rules keep it from firing by accident, and each is there for something
|
||||
* that went wrong without it:
|
||||
*
|
||||
* • it must **start at the edge**, or every sideways drag inside a note — a
|
||||
* text selection, a table being scrolled — would open the sidebar;
|
||||
* • it must travel far enough to be meant, or a tap that slides by two pixels
|
||||
* counts;
|
||||
* • it must be more sideways than downwards, or scrolling a long note with a
|
||||
* thumb near the edge opens it constantly.
|
||||
*
|
||||
* Listening on `document` rather than on the sidebar: the gesture starts where
|
||||
* the sidebar is *not*, which is the whole point of it.
|
||||
*/
|
||||
export function useEdgeSwipe({ enabled, isOpen, onOpen, onClose }: EdgeSwipe): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let tracking = false;
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return; // a pinch is not a swipe
|
||||
const t = e.touches[0];
|
||||
startX = t.clientX;
|
||||
startY = t.clientY;
|
||||
// Closed: only from the edge. Open: anywhere on the panel, because the
|
||||
// whole panel is the thing being pushed back.
|
||||
tracking = isOpen ? startX < window.innerWidth * 0.8 : startX <= EDGE_PX;
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (!tracking || e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - startX;
|
||||
const dy = t.clientY - startY;
|
||||
if (Math.abs(dy) > Math.abs(dx) * SLOPE) {
|
||||
tracking = false; // a scroll, not a swipe
|
||||
return;
|
||||
}
|
||||
if (!isOpen && dx > TRAVEL_PX) {
|
||||
tracking = false;
|
||||
onOpen();
|
||||
} else if (isOpen && dx < -TRAVEL_PX) {
|
||||
tracking = false;
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
tracking = false;
|
||||
};
|
||||
|
||||
// Passive: this never calls preventDefault — the page must keep scrolling
|
||||
// normally while the gesture is being judged.
|
||||
document.addEventListener("touchstart", onStart, { passive: true });
|
||||
document.addEventListener("touchmove", onMove, { passive: true });
|
||||
document.addEventListener("touchend", stop, { passive: true });
|
||||
document.addEventListener("touchcancel", stop, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener("touchstart", onStart);
|
||||
document.removeEventListener("touchmove", onMove);
|
||||
document.removeEventListener("touchend", stop);
|
||||
document.removeEventListener("touchcancel", stop);
|
||||
};
|
||||
}, [enabled, isOpen, onOpen, onClose]);
|
||||
}
|
||||
@@ -59,6 +59,51 @@ describe("blocks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("colour, which Markdown can only carry as HTML", () => {
|
||||
const marksOf = (md: string) => first(md).content![0]!.marks!;
|
||||
|
||||
it("reads a highlight back, with and without a colour", () => {
|
||||
expect(marksOf('<mark style="background-color: #fff3a3">gelb</mark>')).toMatchObject([
|
||||
{ type: "highlight", attrs: { color: "#fff3a3" } },
|
||||
]);
|
||||
expect(marksOf("<mark>markiert</mark>")[0]!.type).toBe("highlight");
|
||||
});
|
||||
|
||||
it("reads a text colour back", () => {
|
||||
expect(marksOf('<span style="color: #d1242f">rot</span>')).toMatchObject([
|
||||
{ type: "textColor", attrs: { color: "#d1242f" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a span that says nothing about colour alone", () => {
|
||||
// Every other span in a pasted document would otherwise become a mark with
|
||||
// nothing in it.
|
||||
expect(plain(first('<span class="x">schlicht</span>'))).toContain("schlicht");
|
||||
expect(first('<span class="x">schlicht</span>').content![0]!.marks ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("survives the round trip", () => {
|
||||
const there = (d: DocNode) => markdownToNote(noteToMarkdown(d)).doc;
|
||||
const source: DocNode = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "gelb", marks: [{ type: "highlight", attrs: { color: "#fff3a3" } }] } as DocNode,
|
||||
{ type: "text", text: " und " } as DocNode,
|
||||
{ type: "text", text: "rot", marks: [{ type: "textColor", attrs: { color: "#d1242f" } }] } as DocNode,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const back = there(source);
|
||||
const spans = back.content![0]!.content!;
|
||||
expect(spans[0]!.marks).toMatchObject([{ type: "highlight", attrs: { color: "#fff3a3" } }]);
|
||||
expect(spans[spans.length - 1]!.marks).toMatchObject([{ type: "textColor", attrs: { color: "#d1242f" } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lists", () => {
|
||||
it("reads a bullet list", () => {
|
||||
const list = first("- eins\n- zwei");
|
||||
|
||||
@@ -130,6 +130,31 @@ export function inlineNodes(src: string, marks: Mark[] = [], images?: string[]):
|
||||
i += u[0].length;
|
||||
continue;
|
||||
}
|
||||
/* The two colours, which Markdown can only carry as HTML — written by
|
||||
* this exporter, and readable enough that somebody might write them by
|
||||
* hand. The colour is optional: `<mark>` on its own is still a highlight,
|
||||
* and that is what most other writers emit. */
|
||||
const marker = /^<mark(?:\s[^>]*?)?>([\s\S]*?)<\/mark>/i.exec(src.slice(i));
|
||||
if (marker) {
|
||||
const colour = /background-color:\s*([^;"']+)/i.exec(marker[0]);
|
||||
flush();
|
||||
out.push(
|
||||
...inlineNodes(marker[1]!, [...marks, { type: "highlight", attrs: { color: colour ? colour[1]!.trim() : null } }], images)
|
||||
);
|
||||
i += marker[0].length;
|
||||
continue;
|
||||
}
|
||||
const coloured = /^<span\s[^>]*style=["'][^"']*\bcolor:\s*([^;"']+)[^"']*["'][^>]*>([\s\S]*?)<\/span>/i.exec(
|
||||
src.slice(i)
|
||||
);
|
||||
if (coloured) {
|
||||
flush();
|
||||
out.push(
|
||||
...inlineNodes(coloured[2]!, [...marks, { type: "textColor", attrs: { color: coloured[1]!.trim() } }], images)
|
||||
);
|
||||
i += coloured[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (c === "!" || c === "[") {
|
||||
|
||||
@@ -152,6 +152,8 @@ export function Sidebar({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sidebar
|
||||
data-sidebar-open={open ? "yes" : "no"}
|
||||
style={{
|
||||
position: isMobile ? "fixed" : "relative",
|
||||
zIndex: isMobile ? 900 : 1,
|
||||
|
||||
@@ -613,12 +613,13 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| Suite | Checks | What it holds down |
|
||||
| --- | --- | --- |
|
||||
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
|
||||
| `shell` | 9 | the frame around the writing: what the dashboard leads with, the sidebar answering a drag from the edge, a tap on blank paper putting the caret there, and the page having a visible edge on a phone |
|
||||
| `oidc` | 40 | the whole redirect journey against a provider that signs real tokens — and the four ways it must fail: a refusal, a replayed ticket, a token signed with an unpublished key, a token addressed to another client |
|
||||
| `editor` | 45 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
|
||||
| `editor` | 52 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
|
||||
| `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` | 22 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
|
||||
| `pdf` | 96 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `pdf` | 101 | 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` | 23 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
@@ -674,6 +675,26 @@ block a meaning, which only moved the problem — the whole sheet sat hard again
|
||||
the left edge. Centred, the same leftover space reads as two margins and needs no
|
||||
explaining.
|
||||
|
||||
**The dashboard leads with what you were last writing.** The six most recently
|
||||
edited notes come first and the shelf of notebooks second, because coming back to
|
||||
a note is the common case and choosing a notebook is the occasional one. "Recent"
|
||||
is sorted by time here whatever order the notebooks themselves are in — it is a
|
||||
question about time, and a hand-made arrangement is the wrong answer to it.
|
||||
|
||||
**Tapping the blank part of a page starts writing there.** The text is only as
|
||||
tall as what has been written, so most of a fresh page is *beside* the editable
|
||||
area rather than in it; a tap down there used to land on nothing at all — no
|
||||
caret, and on a phone no keyboard. The caret goes to the position nearest the
|
||||
tap, not simply to the end, because on a page with several paragraphs "nearest"
|
||||
is what the tap meant.
|
||||
|
||||
**Dragging in from the left edge shows the sidebar** on a phone or tablet, and
|
||||
dragging it back hides it. The button that opens it sits in the top corner, which
|
||||
is the one part of a tall screen a thumb cannot reach. The gesture has to start
|
||||
within 28px of the edge, travel 60px, and be more sideways than downwards —
|
||||
without the first rule every sideways drag inside a note opens it, and without
|
||||
the third, scrolling with a thumb near the edge does.
|
||||
|
||||
**The typeface is a per-account setting** (Account → Profile): serif, sans or
|
||||
mono. How a chapter opens is chosen there too, and light or dark, for the same
|
||||
reason — the sun used to sit in the corner of the sidebar, where it was reached
|
||||
@@ -702,6 +723,12 @@ itself. A `useLayoutEffect` runs between the DOM being built and the browser
|
||||
painting it, which is exactly the gap that flash lived in. A test samples the
|
||||
sheet's height every frame across a note switch, because one frame is all it was.
|
||||
|
||||
**The page has an edge on a phone too.** The paper there runs to the edges of the
|
||||
screen — a phone has no room for margins around a margin — but it used to be
|
||||
drawn with no border and no shadow at all, which left nothing to see where one
|
||||
page ended and the next began. It has an outline now; the shadow stays a desktop
|
||||
thing.
|
||||
|
||||
**A quote holds whole blocks.** A heading, a list and a closing line inside one
|
||||
are ordinary blocks of the document, and they are spaced like ordinary blocks:
|
||||
the rule that gives the writing its rhythm reaches only the editor's own
|
||||
@@ -824,6 +851,25 @@ to survive `document.execCommand`. Notes written before this were
|
||||
opened and rewritten in the new format on the next save, so nothing needs
|
||||
migrating up front.
|
||||
|
||||
### Colour, of two kinds
|
||||
|
||||
A marker pen and a colour for the letters, both written as marks in
|
||||
[`editor/textMarks.js`](frontend/src/editor/textMarks.js) rather than installed —
|
||||
the schema of a note decides what can be stored in it forever, and that belongs
|
||||
in files this project owns, the same reason the alignment extension is here.
|
||||
|
||||
A highlight is a `<mark>`: the element that *means* highlighted, so a reader that
|
||||
drops the colour still shows the word as marked. Marked text is painted with dark
|
||||
ink whatever the theme, the way a real marker leaves the writing alone — a
|
||||
light-grey word on pale yellow would be unreadable on the dark one. An explicit
|
||||
text colour is an inline style and still wins.
|
||||
|
||||
Both travel as HTML in Markdown, like underline, since Markdown has no colour,
|
||||
and both are read back — including a bare `<mark>` with no colour, which is what
|
||||
most other writers emit. Neither keeps its palette in the schema: what the
|
||||
toolbar offers is an interface question, while the document only has to hold what
|
||||
was chosen, including a colour picked before the palette last changed.
|
||||
|
||||
### Note links and backlinks
|
||||
|
||||
Typing `@` opens a search over notes and notebooks and inserts a `noteLink`
|
||||
@@ -880,6 +926,17 @@ page is expensive to draw and cheap to hold, so scrolling back up repaints
|
||||
nothing. Canvases are sized in device pixels and scaled down by CSS, which is
|
||||
what keeps text sharp on a high-density screen.
|
||||
|
||||
**Full screen is two things at once.** The button asks the browser for its own
|
||||
full screen — which is what puts the document past the address bar and the window
|
||||
frame, and the reason to want it on a laptop or a tablet — *and* lays the
|
||||
workspace out as an overlay over the app, which is what actually removes the
|
||||
sidebar, the note list and the title bar. Either alone is half the job, and a
|
||||
browser that refuses the first (an iPhone does) still gets the second. Escape
|
||||
leaves, and so does the button, which stays in the toolbar while full: a way out
|
||||
that is only known and not visible is not a way out. Leaving the browser's full
|
||||
screen by any other route takes the overlay with it, or the app would be left
|
||||
pretending to be full screen inside an ordinary window.
|
||||
|
||||
**Zooming holds something still.** A zoom changes how tall the document is, so
|
||||
the scroll position means something different afterwards — left alone, every step
|
||||
drifts and three presses of + leave you somewhere else in the file. What should
|
||||
|
||||
Reference in new issue
Block a user