Compare commits

...
23 Commits
Author SHA1 Message Date
marcandClaude Opus 5 4013e959d6 The same page on every screen
The text is laid out on a page of one fixed size and the sheet scaled to
the screen, so a phone breaks it exactly where a desktop does — twenty-one
lines to the page on both. The type size is computed from the page and the
line count rather than chosen.

A PDF's full screen is the browser window, not the screen, and its button
sits beside the zoom. "Recent Notes" is stamped by every write, not only by
a rename. And a PDF that will not open says which of the four reasons it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:23:03 +02:00
marcandClaude Opus 5 9507761bce 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>
2026-08-26 12:40:21 +02:00
marcandClaude Opus 5 c9148540a8 One entry for one day, and the right date on it
Another round went out today from elsewhere — the HoneyOS bar — and the PDF
work landed on top of it, so the images just pushed carry both. My entry
carried the date this long session started rather than the day it shipped,
and sat below the bar's as though it were older.

One entry for the day, as the file says: newest digests at the head, the
earlier pair named, both pieces of work under it unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:01:41 +02:00
marcandClaude Opus 5 580eed8992 Ctrl and the wheel over a PDF
The same gesture Aether has, whose comment already claimed InkNote had it.
Registered by hand rather than through onWheel, because React attaches wheel
listeners passively and a passive listener may not call preventDefault —
without which the browser zooms the whole application instead of the
document.

What stays still depends on how the zoom was asked for, and the two answers
differ: the wheel holds the point under the pointer, the buttons hold the
page being read. The buttons held nothing at all before, so three presses of
+ left you somewhere else in the file; that came out of building this and is
fixed with it.

The checks are the step, that the default really was prevented, and that a
point measured on the page is still under the pointer afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:00:54 +02:00
marcandClaude Opus 5 8f8fecfb96 docs(changelog): die Digests des ausgelieferten Standes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:00:42 +02:00
marcandClaude Opus 5 7c1bbc8176 refactor(honeybar): HONEYBAR_ORIGIN statt INKNOTE_HONEYBAR_ORIGIN
Dieselben Namen wie in Aether. Die Leiste gehoert keiner App, sie ist ueberall
dieselbe Sache — dann soll sie auch ueberall gleich heissen. Ein zweiter Name
fuer dieselbe Einstellung ist eine Fehlerquelle beim Umziehen und beim Suchen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:00:15 +02:00
marcandClaude Opus 5 00b1d4469f feat(honeybar): the HoneyOS bar, loaded at runtime
A strip along the top that leads to the other apps of the same system. It is
served by HoneyOS; InkNote only says where to find it, through
INKNOTE_HONEYBAR_ORIGIN. Empty means no bar and no change at all.

The origin is served by the API rather than built into the page, because an
image is pulled ready-made and a service worker keeps whole responses: anything
frozen into index.html would outlive the setting.

Layout follows the bar through --honeybar-h. The body has overflow:hidden, so
the surplus height would not have scrolled but been cut off at the bottom; and
#root padded itself around the notch that the bar now covers, which would have
counted that gap twice.

Unit 263/263, all fourteen browser suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:07:43 +02:00
marcandClaude Opus 5 2ea448f091 A bigger face, and the theme where the settings are
The picture fills the account button now — twenty pixels of a face is a
smudge — and light or dark moved out of the sidebar corner into Account →
Profile, next to the typeface and how a chapter opens, which is the kind of
thing it is.

Moving it made it worth keeping: it was pure screen state, so every reload
landed on dark whatever had been chosen. It is a setting on the account now,
like the typeface, with a copy in the browser so the page does not start in
one theme and turn to the other once the account arrives.

The check I first wrote for the sidebar losing its switch could not fail. It
counts the buttons in that corner instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 00:10:13 +02:00
marcandClaude Opus 5 ead51b9fa0 Hold down the place the picture is looked for
The suite proved a picture was drawn somewhere on the page. Where it belongs
is the account button at the top of the sidebar — the person icon's place —
so that is what the check now names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:24:45 +02:00
marcandClaude Opus 5 5163f87356 Repair a sentence stitched together badly
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:55:30 +02:00
marcandClaude Opus 5 1e2375f190 Ask for the picture the way providers actually offer it
Two assumptions, both mine, and either one was enough for an account to end
up with no picture. It was looked for only in the ID token, which a provider
is free to keep lean while holding the rest at userinfo — PocketID does
exactly that — and the endpoint was never asked. Then the image was fetched
with no credentials, while avatars are commonly behind the provider's own
login.

Userinfo is now asked when something is missing, and believed only when its
sub matches the token whose signature was checked. The access token rides
along with the picture request when, and only when, the picture lives at the
provider.

The third fault was the one that cost the time: it failed silently, so there
was no way to tell which of the two it was. Every outcome is a line in the
log now. And the test provider keeps its picture out of the token and behind
its door, so the check passing is evidence for both new paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:55:19 +02:00
marcandClaude Opus 5 a2b3f91e00 The face from the provider, and whose business a password is
The password tab was hidden for accounts with no password, which is not the
same as accounts that sign in through the provider — an account linked to one
kept its password and therefore kept the tab. That is precisely the account
the person setting this up is using. It goes by "signs in over there" now; the
password it kept is the emergency door, set by an administrator.

And the picture. The provider sends one, the server fetches it at each
sign-in and stores it encrypted in a table beside the users rather than in
them, and serves it back through the API — so the page never talks to the
provider, no third-party cookie is needed for a face to appear, and it
survives the provider being down like everything else here. Fetched once per
account into an object URL, since an img tag cannot carry a bearer token.

The suite's provider now serves a picture too, and the check asks the browser
whether it actually decoded rather than whether the element exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:30:58 +02:00
marcandClaude Opus 5 40bdf81aa2 Say which accounts belong to the provider, and to whom
"Has an identity at the provider" and "has no password here" are two
different questions, and the interface was answering the second while being
asked the first: an account that existed before and was linked — the usual
case for whoever sets this up — carried no mark at all in the administration
list.

The server now reports both. The list marks every linked account, says which
of them have no password, and offers to give one rather than to reset
something that is not there. The account panel names the provider it signs in
through, which is also the missing explanation for the password tab that
disappeared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:47:42 +02:00
marcandClaude Opus 5 cd70a8c8bb Name the failure the .env cannot fix
Compose passes only what its environment block names, so a docker-compose.yml
from before OIDC ignores the whole section in .env — and the boot log stays
silent, because a server with none of the variables set has nothing to report.
That combination reads as "I filled everything in and nothing happened", so it
belongs in the table with the command that settles it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:58:48 +02:00
marcandClaude Opus 5 26a48af620 Put the OIDC set-up where it can be read
The .env.example had grown a page of prose around seven variables, which is
the wrong place for it: that file is read while editing, one line at a time.
It now names the variables and points at the readme.

The readme carries the whole thing instead, worked through with PocketID: the
client at the provider, the settings, the two lines the boot log can print,
and a table of what each failure looks like from the outside — including the
one that catches everyone, a server container that cannot reach an issuer the
browser reaches perfectly well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:51:15 +02:00
marcandClaude Opus 5 43a98b0972 A second way in: signing in through an identity provider
OpenID Connect, off unless four settings are present. It is a second way to
the same token the password login mints — every route and the WebSocket
still know only a signed JWT — which is why it fits in one file and needs no
new dependency: Node imports a JWK itself, so checking an ID token is a
signature and five claims.

Authorization Code with PKCE, exchanged on the server so the client secret
stays there. The browser comes home with a one-time ticket rather than the
token, because a token in a redirect URL sits in the history, in the proxy's
log and in the referrer of the next request.

Somebody the app has not seen gets an account; an account that already
exists under that name is linked to the identity rather than duplicated, by
subject from then on. Never an administrator. An account made this way has
no password here, and the interface stops offering one — but an admin can
still set one, which is the way back in if the provider is ever gone.

The suite signs in at a provider of its own that signs real RS256 tokens,
and holds down the four ways the journey must fail as well as the way it
must work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:38:24 +02:00
marcandClaude Opus 5 c86c63922a Quotes that hold more than a sentence, and one list of marks
A quote may hold whole blocks, and they now have room: the rule that gives
the writing its rhythm reaches only the editor's own children, so inside a
quote a heading, a list and a closing line stood jammed together. Leaving as
Markdown they were written on consecutive lines, which every other reader
folds into one — the sentence after a quoted list became more of the last
bullet. They are divided by a quoted blank line now, the same rule the top
level has had since 11 August.

The marks were two copies of one list, in two dialogs, and only one of them
had ever been extended. One list, one grid, twice as many marks, divided
under headings — and anything else can be typed into the search box, which
on a phone is where the emoji keyboard lives.

The changelog is a third of its length: one entry per day rather than one
per image, since a round that repaired what an earlier round the same day
broke reads as noise afterwards. Every digest is still listed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:15:55 +02:00
marcandClaude Opus 5 c0523f3ba4 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:15:13 +02:00
marcandClaude Opus 5 641acaae40 Three things that only showed on screen
Grey letters ran down the bar while dragging a column edge. The editor marks
the column under the pointer by putting a small div in each of its cells;
drawn along the cell's right edge, that absolutely positioned child sent
Chromium down another painting path and the cell's own text came out a second
time, squeezed into the two pixels under the bar, one letter to a line. It
appeared with the bar and went with it, which named the culprit -- measured,
there was no text there at all, only three empty markers, and a forced repaint
did not clear it. The marker is display:none now and only says which cells the
column is made of; the line is an inset shadow on those cells, matched with
:has(). Two suspects were tried and reverted first: the table as its own paint
region, and separate borders. Neither changed anything, so neither stayed.

Opening a note flashed a long narrow page. The drawing stage starts at the
canonical page, 1600px tall, and the measurement that fits it to the pane ran
as an ordinary effect -- a frame too late. useLayoutEffect runs between the DOM
being built and the browser painting it, which is exactly the gap that flash
lived in. The test samples the sheet's height every frame across a note switch,
because one frame is all it was.

The mark above a note did not stand in the same column as the tools below it.
Vertically it was already exact -- measured, symbol centre 20, cap centre 20 --
but it began six pixels right of the first tool, and the title thirty. The
mark's box is only as wide as the mark now, so the mark lands on that column,
and the title follows nine pixels closer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:14:38 +02:00
marcandClaude Opus 5 1c0f2184e6 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:45:55 +02:00
marcandClaude Opus 5 3b7958b713 A chapter with an order of its own
A chapter orders its own notes now, chosen in the same dialog as its name,
mark and colour -- one decision about one thing. The notebook's setting
governs the notebook's own list; a chapter is a place someone made and
filled, so a reference chapter can stay alphabetical while the notebook
around it stays as it was arranged. It starts hand-made, and a chapter put
in one of the other orders loses its lines and its handles exactly as the
notebook does: neither means anything against an order the app is choosing.

A line still would not go under another line, and the comparator was only
half of it. The write was then thrown away by the "did anything change?"
test, which sorted the separators before comparing them -- to ignore the
other chapters' lines riding along in the same field -- and a sorted
comparison cannot see a sequence. Two lines in one gap carry the same
position, so swapping them changes nothing else. It compares this list's own
lines, in order.

An open chapter swelled while rearranging: the strips at the two ends of its
list stood open for the whole of the mode, adding half a finger of empty
space inside every chapter. They are thin now, and only there while
something is actually being carried -- which is also why the suite presses
first and aims afterwards.

Server: sort_mode on chapters, validated against the same five modes as a
notebook's, and set outside the COALESCE so null means the hand-made order
rather than "leave it alone".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:45:05 +02:00
marcandClaude Opus 5 ea1bca5d25 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:37:29 +02:00
marcandClaude Opus 5 f01522aa6f A chapter is a list too, and deleting stops moving things
Deleting a note moved everything else. A row leaving is the same problem as
a row arriving, in reverse: a line's position counts the rows above it, so a
note deleted above a line left the line counting rows that were no longer
there, and everything under it appeared to jump up past it. withoutRow
renumbers the list a row leaves, and every place one leaves says so -- the
bin, a move, a chapter being removed. Arrivals land at the top: an old
position belongs to an arrangement that has moved on.

A line could not be dragged under another line. Two lines in the same gap
carry the same position, and the comparator claimed "a separator comes
first" without asking what the other one was -- both directions at once for
that case. A comparator that contradicts itself has no defined result, so
the sort returned them in whichever order it happened to walk.

A chapter is always in the user's own order and can be arranged like any
other list: its notes wear handles under the same switch as the list beside
them and are dragged among themselves, strips at both ends included. The two
lists keep apart by scope -- a chapter unfolded under its row holds rows
inside a row of the notebook's list, so the nearest row above the pointer
cannot say which list a drag belongs to. The sort menu is gone inside a
chapter: the mode belongs to the notebook, and a menu there would offer a
choice that cannot be honoured.

The chapters suite no longer assumes the first chapter row is the one that
matters. A chapter is a row in the arrangement and can be anywhere in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:37:02 +02:00
56 changed files with 5282 additions and 1080 deletions

No files matched your search

+19
View File
@@ -24,6 +24,15 @@ INKNOTE_ADMIN_PASSWORD=
# ── Optional ── unset keeps the default shown
# INKNOTE_MAX_IMAGE_MB=25 # pasted images
# ── HoneyOS bar ───────────────────────────────────────────────────────────────
# The strip along the top that ties the apps together. Empty = no bar.
# Wirkt beim Neustart, nicht beim Bauen.
# HONEYBAR_ORIGIN=https://honeyos.example.com
# Only needed when HoneyOS lives on a different site than InkNote — then the
# browser will not send its session and the bar needs this to fetch the app
# list. Same domain (notes.example.org + example.org): leave empty.
# HONEYBAR_KEY=
# INKNOTE_MAX_PDF_MB=200 # imported PDFs, limited separately
# INKNOTE_UID=1000 # must own INKNOTE_DATA_DIR
# INKNOTE_GID=1000
@@ -31,3 +40,13 @@ INKNOTE_ADMIN_PASSWORD=
# VERSION_RETENTION_DAYS= # off: versions expire by count, not by age
# VERSION_COALESCE_MINUTES=5
# ALLOWED_ORIGINS= # only if the UI is on another host
# ── Signing in through an identity provider (OIDC) ── optional, off when empty
# Set-up and what it does: readme.md → Signing in through an identity provider
# INKNOTE_PUBLIC_URL=https://notes.example.com
# INKNOTE_OIDC_ISSUER=https://id.example.com
# INKNOTE_OIDC_CLIENT_ID=
# INKNOTE_OIDC_CLIENT_SECRET=
# INKNOTE_OIDC_LABEL=PocketID # the button says "Continue with …"
# INKNOTE_OIDC_SCOPES=openid profile email
# INKNOTE_OIDC_REDIRECT_URI= # only if not <public url>/api/auth/oidc/callback
+443 -542
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -22,6 +22,11 @@ Diese Punkte sind Entscheidungen von Marc, keine offenen Fragen:
- **Keine öffentliche Registrierung.** Konten legt ein Administrator an. Ein
Admin darf anlegen, löschen und Passwörter zurücksetzen – aber **keine**
weiteren Admins ernennen und **keine** fremden Konten umbenennen.
- **OIDC ist die zweite Anmeldeart, nicht die einzige.** Wer sich beim Anbieter
anmelden darf, bekommt hier ein Konto (Marcs Entscheidung, 18.08.2026) – aber
**nie** Admin-Rechte, die werden weiter nur in der App vergeben. Der
Passwort-Login bleibt immer erreichbar, sonst sperrt ein kaputter Anbieter
alle aus. Ohne `INKNOTE_OIDC_*` in der `.env` ist davon nichts zu sehen.
- **Bundle-Größe bleibt, wie sie ist.** Bewusst so entschieden.
- **Dashboard und Seitenleiste sind vertagt.** Die offenen Befunde dazu sind
bekannt; nicht ungefragt damit anfangen.
+431
View File
@@ -0,0 +1,431 @@
/*
* Signing in through an identity provider — OpenID Connect.
* ────────────────────────────────────────────────────────
*
* This is a second way to obtain the *same* token the password login hands out.
* Nothing downstream changes: every route, and the WebSocket, still knows only
* a signed JWT. That is the whole reason this fits in one file.
*
* Authorization Code with PKCE, and the code is exchanged **here**, never in
* the browser: the client secret stays on the server, and the browser is only
* ever handed a one-time ticket to swap for its token — a token in a redirect
* URL ends up in the history, in the proxy's log, and in the referrer of the
* next request.
*
* No library. Discovery is one document, the exchange is one form post, and
* checking the ID token is a signature and five claims — Node imports a JWK
* directly (`createPublicKey({ format: "jwk" })`), so this needs nothing that
* is not already installed. A dependency here would be one more thing to keep
* current in the part of the app that must not rot.
*
* What this file deliberately does *not* do:
* • refresh tokens — the provider is asked once, at sign-in, and InkNote's own
* token carries the session from then on. Nothing here needs the provider to
* be reachable afterwards, which is what keeps the app working offline.
* • decide who may in. That is an account question, and it is answered in
* server.js where the users are.
*/
const crypto = require("crypto");
/* ─── Configuration ─── */
const trimSlash = (s) => String(s || "").replace(/\/+$/, "");
const ISSUER = trimSlash(process.env.INKNOTE_OIDC_ISSUER);
const CLIENT_ID = process.env.INKNOTE_OIDC_CLIENT_ID || "";
const CLIENT_SECRET = process.env.INKNOTE_OIDC_CLIENT_SECRET || "";
const PUBLIC_URL = trimSlash(process.env.INKNOTE_PUBLIC_URL);
const REDIRECT_URI =
process.env.INKNOTE_OIDC_REDIRECT_URI || (PUBLIC_URL ? `${PUBLIC_URL}/api/auth/oidc/callback` : "");
const SCOPES = process.env.INKNOTE_OIDC_SCOPES || "openid profile email";
const LABEL = process.env.INKNOTE_OIDC_LABEL || "single sign-on";
/* Where the browser is sent back to with its ticket. The redirect URI names the
* API; the app itself sits at the root of the same site. Never anything read
* off the request: a redirect built from a parameter is an open redirect, and
* this one is reached without being signed in. */
const APP_URL = PUBLIC_URL || trimSlash(REDIRECT_URI).replace(/\/api\/auth\/oidc\/callback$/, "") || "";
const enabled = Boolean(ISSUER && CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
/** Says what is missing, once, at boot — half a configuration is a typo, not a choice. */
function describeConfiguration() {
if (enabled) {
// The client secret and the code both travel to the issuer. Over plain HTTP
// they travel readably, which is worth saying out loud rather than assuming
// somebody meant it.
const insecure = ISSUER.startsWith("http://") ? "\n ⚠️ …over plain HTTP: the client secret and the code are readable in transit" : "";
return ` ✅ OpenID Connect enabled — ${ISSUER} (redirect ${REDIRECT_URI})${insecure}`;
}
const named = [
["INKNOTE_OIDC_ISSUER", ISSUER],
["INKNOTE_OIDC_CLIENT_ID", CLIENT_ID],
["INKNOTE_OIDC_CLIENT_SECRET", CLIENT_SECRET],
["INKNOTE_PUBLIC_URL or INKNOTE_OIDC_REDIRECT_URI", REDIRECT_URI],
];
if (named.every(([, v]) => !v)) return null; // not configured at all: nothing to say
const missing = named.filter(([, v]) => !v).map(([n]) => n);
return ` ⚠️ OpenID Connect is half configured and stays off — missing: ${missing.join(", ")}`;
}
/* ─── Small helpers ─── */
const b64url = (buf) => Buffer.from(buf).toString("base64url");
const randomId = () => crypto.randomBytes(32).toString("base64url");
/** Fails loudly with a short slug: the browser is told the slug, the log the rest. */
function fail(code, detail) {
const err = new Error(detail ? `${code}: ${detail}` : code);
err.code = code;
return err;
}
async function getJson(url, options = {}) {
const res = await fetch(url, { ...options, signal: AbortSignal.timeout(8000) });
const text = await res.text();
let body = null;
try {
body = JSON.parse(text);
} catch {
/* left null — reported below with the status, which is the useful half */
}
if (!res.ok || !body) throw fail("provider_unreachable", `${url} → ${res.status} ${text.slice(0, 200)}`);
return body;
}
/* ─── Discovery and keys, both cached ───
*
* Cached because they are asked for on every sign-in and change about once a
* year. The key cache is also refreshed on demand: a provider may rotate its
* keys at any moment, and the first token signed by the new one would otherwise
* fail until the hour was up. That refresh is rate-limited, or an unknown `kid`
* — which is also what a forged token looks like — would be a way to make this
* server hammer the provider.
*/
const HOUR = 60 * 60 * 1000;
let discovery = { at: 0, doc: null };
let jwks = { at: 0, keys: [], lastForcedRefresh: 0 };
async function metadata() {
if (discovery.doc && Date.now() - discovery.at < HOUR) return discovery.doc;
const doc = await getJson(`${ISSUER}/.well-known/openid-configuration`);
for (const field of ["authorization_endpoint", "token_endpoint", "jwks_uri"]) {
if (!doc[field]) throw fail("provider_incomplete", `discovery document has no ${field}`);
}
// The issuer identifies the provider and is checked against every token; a
// document that names someone else is either misconfigured or not theirs.
if (trimSlash(doc.issuer) !== ISSUER) {
throw fail("provider_incomplete", `discovery says issuer ${doc.issuer}, configured ${ISSUER}`);
}
discovery = { at: Date.now(), doc };
return doc;
}
async function signingKeys({ force = false } = {}) {
const fresh = Date.now() - jwks.at < HOUR;
if (jwks.keys.length && fresh && !force) return jwks.keys;
if (force && Date.now() - jwks.lastForcedRefresh < 60_000) return jwks.keys;
if (force) jwks.lastForcedRefresh = Date.now();
const doc = await metadata();
const body = await getJson(doc.jwks_uri);
const keys = Array.isArray(body.keys) ? body.keys : [];
jwks = { ...jwks, at: Date.now(), keys };
return keys;
}
/* ─── Pending sign-ins and one-time tickets ───
*
* Both live in memory on purpose. They exist for seconds, they are worthless
* afterwards, and writing them to the database would mean a table to clean up
* for something a restart is allowed to lose: a sign-in interrupted by a
* restart simply starts again.
*/
const PENDING_TTL = 10 * 60 * 1000;
const TICKET_TTL = 60 * 1000;
const pending = new Map(); // state -> { verifier, nonce, at }
const tickets = new Map(); // ticket -> { payload, at }
setInterval(() => {
const now = Date.now();
for (const [k, v] of pending) if (now - v.at > PENDING_TTL) pending.delete(k);
for (const [k, v] of tickets) if (now - v.at > TICKET_TTL) tickets.delete(k);
}, 60_000).unref();
/**
* Where to send the browser, and what to remember while it is away.
*
* PKCE as well as the secret: the code comes back through the browser, and a
* code that can be redeemed by whoever grabs it is worth as much as the token
* it buys. `state` ties the return to this attempt, `nonce` ties the ID token to
* it — different attacks, both cheap to prevent.
*/
async function beginLogin() {
const doc = await metadata();
const state = randomId();
const nonce = randomId();
const verifier = randomId();
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
pending.set(state, { verifier, nonce, at: Date.now() });
const url = new URL(doc.authorization_endpoint);
for (const [k, v] of Object.entries({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPES,
state,
nonce,
code_challenge: challenge,
code_challenge_method: "S256",
})) {
url.searchParams.set(k, v);
}
return url.toString();
}
/** The signature, then the claims. Everything here is a reason to refuse. */
async function verifyIdToken(idToken, nonce) {
const parts = String(idToken).split(".");
if (parts.length !== 3) throw fail("id_token_invalid", "not three parts");
const [headerB64, payloadB64, signatureB64] = parts;
let header, claims;
try {
header = JSON.parse(Buffer.from(headerB64, "base64url").toString("utf8"));
claims = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
} catch {
throw fail("id_token_invalid", "header or payload is not JSON");
}
const ALGORITHMS = {
RS256: { hash: "sha256" },
RS384: { hash: "sha384" },
RS512: { hash: "sha512" },
PS256: { hash: "sha256", padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 },
ES256: { hash: "sha256", dsaEncoding: "ieee-p1363" },
ES384: { hash: "sha384", dsaEncoding: "ieee-p1363" },
};
const alg = ALGORITHMS[header.alg];
// `none` is a valid JWT algorithm and an unforgeable way in if it is honoured.
if (!alg) throw fail("id_token_invalid", `unsupported algorithm ${header.alg}`);
const pick = (keys) =>
keys.find((k) => (header.kid ? k.kid === header.kid : true) && (!k.use || k.use === "sig"));
const attempt = async (force) => {
const jwk = pick(await signingKeys({ force }));
if (!jwk) return `no signing key for kid ${header.kid}`;
try {
const key = crypto.createPublicKey({ key: jwk, format: "jwk" });
const ok = crypto.verify(
alg.hash,
Buffer.from(`${headerB64}.${payloadB64}`),
{
key,
...(alg.padding ? { padding: alg.padding, saltLength: alg.saltLength } : {}),
...(alg.dsaEncoding ? { dsaEncoding: alg.dsaEncoding } : {}),
},
Buffer.from(signatureB64, "base64url")
);
return ok ? null : "signature does not match";
} catch (e) {
return `signature check failed: ${e.message}`;
}
};
/* Once with what we have, and if that fails once more with the key set read
* fresh. A provider may add a key, or replace one without renaming it, and
* either way the first token signed with it would otherwise be refused until
* the cached hour was up. The re-read is rate-limited inside `signingKeys`, so
* a stream of forged tokens cannot turn this into a way to hammer them. */
const problem = (await attempt(false)) && (await attempt(true));
if (problem) throw fail("id_token_invalid", problem);
const now = Math.floor(Date.now() / 1000);
const SKEW = 120; // clocks on two machines are never quite the same
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (trimSlash(claims.iss) !== ISSUER) throw fail("id_token_invalid", `issued by ${claims.iss}`);
if (!audiences.includes(CLIENT_ID)) throw fail("id_token_invalid", "not addressed to this client");
if (audiences.length > 1 && claims.azp !== CLIENT_ID) throw fail("id_token_invalid", "azp is someone else");
if (!claims.exp || claims.exp + SKEW < now) throw fail("id_token_invalid", "expired");
if (claims.iat && claims.iat - SKEW > now) throw fail("id_token_invalid", "issued in the future");
// The nonce is what makes this token *this* sign-in rather than one replayed
// from somewhere else.
if (claims.nonce !== nonce) throw fail("id_token_invalid", "nonce does not match");
if (!claims.sub) throw fail("id_token_invalid", "no subject");
return claims;
}
/**
* The return leg: state back to a pending attempt, code to tokens, token to
* claims. Gives back only what an account is made of.
*/
async function completeLogin({ code, state }) {
if (!code || !state) throw fail("callback_incomplete");
const attempt = pending.get(state);
// One attempt, one use — a code replayed with the same state gets nowhere.
pending.delete(state);
if (!attempt) throw fail("state_unknown", "no pending sign-in for this state, or it expired");
const doc = await metadata();
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: attempt.verifier,
});
let tokens;
try {
tokens = await getJson(doc.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: body.toString(),
});
} catch (e) {
throw fail("token_exchange_failed", e.message);
}
if (!tokens.id_token) throw fail("token_exchange_failed", "no id_token in the response");
const verified = await verifyIdToken(tokens.id_token, attempt.nonce);
const claims = await withUserInfo(verified, tokens.access_token, doc);
return {
sub: String(verified.sub),
username: claims.preferred_username || null,
email: claims.email || null,
displayName: claims.name || claims.given_name || null,
picture: typeof claims.picture === "string" ? claims.picture : null,
/* Only for fetching a picture the provider keeps behind its own door, and
* only ever sent back to the provider itself. */
accessToken: tokens.access_token || null,
};
}
/**
* What the ID token did not say.
*
* A provider is free to put the bare minimum in the token and keep the rest at
* its userinfo endpoint, and several do — PocketID among them for the picture.
* Asked only when something is actually missing, so a provider that says
* everything up front is not made to answer a second request per sign-in.
*
* The reply is trusted only after its `sub` matches the token's: userinfo is an
* ordinary API answer, and the token is the thing whose signature was checked.
*/
async function withUserInfo(claims, accessToken, doc) {
const complete = claims.preferred_username && claims.name && claims.picture;
if (complete || !accessToken || !doc.userinfo_endpoint) return claims;
try {
const info = await getJson(doc.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
if (String(info.sub) !== String(claims.sub)) {
console.warn("[oidc] userinfo is about somebody else — ignored");
return claims;
}
// The signed token wins wherever both speak.
return { ...info, ...claims };
} catch (err) {
console.warn("[oidc] userinfo could not be read:", err.message);
return claims;
}
}
/**
* The profile picture, fetched by the server rather than by the browser.
*
* Three reasons it happens here. The address is often behind the provider's own
* session, which this app's visitors may not have in a third-party context; the
* browser would have to talk to the provider on every page, which is a request
* to somewhere else on every load; and an image that lives here keeps working
* when the provider does not, like everything else in this app.
*
* Best effort in the strict sense: a slow, missing, oversized or non-image
* answer gives null and the sign-in carries on. A picture is never a reason to
* fail to let somebody in.
*/
async function fetchPicture(url, { accessToken = null, maxBytes = 512 * 1024 } = {}) {
const give_up = (why) => {
console.warn(`[oidc] no profile picture: ${why}`);
return null;
};
if (!url) return give_up("the provider sent no picture for this account");
/* Some providers hand over the image itself rather than an address for it. */
const inline = /^data:(image\/[a-z+]+);base64,([\s\S]+)$/i.exec(url.trim());
if (inline) {
const bytes = Buffer.from(inline[2], "base64");
if (!bytes.length || bytes.length > maxBytes) return give_up("the inline picture is empty or too large");
return { bytes, type: inline[1].toLowerCase() };
}
if (!/^https?:\/\//i.test(url)) return give_up(`the picture address is not one we can fetch (${url.slice(0, 40)})`);
/* The token goes with the request only when the picture lives at the provider
* — which is where a picture behind a login lives. Never anywhere else: an
* access token sent to a third party is a credential given away. */
const sameHouse = (() => {
try {
return new URL(url).origin === new URL(ISSUER).origin;
} catch {
return false;
}
})();
const headers = accessToken && sameHouse ? { Authorization: `Bearer ${accessToken}` } : {};
try {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(4000), redirect: "follow" });
if (!res.ok) {
return give_up(
res.status === 401 || res.status === 403
? `the provider would not hand it over (${res.status})${sameHouse ? "" : " — and it is hosted elsewhere, so no token was sent"}`
: `the provider answered ${res.status}`
);
}
const type = String(res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
if (!/^image\/(png|jpeg|webp|gif|svg\+xml)$/.test(type)) return give_up(`it is not an image we keep (${type || "no content-type"})`);
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) return give_up(`it is larger than ${maxBytes} bytes`);
const bytes = Buffer.from(await res.arrayBuffer());
// The header may have lied or been absent; the bytes are the truth.
if (!bytes.length) return give_up("the answer was empty");
if (bytes.length > maxBytes) return give_up(`it is larger than ${maxBytes} bytes`);
return { bytes, type };
} catch (err) {
return give_up(`it could not be fetched (${err.message})`);
}
}
/* ─── The ticket the browser carries home ─── */
function issueTicket(payload) {
const ticket = randomId();
tickets.set(ticket, { payload, at: Date.now() });
return ticket;
}
/** Single use: redeemed or gone, so a ticket left in a URL is worth nothing twice. */
function redeemTicket(ticket) {
const entry = tickets.get(String(ticket ?? ""));
if (!entry) return null;
tickets.delete(String(ticket));
if (Date.now() - entry.at > TICKET_TTL) return null;
return entry.payload;
}
module.exports = {
enabled,
label: LABEL,
issuer: ISSUER,
redirectUri: REDIRECT_URI,
appUrl: APP_URL,
describeConfiguration,
beginLogin,
completeLogin,
fetchPicture,
issueTicket,
redeemTicket,
};
+317 -13
View File
@@ -32,6 +32,8 @@ const path = require("path");
const fs = require("fs");
const http = require("http");
const oidc = require("./oidc");
/* ─────────── Config ─────────── */
const PORT = process.env.PORT || 4000;
@@ -276,6 +278,19 @@ const publicUser = (row) => ({
displayName: row.display_name,
isAdmin: !!row.is_admin,
preferences: readPreferences(row.preferences),
/* Two different things, and the interface needs both.
*
* `linkedToProvider` — this account has an identity at the provider and can
* sign in through it. `signsInWithProvider` — it has *no password here at
* all*, which is true of accounts the provider created and not of an existing
* account that was later linked. The first decides whether to say so; the
* second decides whether there is a password to offer changing. */
linkedToProvider: !!row.oidc_sub,
signsInWithProvider: isProviderOnly(row),
/* Whether there is a picture to ask for. The bytes are fetched separately —
* this only decides whether anything asks at all, so a row without one draws
* its initial instead of firing a request that answers 404. */
hasAvatar: !!hasAvatar.get(row.id),
});
/**
@@ -301,6 +316,11 @@ const NOTE_FONTS = ["serif", "sans", "mono"];
* lays out its pages, or unfolded in place under its own row. A phone has room
* for neither, and falls back to filling the screen with a way back. */
const CHAPTER_VIEWS = ["column", "inline"];
/* Light or dark. On the account rather than in the browser, like the typeface —
* it is a choice about how your own notes look, and making it again on every
* device is making it badly. The browser keeps a copy of its own so a reload
* does not flash the other one while the account is being fetched. */
const THEMES = ["dark", "light"];
function readPreferences(raw) {
let parsed = null;
@@ -311,9 +331,11 @@ function readPreferences(raw) {
}
const font = parsed?.noteFont;
const view = parsed?.chapterView;
const theme = parsed?.theme;
return {
noteFont: NOTE_FONTS.includes(font) ? font : "serif",
chapterView: CHAPTER_VIEWS.includes(view) ? view : "column",
theme: THEMES.includes(theme) ? theme : "dark",
};
}
@@ -469,11 +491,45 @@ if (!tableColumns("notes").includes("icon_color")) {
db.exec("ALTER TABLE notes ADD COLUMN icon_color TEXT");
}
/* A profile picture, kept out of the users table on purpose: every query that
* reads a user does `SELECT *`, and a blob in that row would be carried through
* the admin list, every sign-in and every `/auth/me` for the sake of something
* shown in one corner. Encrypted at rest like every other file here — it comes
* from outside and is a picture of a person. */
db.exec(`
CREATE TABLE IF NOT EXISTS user_avatars (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
bytes BLOB NOT NULL,
content_type TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
`);
/* Which identity at the provider this account is, once it has signed in that
* way. Kept on the account rather than in a table of its own: a person has one
* provider here, and the column is what makes the link survive a rename — the
* subject is the provider's permanent name for someone, the username is not.
* Unique where it is set, so two accounts can never claim the same identity. */
if (!tableColumns("users").includes("oidc_sub")) {
db.exec("ALTER TABLE users ADD COLUMN oidc_sub TEXT");
}
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL");
/* Added after chapters shipped, so an existing table needs it too. */
if (!tableColumns("chapters").includes("color")) {
db.exec("ALTER TABLE chapters ADD COLUMN color TEXT");
}
/* A chapter orders its own notes.
*
* Its own column rather than the notebook's setting, because a chapter is a
* place someone made and filled: a reference chapter can stay alphabetical
* while the notebook around it stays in the order it was arranged. Null means
* the hand-made order, which is what a chapter is in until it is told otherwise. */
if (!tableColumns("chapters").includes("sort_mode")) {
db.exec("ALTER TABLE chapters ADD COLUMN sort_mode TEXT");
}
if (!tableColumns("notes").includes("chapter_id")) {
db.exec("ALTER TABLE notes ADD COLUMN chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL");
db.exec("CREATE INDEX IF NOT EXISTS idx_notes_chapter ON notes(chapter_id)");
@@ -673,6 +729,7 @@ function authMiddleware(req, res, next) {
}
}
const userExists = db.prepare("SELECT 1 FROM users WHERE id = ?").pluck();
const hasAvatar = db.prepare("SELECT 1 FROM user_avatars WHERE user_id = ?").pluck();
/* ─────────── Express app ─────────── */
const app = express();
@@ -727,9 +784,12 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
const { username, password } = req.body;
const name = String(username ?? "").trim();
const user = name ? db.prepare("SELECT * FROM users WHERE username = ?").get(name) : null;
// Always run a comparison so timing does not leak whether the user exists.
const ok = await bcrypt.compare(String(password ?? ""), user ? user.password_hash : DUMMY_HASH);
if (!user || !ok) return res.status(401).json({ error: "Invalid credentials" });
// Always run a comparison so timing does not leak whether the user exists —
// and an account that has no password of its own is compared against the
// dummy too, so it is exactly as slow and says exactly as little.
const usable = user && !isProviderOnly(user);
const ok = await bcrypt.compare(String(password ?? ""), usable ? user.password_hash : DUMMY_HASH);
if (!usable || !ok) return res.status(401).json({ error: "Invalid credentials" });
clearLoginAttempts(req);
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
@@ -737,6 +797,209 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
} catch (err) { next(err); }
});
/* ─── Signing in through an identity provider ─── */
/**
* An account with no password of its own.
*
* Made by the provider, so there is nothing to compare against. The column is
* NOT NULL and bcrypt would happily compare against nonsense forever, so the
* marker is explicit rather than "some string that cannot match": a guard that
* can be read is worth more than one that merely happens to hold.
*/
const PROVIDER_ONLY = "!oidc:";
const isProviderOnly = (user) => String(user?.password_hash || "").startsWith(PROVIDER_ONLY);
/** A username this instance will accept, built from what the provider says. */
function usernameFromIdentity(identity) {
const raw = String(identity.username || (identity.email || "").split("@")[0] || identity.displayName || "user");
// The provider's rules are not this one's: anything outside the alphabet
// becomes a dash, and what is left is padded rather than rejected, because a
// name is a handle here and the identity is the subject.
let base = raw.trim().replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
while (base.length < 3) base += "0";
if (!db.prepare("SELECT 1 FROM users WHERE username = ?").get(base)) return base;
// Taken by somebody else. Number it rather than fail: two people called "marc"
// at the provider is not an error, it is Tuesday.
for (let n = 2; n < 1000; n++) {
const candidate = `${base.slice(0, 28)}-${n}`;
if (!db.prepare("SELECT 1 FROM users WHERE username = ?").get(candidate)) return candidate;
}
throw new Error("could not find a free username");
}
/**
* Which account an arriving identity is.
*
* Three cases, in this order: known subject, an account of the same name that
* has not been claimed yet, or nobody — in which case one is made. Binding by
* name happens once and only for an unclaimed account; from then on the subject
* is the link, so renaming either side changes nothing.
*
* Administrator rights are never taken from the provider. A group over there
* would otherwise be a way to grant them here, and in this app an administrator
* cannot even appoint another one.
*/
function accountForIdentity(identity) {
const known = db.prepare("SELECT * FROM users WHERE oidc_sub = ?").get(identity.sub);
if (known) return known;
const wanted = normalizeUsername(identity.username) || normalizeUsername(String(identity.email || "").split("@")[0]);
const sameName = wanted ? db.prepare("SELECT * FROM users WHERE username = ?").get(wanted) : null;
if (sameName) {
// Already somebody else's identity: refuse rather than move the link, or a
// renamed account at the provider could take over an account here.
if (sameName.oidc_sub && sameName.oidc_sub !== identity.sub) {
const err = new Error("account_taken");
err.code = "account_taken";
throw err;
}
db.prepare("UPDATE users SET oidc_sub = ?, updated_at = datetime('now') WHERE id = ?").run(identity.sub, sameName.id);
console.log(` 🔗 linked account "${sameName.username}" to its identity at the provider`);
return { ...sameName, oidc_sub: identity.sub };
}
const id = uuid();
const username = usernameFromIdentity(identity);
db.prepare(
"INSERT INTO users (id, username, password_hash, display_name, is_admin, oidc_sub) VALUES (?, ?, ?, ?, 0, ?)"
).run(id, username, PROVIDER_ONLY + crypto.randomBytes(24).toString("hex"), identity.displayName || username, identity.sub);
console.log(` ✅ created account "${username}" for a new identity at the provider`);
return db.prepare("SELECT * FROM users WHERE id = ?").get(id);
}
/* What the sign-in screen may know before anyone has signed in: whether there is
* a second way in, and what to call it. Deliberately says nothing else — no
* issuer, no client id, nothing that describes the provider to a stranger. */
app.get("/api/auth/methods", (req, res) => {
res.json({
password: true,
oidc: oidc.enabled ? { enabled: true, label: oidc.label } : { enabled: false },
});
});
/* Where the HoneyOS bar lives, if it is wanted at all.
*
* Served at runtime rather than baked into the build for two reasons. The image
* is pulled ready-made, so a value fixed at build time could never be changed
* from the .env afterwards. And a service worker caches whole responses,
* headers included — anything frozen into index.html stays frozen in the
* browser long after the server has moved on.
*
* The key is only handed out together with an origin: without a bar it has
* nothing to unlock, and it should not sit in the page for no reason. It buys
* the list of apps and nothing else. */
app.get("/api/public/config", (req, res) => {
const origin = String(process.env.HONEYBAR_ORIGIN || "").replace(/\/+$/, "");
res.json({
honeybar: origin
? { origin, key: String(process.env.HONEYBAR_KEY || "") }
: null,
});
});
/* The browser is sent to the provider from here rather than from the app, so
* the app never has to know the issuer, the scopes or the redirect. */
app.get("/api/auth/oidc/start", async (req, res) => {
if (!oidc.enabled) return res.status(404).json({ error: "Not configured" });
try {
res.redirect(await oidc.beginLogin());
} catch (err) {
console.warn("[oidc] start failed:", err.message);
res.redirect(backToApp({ oidc_error: err.code || "start_failed" }));
}
});
/** Home again, whatever happened — the app is the only place to report to. */
const backToApp = (params) => `${oidc.appUrl || ""}/?${new URLSearchParams(params)}`;
app.get("/api/auth/oidc/callback", async (req, res) => {
if (!oidc.enabled) return res.status(404).json({ error: "Not configured" });
if (req.query.error) {
console.warn(`[oidc] provider refused: ${req.query.error} ${req.query.error_description || ""}`);
return res.redirect(backToApp({ oidc_error: "provider_refused" }));
}
try {
const identity = await oidc.completeLogin({ code: req.query.code, state: req.query.state });
const user = accountForIdentity(identity);
await rememberPicture(user.id, identity.picture, identity.accessToken);
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
// The token itself never rides in the URL: a redirect lands in the history,
// in the proxy's log, and in the next request's referrer.
res.redirect(backToApp({ oidc: oidc.issueTicket({ token, user: signedInUser(user) }) }));
} catch (err) {
console.warn("[oidc] sign-in failed:", err.message);
res.redirect(backToApp({ oidc_error: err.code || "sign_in_failed" }));
}
});
/**
* Keeps the picture the provider has of somebody, or clears it when they take
* theirs away.
*
* On every sign-in rather than only the first: a picture that changed over there
* and never here is worse than none, and this is the one moment we are talking
* to the provider anyway. Failure is silence — the account is already signed in
* by the time this runs, and a portrait is not worth a failed sign-in.
*/
async function rememberPicture(userId, url, accessToken) {
try {
if (!url) {
db.prepare("DELETE FROM user_avatars WHERE user_id = ?").run(userId);
console.log(" ℹ️ the provider sent no picture for this account");
return;
}
const picture = await oidc.fetchPicture(url, { accessToken });
if (!picture) return; // unreachable or not an image: keep whatever is stored
db.prepare(
`INSERT INTO user_avatars (user_id, bytes, content_type, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET bytes = excluded.bytes,
content_type = excluded.content_type,
updated_at = excluded.updated_at`
).run(userId, encryptBuffer(picture.bytes), picture.type);
console.log(` 🖼️ stored a profile picture from the provider (${picture.type}, ${picture.bytes.length} bytes)`);
} catch (err) {
console.warn("[oidc] could not store the profile picture:", err.message);
}
}
/**
* The picture itself.
*
* Yourself always; anybody else only for an administrator, who already sees the
* list of accounts. It is not a public avatar service — everything in this app
* is behind the same door.
*/
app.get("/api/users/:id/avatar", authMiddleware, (req, res) => {
const mine = req.params.id === req.userId;
if (!mine && !db.prepare("SELECT is_admin FROM users WHERE id = ?").get(req.userId)?.is_admin) {
return res.status(403).json({ error: "Not allowed" });
}
const row = db.prepare("SELECT bytes, content_type, updated_at FROM user_avatars WHERE user_id = ?").get(req.params.id);
if (!row) return res.status(404).json({ error: "No picture" });
try {
res.set("Content-Type", row.content_type);
// Private: it is fetched with a bearer token and belongs to one person.
res.set("Cache-Control", "private, max-age=300");
// It came from somewhere else, and one of the types kept here (SVG) is a
// document when a browser is allowed to think so. It never is.
res.set("X-Content-Type-Options", "nosniff");
res.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
res.send(decryptBuffer(row.bytes));
} catch (err) {
console.error("[avatar] could not be read:", err.message);
res.status(500).json({ error: "Could not read the picture" });
}
});
/* The ticket is swapped for the token by the app itself, once, within a minute. */
app.post("/api/auth/oidc/claim", (req, res) => {
const payload = oidc.redeemTicket(req.body?.ticket);
if (!payload) return res.status(401).json({ error: "This sign-in has expired. Try again." });
res.json(payload);
});
/* ─── Auth profile routes ─── */
app.get("/api/auth/me", authMiddleware, (req, res) => {
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
@@ -752,13 +1015,16 @@ app.put("/api/auth/profile", authMiddleware, (req, res) => {
});
app.put("/api/auth/preferences", authMiddleware, (req, res) => {
const { noteFont, chapterView } = req.body || {};
const { noteFont, chapterView, theme } = req.body || {};
if (noteFont !== undefined && !NOTE_FONTS.includes(noteFont)) {
return res.status(400).json({ error: `noteFont must be one of ${NOTE_FONTS.join(", ")}` });
}
if (chapterView !== undefined && !CHAPTER_VIEWS.includes(chapterView)) {
return res.status(400).json({ error: `chapterView must be one of ${CHAPTER_VIEWS.join(", ")}` });
}
if (theme !== undefined && !THEMES.includes(theme)) {
return res.status(400).json({ error: `theme must be one of ${THEMES.join(", ")}` });
}
const current = readPreferences(
db.prepare("SELECT preferences FROM users WHERE id = ?").get(req.userId)?.preferences
);
@@ -766,6 +1032,7 @@ app.put("/api/auth/preferences", authMiddleware, (req, res) => {
...current,
...(noteFont !== undefined ? { noteFont } : {}),
...(chapterView !== undefined ? { chapterView } : {}),
...(theme !== undefined ? { theme } : {}),
};
db.prepare("UPDATE users SET preferences = ?, updated_at = datetime('now') WHERE id = ?")
.run(JSON.stringify(next), req.userId);
@@ -776,10 +1043,16 @@ app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
try {
const { newUsername, password } = req.body;
const name = normalizeUsername(newUsername);
if (!name || !password) return res.status(400).json({ error: "Username and password required" });
if (!name) return res.status(400).json({ error: "Username and password required" });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
if (!user) return res.status(404).json({ error: "User not found" });
if (!(await bcrypt.compare(String(password), user.password_hash))) return res.status(401).json({ error: "Wrong password" });
/* An account made by the provider has no password to confirm with, and asking
* for one it cannot have would simply lock the name. The signed-in token is
* the only credential such an account has, and it is the same one that could
* already read every note. */
if (!isProviderOnly(user) && !(await bcrypt.compare(String(password), user.password_hash))) {
return res.status(401).json({ error: "Wrong password" });
}
const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId);
if (exists) return res.status(409).json({ error: "Username already taken" });
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(name, req.userId);
@@ -790,6 +1063,12 @@ app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
app.put("/api/auth/password", authMiddleware, async (req, res, next) => {
try {
const { currentPassword, newPassword } = req.body;
const me = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(req.userId);
/* Nothing to change, and nothing to change it with. An administrator can
* still set one — that is the way back in if the provider is ever gone. */
if (isProviderOnly(me)) {
return res.status(400).json({ error: `This account signs in through ${oidc.label} and has no password.` });
}
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
if (String(newPassword).length < MIN_PASSWORD_LENGTH) return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
@@ -1759,16 +2038,19 @@ const cleanIcon = (value) => {
app.get("/api/chapters", authMiddleware, (req, res) => {
res.json(
db
.prepare("SELECT id, notebook_id, name, color, icon, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.prepare("SELECT id, notebook_id, name, color, icon, sort_order, sort_mode FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.all(req.userId)
);
});
app.post("/api/chapters", authMiddleware, (req, res) => {
const { notebookId, name, color, icon } = req.body || {};
const { notebookId, name, color, icon, sortMode } = req.body || {};
const label = String(name ?? "").trim();
if (!label) return res.status(400).json({ error: "name required" });
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
}
// The notebook has to be this user's — otherwise a chapter could be hung off
// someone else's, and every note filed into it would follow.
@@ -1779,10 +2061,21 @@ app.post("/api/chapters", authMiddleware, (req, res) => {
.prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?")
.get(notebookId).n;
const id = uuid();
db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.run(id, req.userId, notebookId, label, cleanColor(color), cleanIcon(icon), order, req.deviceId);
db.prepare(
"INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, sort_mode, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
id,
req.userId,
notebookId,
label,
cleanColor(color),
cleanIcon(icon),
order,
sortMode ?? null,
req.deviceId
);
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
res.json({ id, notebookId, name: label, sortOrder: order });
res.json({ id, notebookId, name: label, sortOrder: order, sortMode: sortMode ?? null });
});
app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
@@ -1804,9 +2097,12 @@ app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
});
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
const { name, sortOrder, color, icon } = req.body || {};
const { name, sortOrder, color, icon, sortMode } = req.body || {};
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!chapter) return res.status(404).json({ error: "Not found" });
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
}
let label;
if (name !== undefined) {
@@ -1826,6 +2122,11 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => {
if (icon !== undefined) {
db.prepare("UPDATE chapters SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
}
// Also outside the COALESCE: null here means the hand-made order, not "leave
// it as it was".
if (sortMode !== undefined) {
db.prepare("UPDATE chapters SET sort_mode = ? WHERE id = ?").run(sortMode ?? null, req.params.id);
}
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
res.json({ success: true });
});
@@ -2088,7 +2389,10 @@ server.listen(PORT, () => {
console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`);
console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`);
console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}`);
console.log(` 🌐 CORS: ${ALLOWED_ORIGINS.length ? ALLOWED_ORIGINS.join(", ") : "same-origin only"}\n`);
console.log(` 🌐 CORS: ${ALLOWED_ORIGINS.length ? ALLOWED_ORIGINS.join(", ") : "same-origin only"}`);
const sso = oidc.describeConfiguration();
if (sso) console.log(sso);
console.log("");
});
/* ─── Graceful shutdown ─── */
+24
View File
@@ -32,10 +32,34 @@ services:
# Optional tuning; unset means the server's own defaults apply.
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- INKNOTE_MAX_IMAGE_MB=${INKNOTE_MAX_IMAGE_MB:-}
# HoneyOS bar. Empty origin means no bar at all; the key is only needed
# when HoneyOS sits on a different site, where the browser withholds its
# session. Takes effect on restart — nothing here is baked into the image.
- HONEYBAR_ORIGIN=${HONEYBAR_ORIGIN:-}
- HONEYBAR_KEY=${HONEYBAR_KEY:-}
- INKNOTE_MAX_PDF_MB=${INKNOTE_MAX_PDF_MB:-}
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
- VERSION_COALESCE_MINUTES=${VERSION_COALESCE_MINUTES:-}
# Signing in through an identity provider — see .env.example. Unset means
# the app is password-only and the sign-in screen shows nothing about it.
# Signing in through an identity provider. In development this points at
# the throwaway one the OIDC suite starts (e2e/fakeIdp.mjs) — which only
# exists while that suite runs, so the button on the sign-in screen is
# there the rest of the time and simply fails. That is the price of
# testing the real redirect against a real signature.
- INKNOTE_PUBLIC_URL=${INKNOTE_PUBLIC_URL:-http://localhost:3000}
- INKNOTE_OIDC_ISSUER=${INKNOTE_OIDC_ISSUER:-http://idp.test:3011}
- INKNOTE_OIDC_CLIENT_ID=${INKNOTE_OIDC_CLIENT_ID:-inknote-test}
- INKNOTE_OIDC_CLIENT_SECRET=${INKNOTE_OIDC_CLIENT_SECRET:-test-secret}
- INKNOTE_OIDC_LABEL=${INKNOTE_OIDC_LABEL:-Test IdP}
- INKNOTE_OIDC_SCOPES=${INKNOTE_OIDC_SCOPES:-}
- INKNOTE_OIDC_REDIRECT_URI=${INKNOTE_OIDC_REDIRECT_URI:-}
# The provider runs on the Docker host (inside the test container, which
# shares the host's network). Same name on both sides, or the issuer in the
# token would not match the one configured here.
extra_hosts:
- "idp.test:host-gateway"
# Bind mounts, not named volumes: the data sits in a plain directory on the
# Docker host where the host's own backup picks it up.
user: "${INKNOTE_UID:-1000}:${INKNOTE_GID:-1000}"
+14
View File
@@ -30,10 +30,24 @@ services:
# Optional tuning; unset means the server's own defaults apply.
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- INKNOTE_MAX_IMAGE_MB=${INKNOTE_MAX_IMAGE_MB:-}
# HoneyOS bar. Empty origin means no bar at all; the key is only needed
# when HoneyOS sits on a different site, where the browser withholds its
# session. Takes effect on restart — nothing here is baked into the image.
- HONEYBAR_ORIGIN=${HONEYBAR_ORIGIN:-}
- HONEYBAR_KEY=${HONEYBAR_KEY:-}
- INKNOTE_MAX_PDF_MB=${INKNOTE_MAX_PDF_MB:-}
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
- VERSION_COALESCE_MINUTES=${VERSION_COALESCE_MINUTES:-}
# Signing in through an identity provider — see .env.example. Unset means
# the app is password-only and the sign-in screen shows nothing about it.
- INKNOTE_PUBLIC_URL=${INKNOTE_PUBLIC_URL:-}
- INKNOTE_OIDC_ISSUER=${INKNOTE_OIDC_ISSUER:-}
- INKNOTE_OIDC_CLIENT_ID=${INKNOTE_OIDC_CLIENT_ID:-}
- INKNOTE_OIDC_CLIENT_SECRET=${INKNOTE_OIDC_CLIENT_SECRET:-}
- INKNOTE_OIDC_LABEL=${INKNOTE_OIDC_LABEL:-}
- INKNOTE_OIDC_SCOPES=${INKNOTE_OIDC_SCOPES:-}
- INKNOTE_OIDC_REDIRECT_URI=${INKNOTE_OIDC_REDIRECT_URI:-}
user: "${INKNOTE_UID:-1000}:${INKNOTE_GID:-1000}"
# Bind mounts, so the data is a plain directory on the host.
# ${VAR:-fallback} means "VAR, or fallback if VAR is empty" — one or the
+211 -33
View File
@@ -282,20 +282,14 @@ try {
/* `where` picks the part of the row held over: a chapter is a place in the
body of its header and a neighbour along the bottom edge of it, which is
how a row can still be put in the gap under a chapter. */
const holdOver = (dragId, chapterId, where = "body") =>
p.evaluate(
([from, to, part]) => {
const row = document.querySelector(`[data-reorder-id="${from}"]`);
const grip = row?.querySelector("[data-reorder-grip]");
const target = document.querySelector(`[data-reorder-id="${to}"]`);
if (!grip || !target) return `missing ${!!grip} ${!!target}`;
const head = target.querySelector("[data-reorder-head]") || target;
const box = head.getBoundingClientRect();
window.__drop = {
x: box.x + box.width / 2,
y: part === "edge" ? box.bottom - 2 : box.y + box.height / 2,
};
const fire = (el, type, x, y) =>
/* Pressed first, aimed after.
Inside a chapter the strips at the two ends of the list only exist while
something is being carried — they are targets, not furniture — so the drop
point cannot be looked up until the row is in the hand. */
const holdOver = async (dragId, chapterId, where = "body", scope = "") => {
const pressed = await p.evaluate(
([from, sc]) => {
window.__fire = (el, type, x, y) =>
el.dispatchEvent(
new PointerEvent(type, {
pointerType: "touch",
@@ -306,14 +300,37 @@ try {
cancelable: true,
})
);
/* Looked up in one list: the two ends have the same names in every list
on screen, so a bare id would find the notebook's when the chapter's
was meant. */
const row = document.querySelector(`[data-reorder-id="${from}"][data-reorder-scope="${sc}"]`);
const grip = row?.querySelector("[data-reorder-grip]");
if (!grip) return "missing grip";
const s = grip.getBoundingClientRect();
fire(grip, "pointerdown", s.x + s.width / 2, s.y + s.height / 2);
fire(window, "pointermove", window.__drop.x, window.__drop.y - 6);
fire(window, "pointermove", window.__drop.x, window.__drop.y);
window.__fire(grip, "pointerdown", s.x + s.width / 2, s.y + s.height / 2);
return "pressed";
},
[dragId, scope]
);
if (pressed !== "pressed") return pressed;
await p.waitForTimeout(300);
return p.evaluate(
([to, part, sc]) => {
const target = document.querySelector(`[data-reorder-id="${to}"][data-reorder-scope="${sc}"]`);
if (!target) return "missing target";
const head = target.querySelector("[data-reorder-head]") || target;
const box = head.getBoundingClientRect();
window.__drop = {
x: box.x + box.width / 2,
y: part === "edge" ? box.bottom - 2 : box.y + box.height / 2,
};
window.__fire(window, "pointermove", window.__drop.x, window.__drop.y - 6);
window.__fire(window, "pointermove", window.__drop.x, window.__drop.y);
return "held";
},
[dragId, chapterId, where]
[chapterId, where, scope]
);
};
const letGo = () =>
p.evaluate(() => {
@@ -359,8 +376,17 @@ try {
const looseBefore = (await titles()).length;
/* Both ends of the drag are read off the rows on screen: what is draggable is
what is rendered, and the server's idea of the notebook includes rows this
list is not showing. */
const rowOrder = await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId));
list is not showing.
Only the notebook's own rows: an open chapter draws its notes with handles
of their own, and those belong to a second list — dragging one of those from
here would be measuring one list against the other. */
const notebookRows = () =>
p
.locator("[data-reorder-id]")
.evaluateAll((els) =>
els.filter((e) => (e.dataset.reorderScope ?? "") === "").map((e) => e.dataset.reorderId)
);
const rowOrder = await notebookRows();
const shownChapters = await p
.locator("[data-chapter-row]")
.evaluateAll((els) => els.map((e) => e.getAttribute("data-chapter-row")));
@@ -391,9 +417,7 @@ try {
/* A line is drawn in the notebook and dragged in, which is how a chapter is
divided at all. */
const sepRow = (
await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId))
).find((id) => id && id.startsWith("sep-"));
const sepRow = (await notebookRows()).find((id) => id && id.startsWith("sep-"));
if (sepRow) {
await dragOnto(sepRow, shownChapters[0]);
await p.waitForTimeout(2500);
@@ -414,9 +438,7 @@ try {
* not be moved among its neighbours, and nothing could be put in the gap under
* one. The bottom edge of the header is that gap, and asks for a line rather
* than lighting the chapter up. */
const looseLeft = (
await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId))
).find(
const looseLeft = (await notebookRows()).find(
(id) =>
id &&
!id.startsWith("sep-") &&
@@ -440,6 +462,119 @@ try {
check(false, "a loose note was left to try the edge with", "none");
}
/* ── a chapter is a list of its own, and can be arranged like one ──
* Its notes are shown with handles under the same switch as the list beside
* them, and dragged among themselves. They could not be dragged at all before:
* a chapter was somewhere to put notes and not somewhere to arrange them. */
/* The one that actually holds notes, asked of the server rather than assumed
to be the first row: which chapter comes first is an arrangement, and this
test is not about that. */
const fullest = (await api(p, "GET", "/notes")).body
.filter((n) => n.chapter_id)
.reduce((counts, n) => counts.set(n.chapter_id, (counts.get(n.chapter_id) || 0) + 1), new Map());
const busiest = [...fullest.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
const openRow = p.locator(`[data-chapter-row="${busiest}"]`);
if ((await p.locator(`[data-chapter-notes="${busiest}"]`).count()) === 0) {
await openRow.click();
await p.waitForTimeout(1600);
}
if ((await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle")) === "off") {
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(700);
}
const inChapterRows = () =>
p
.locator("[data-reorder-id]")
.evaluateAll((els) =>
els
.filter((e) => (e.dataset.reorderScope ?? "").startsWith("chapter:"))
.map((e) => e.dataset.reorderId)
);
const chapterOrder = () =>
p.locator("[data-chapter-note]").evaluateAll((els) => els.map((e) => e.innerText.trim()));
const handles = await inChapterRows();
check(handles.length > 0, "the notes inside a chapter wear handles too", JSON.stringify(handles));
// The name of the chapter's list, which both ends of the drag are looked up in.
const chapterScope = await p
.locator("[data-reorder-id]")
.evaluateAll(
(els) => els.map((e) => e.dataset.reorderScope ?? "").find((s) => s.startsWith("chapter:")) ?? ""
);
const beforeInside = await chapterOrder();
let afterInside = [];
if (beforeInside.length >= 2) {
const rows = await inChapterRows();
const last = rows.filter((id) => id !== "__start__" && id !== "__end__").pop();
await holdOver(last, "__start__", "body", chapterScope);
await p.waitForTimeout(400);
await letGo();
await p.waitForTimeout(2500);
afterInside = await chapterOrder();
check(
afterInside[0] === beforeInside[beforeInside.length - 1] && afterInside.length === beforeInside.length,
"and can be dragged among themselves, the strip at the top included",
`${beforeInside.join(",")} → ${afterInside.join(",")}`
);
// Written, not only drawn: the chapter's order is the notebook's business
// like any other, and has to survive the trip.
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(3000);
await openNotebook();
await openRow.click();
await p.waitForTimeout(1800);
check(
(await chapterOrder()).join(",") === afterInside.join(","),
"and the chapter comes back in the order it was left in",
(await chapterOrder()).join(",")
);
} else {
check(false, "a chapter with two notes to arrange", beforeInside.join(","));
}
/* ── a chapter orders its own notes ──
* The notebook's setting governs the notebook's own list. A chapter is a place
* someone made and filled, so it carries its own — set in the same dialog as
* its name, mark and colour, because they are one decision about one thing. */
await openRow.click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
check((await p.locator("[data-chapter-sort]").count()) === 1, "its dialog offers an order of its own");
await p.locator("[data-chapter-sort]").selectOption("title");
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2500);
const stored = (await api(p, "GET", "/chapters")).body.find((c) => c.id === busiest);
check(stored?.sort_mode === "title", "and keeps it, on the server", JSON.stringify(stored?.sort_mode));
const alphabetical = await chapterOrder();
check(
alphabetical.join(",") === [...alphabetical].sort((a, b) => a.localeCompare(b, "de")).join(","),
"the chapter's notes come out in that order",
alphabetical.join(",")
);
/* And the things that only mean something in a hand-made order go with it: a
line the notes would slide past on every edit, and handles for an order the
app is choosing. */
check(
(await inChapterRows()).length === 0,
"with no handles in it, since that order is not dragged into place",
JSON.stringify(await inChapterRows())
);
await openRow.click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
await p.locator("[data-chapter-sort]").selectOption("manual");
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2500);
check(
(await chapterOrder()).join(",") === afterInside.join(","),
"and the hand-made one is still there when it comes back",
`${afterInside.join(",")} vs ${(await chapterOrder()).join(",")}`
);
/* ── a dialog does not close because a selection ended outside it ──
* Select a word in the field, drag a little past the edge, let go: the click
* lands on the backdrop — the nearest ancestor of press and release — and the
@@ -494,12 +629,25 @@ try {
await p.waitForTimeout(600);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
/* The whole set, as the chapter dialog offers it — kept to compare against
the note dialog further down. The two grids were once two lists, and marks
added to one did not exist in the other. */
const allMarks = (page) =>
page.evaluate(() => [...document.querySelectorAll("[data-icon-option]")].map((el) => el.dataset.iconOption));
const marksInChapterDialog = await allMarks(p);
const headings = await p.locator("[data-icon-group]").count();
check(headings >= 3, "the marks are divided under headings", `${headings} groups`);
await p.getByLabel("Search icons").fill("invoice");
await p.waitForTimeout(400);
// One extra: "no icon" sits in the grid, because taking a mark off is the
// same kind of choice as putting one on.
const offered = await p.locator("[data-icon-option]").count();
check(offered > 1 && offered < 20, "icons are searched by what they are for", `${offered} shown`);
check(
(await p.locator("[data-icon-group]").count()) < headings,
"and a heading with nothing under it is not shown"
);
await p.locator("[data-icon-option]").nth(1).click();
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2200);
@@ -515,9 +663,30 @@ try {
await p.waitForTimeout(1200);
await p.locator("[data-note-icon]").click();
await p.waitForTimeout(700);
check(
JSON.stringify(await allMarks(p)) === JSON.stringify(marksInChapterDialog),
"a note is offered exactly the marks a chapter is",
`${marksInChapterDialog.length} marks`
);
/* A mark nobody thought of: the search box is a text field, which on a phone
is where the emoji keyboard lives, so anything typed into it is offered. */
await p.getByLabel("Search icons").fill("🦖");
await p.waitForTimeout(400);
check(
(await p.locator('[data-icon-option="🦖"]').count()) === 1,
"an emoji typed into the search box is offered as a mark"
);
await p.getByLabel("Search icons").fill("plainly not a mark");
await p.waitForTimeout(400);
check(
(await p.locator("[data-icon-option]").count()) === 1,
"while words that match nothing offer only the empty mark"
);
await p.getByLabel("Search icons").fill("house");
await p.waitForTimeout(400);
await p.locator("[data-icon-option]").first().click();
// Both dialogs show the same grid, and in it "no icon" is the first tile.
await p.locator("[data-icon-option]").nth(1).click();
await p.locator('[data-icon-colour="#2da44e"]').click();
await p.locator("[data-icon-save]").click();
await p.waitForTimeout(2500);
@@ -537,8 +706,17 @@ try {
/* ── a chapter is not a wastebasket ──
* Removing one must never remove what it held: the notes come back to the
* notebook, where they can be seen and filed again. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
* notebook, where they can be seen and filed again.
*
* The one holding notes, named rather than taken as "the first row": a chapter
* is a row in the arrangement like any other and can be anywhere in it. */
const holdings = (await api(p, "GET", "/notes")).body.filter((n) => n.chapter_id);
const doomed = holdings[0]?.chapter_id;
const freedTitles = holdings
.filter((n) => n.chapter_id === doomed)
.map((n) => n.title)
.sort();
await p.locator(`[data-chapter-row="${doomed}"]`).click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Remove chapter", { exact: true }).click();
await p.waitForTimeout(700);
@@ -562,15 +740,15 @@ try {
"the chapter is gone, and only that one",
String(await p.locator("[data-chapter-row]").count())
);
const after = (await titles()).sort().join(",");
const after = (await titles()).sort();
check(
after === "Gartenzaun,Stromrechnung,Wasserrechnung,Zählerstand",
freedTitles.every((t) => after.includes(t)),
"and every note it held is back in the notebook",
after
`${freedTitles.join(",")} → ${after.join(",")}`
);
const freed = (await api(p, "GET", "/notes")).body;
check(
freed.every((n) => !n.chapter_id),
freed.every((n) => n.chapter_id !== doomed),
"with nothing on the server still pointing at a chapter that is gone",
JSON.stringify(freed.map((n) => [n.title, n.chapter_id]))
);
+235 -6
View File
@@ -131,6 +131,35 @@ try {
);
check((await p.locator(".ProseMirror").innerText()).includes("Grundtext"), "plain text survived a reload");
await p.screenshot({ path: `${OUT}/editor-after-reload.png` });
/* ── the sheet is the right size from the first frame ──
* It was not. The stage starts at the canonical page, 1600px tall, and the
* measurement that pulls it back to the pane ran an ordinary effect — a frame
* too late. Every note opened by flashing a long narrow sheet and then
* righting itself. Sampled every frame, because one frame is all it was. */
const settled = await p.locator("[data-page-sheet]").first().evaluate((el) => el.getBoundingClientRect().height);
await p.evaluate(() => {
window.__tall = 0;
const tick = () => {
const el = document.querySelector("[data-page-sheet]");
if (el) window.__tall = Math.max(window.__tall, el.getBoundingClientRect().height);
window.__raf = requestAnimationFrame(tick);
};
tick();
});
await p.locator("button").filter({ hasText: "Ziel" }).first().click();
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
await p.waitForTimeout(1500);
const tallest = await p.evaluate(() => {
cancelAnimationFrame(window.__raf);
return window.__tall;
});
check(
tallest <= settled * 1.15,
"switching notes never draws the sheet at the wrong size, not even for a frame",
`settled ${Math.round(settled)}, tallest ${Math.round(tallest)}`
);
await reopenNote(p, "Quelle");
/* ── how many pages the writing fills ── */
// Pages are a reading aid, not a change to the document: the note stays one
// body, and Markdown has no page. The height of one is the same 3:4 the canvas
@@ -144,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.`);
/* 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();
@@ -223,6 +261,76 @@ try {
"and the gap stays blank — no placeholder printed at the foot of the page"
);
/* ── twenty-one lines, and the same twenty-one everywhere ──
*
* Two claims. First, a page holds the number of lines a ruled page holds —
* it held eighteen on a phone, which is what started this. Second, and the
* one that matters: the phone breaks the text in exactly the same places the
* desktop does. It did not, because the text used to be set in screen pixels:
* a 340-pixel sheet fitted nothing like what a 760-pixel one did, so the same
* note had different lines on different pages depending on what you read it
* on. The text is now laid out on a page of a fixed size and the whole sheet
* scaled, so the only thing a narrow screen changes is how big it looks.
*
* Measured in page units — every length divided by the sheet's own scale —
* because screen pixels are precisely what must not agree. */
const pageUnits = () =>
p.evaluate(() => {
const scale = Number(document.querySelector("[data-text-page-scale]").dataset.textPageScale);
const pm = document.querySelector(".ProseMirror");
const cs = getComputedStyle(pm);
const sheet = document.querySelector("[data-page-sheet]").getBoundingClientRect();
const top = pm.getBoundingClientRect().top;
const blocks = [...pm.children]
.filter((el) => !el.classList.contains("ne-page-spacer"))
.slice(0, 40)
.map((el) => {
const r = el.getBoundingClientRect();
return { y: Math.round((r.top - top) / scale), h: Math.round(r.height / scale) };
});
return {
lines:
(sheet.height / scale - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)) /
parseFloat(cs.lineHeight),
blocks,
};
});
const onDesktop = await pageUnits();
check(
Math.floor(onDesktop.lines) === 21,
"a page holds twenty-one lines",
`${onDesktop.lines.toFixed(2)} fit`
);
const phone = { width: 390, height: 844 };
const desktop = p.viewportSize();
await p.setViewportSize(phone);
await p.waitForTimeout(2000);
/* A phone shows one pane at a time, so the note may have been left behind in
the list when the window narrowed. Open it again before measuring. */
if ((await p.locator("[data-text-page-scale]").count()) === 0) await reopenNote(p, "Langer Text");
await p.waitForSelector("[data-text-page-scale]", { timeout: 15000 });
await p.waitForTimeout(3000);
const onPhone = await pageUnits();
check(
Math.floor(onPhone.lines) === 21,
"on a phone as well — the same page, not a smaller one",
`${onPhone.lines.toFixed(2)} fit`
);
const moved = onDesktop.blocks
.map((b, i) => ({ i, b, o: onPhone.blocks[i] }))
.filter(({ b, o }) => !o || Math.abs(b.y - o.y) > 1 || Math.abs(b.h - o.h) > 1)
.map(({ i, b, o }) => `#${i}: ${b.y}/${b.h} vs ${o ? `${o.y}/${o.h}` : "missing"}`);
check(
onPhone.blocks.length === onDesktop.blocks.length && moved.length === 0,
"and every paragraph falls in the same place on both, to the page unit",
moved.slice(0, 4).join(" | ")
);
await p.setViewportSize(desktop);
await p.waitForTimeout(2000);
if ((await p.locator(".ProseMirror").count()) === 0) await reopenNote(p, "Langer Text");
/* The note is still one document — pages are drawn, not stored. */
const stored = (await api(p, "GET", "/notes")).body[0];
const body = (await api(p, "GET", `/notes/${stored.id}`)).body.content;
@@ -266,6 +374,89 @@ 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
* heading, a list and a closing line stood jammed together while the items of
* the list were further apart than the blocks around them. */
await createNote(p);
await setTitle(p, "Zitat");
await p.locator(".ProseMirror").click();
await p.keyboard.type("> ### Quartalszahlen");
await p.keyboard.press("Enter");
await p.keyboard.type("- Umsatz");
await p.keyboard.press("Enter");
await p.keyboard.type("Gewinn");
await p.keyboard.press("Enter");
await p.keyboard.press("Enter");
await p.keyboard.type("Alles nach Plan.");
await p.waitForTimeout(1500);
const quoted = await p.evaluate(() =>
[...(document.querySelector(".ProseMirror blockquote")?.children || [])].map((el) => {
const r = el.getBoundingClientRect();
return { tag: el.tagName, top: r.top, bottom: r.bottom };
})
);
check(
quoted.map((c) => c.tag).join(",") === "H3,UL,P",
"a quote holds a heading, a list and a paragraph",
quoted.map((c) => c.tag).join(",")
);
const gaps = quoted.slice(1).map((c, i) => Math.round(c.top - quoted[i].bottom));
check(gaps.every((g) => g >= 6), "with room between them, as outside a quote", gaps.join(" | "));
/* And the same divide when it leaves as Markdown: written on consecutive
lines, every other reader folds that closing line into the last bullet. */
const quoteNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Zitat");
const quoteDoc = (await api(p, "GET", `/notes/${quoteNote.id}`)).body.content;
check(/blockquote/.test(quoteDoc), "and it is stored as a quote");
/* ── the typeface follows the account ── */
await p.getByTitle("Account").click();
await p.waitForTimeout(800);
@@ -295,6 +486,44 @@ try {
"and survives a reload"
);
/* ── light or dark is a setting, and it belongs to the account ──
* It used to be a sun in the corner of the sidebar: reached for by accident,
* forgotten by the next device, and gone on every reload. */
const shade = () =>
p.evaluate(() => getComputedStyle(document.querySelector("#root > div") || document.body).backgroundColor);
const wasDark = await shade();
await p.getByTitle("Account").click();
await p.waitForTimeout(900);
check(
(await p.locator("[data-theme-option]").count()) === 2,
"the account panel offers light and dark"
);
await p.locator('[data-theme-option="light"]').click();
await p.waitForTimeout(1500);
await p.keyboard.press("Escape");
await p.waitForTimeout(900);
check((await shade()) !== wasDark, "choosing one repaints the app", `${wasDark} → ${await shade()}`);
const themed = await api(p, "GET", "/auth/me");
check(
themed.body?.preferences?.theme === "light",
"and it is stored on the account, not in this browser",
JSON.stringify(themed.body?.preferences)
);
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(3000);
check((await shade()) !== wasDark, "and it is still there after a reload", await shade());
/* The sun in the sidebar is gone with it — two switches for one setting is one
too many, and that corner is for places to go. Counted where it stood: the
account button's own row, which used to hold two. */
const cornerButtons = await p
.locator('[title="Account"]')
.evaluate((el) => el.parentElement.querySelectorAll("button").length);
check(cornerButtons === 1, "and the sidebar corner holds nothing but the account", `${cornerButtons} buttons`);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
+26
View File
@@ -96,6 +96,23 @@ try {
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
await p.waitForTimeout(1800);
/* A quote holding more than one kind of block. Written on consecutive lines,
Markdown reads the closing sentence as more of the last bullet — so the
blocks inside a quote have to be divided by a quoted blank line. */
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.press("Enter");
await p.keyboard.press("Enter"); // out of the task list again
await p.keyboard.type("> ### Quartalszahlen");
await p.keyboard.press("Enter");
await p.keyboard.type("- Umsatz");
await p.keyboard.press("Enter");
await p.keyboard.type("Gewinn");
await p.keyboard.press("Enter");
await p.keyboard.press("Enter");
await p.keyboard.type("Alles nach Plan.");
await p.waitForTimeout(1800);
await captureDownloads(p);
/* ── single note as Markdown ── */
@@ -114,6 +131,15 @@ try {
check(/\|\s*Spalte\s*\|/.test(md), "the table came along", md.match(/\|.*\|/)?.[0] || "no table row");
check(md.includes("| --- |"), "the table has a separator row so it renders as a table");
check(md.includes("- [x] Erledigte Sache"), "the ticked task is a ticked checkbox");
check(
md.includes("> ### Quartalszahlen\n>\n> - Umsatz"),
"the blocks inside a quote are divided by a quoted blank line",
md.split("\n").filter((l) => l.startsWith(">")).join(" ⏎ ")
);
check(
md.includes("> - Gewinn\n>\n> Alles nach Plan."),
"so the sentence after a quoted list stays a sentence"
);
console.log("--- Markdown ---\n" + md + "\n--- Ende ---");
// The dialog stays up after an export so the result can be read; close it or
+231
View File
@@ -0,0 +1,231 @@
/*
* A provider to sign in at, for the OIDC suite.
*
* Small on purpose: discovery, an authorize page with two buttons, the token
* exchange, and a key set. It is a real OpenID Connect provider in the only
* sense that matters here — it signs a real RS256 ID token with a real key that
* InkNote fetches and checks, so the suite exercises the verification rather
* than stepping around it.
*
* It also does the things a provider should refuse, on request: sign with the
* wrong key, or return a token addressed to somebody else. A check that the
* good case works proves very little on its own.
*
* Bound to every interface, because two different machines talk to it: the
* browser (in this container) and the InkNote server (in another one, reaching
* it through the Docker host). Both must know it under the *same* name, or the
* issuer in the token will not match — see run.sh and docker-compose.dev.yml.
*/
import crypto from "node:crypto";
import http from "node:http";
const b64url = (buf) => Buffer.from(buf).toString("base64url");
const ACCESS_TOKEN = "test-access-token";
const ONE_PIXEL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
const json = (res, body, status = 200) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
/*
* The key this provider signs with, written out rather than generated.
*
* InkNote caches a provider's keys for an hour and only goes back for them when
* it meets a `kid` it does not know — which is right for a real provider, whose
* key does not change between two test runs, and fatal for one that made a new
* key every time: the second run of the day would be refused with a perfectly
* correct "signature does not match".
*
* It is a test key for a test provider on a test port. It signs nothing that
* exists outside this suite.
*/
const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC7T8xvhzB1EVAU
3+A1xOuwCz3OS2ALv9pgpMf8l+Hu6v09Ey3EhxcfGRzh+5c+wLXVD/f7q57VORo3
49fdLUWw9Nb7mKKugEsxOiD3O49jW4C71QILslYxuCkF71JLPFNzVVLiABs+C4zR
PVFmNJIAv27VDiK2w/eEKyNTY0p2776FliQ5U+7VXdM04fqQP0UXjrfcSS2mDBCb
Wa49reQJf7vZy0plyD2dTO/rq2O8UsQoOB+lHYAt4vgjWXdTrf1pcGWJlxO/n/0H
x4XmG0ItUKaD8HTbzO74I8mi/7/9rG2jWP1rK3U1Zozm0X0nhEtlxF7J7rtsFywY
bJ7Zk2hZAgMBAAECggEARdlpHAHm0REGsvIVUxe+6CQZBS/YfikqDzYDma8twoGA
s+DOBWZ2+dLMkXzXlsAYTn6o9AG4tjyYCVV+ruzt+yIidSojKD2RTKcnmeps+xkp
NWImZCQ5vkq1iZAIOSQi3nRn9v0hK471ESXzj9B0zbCVbP/bdBo51+CaSMNTBmyV
0W6kfn+F/ZPh2dJwAnyWyhmAtYItsZUfbh2jrReCY2ttQBnUzfhRXWWsjMpWIRi3
Uxe3Hru0qjHBXDUklchDmy01V/7OCNKvpB7PRgT0OdwYr4ClPDQCx8u7MunGwqL+
UM8NOb+JtC2IUoMtazJUa6EaR+UnkJ6feZzI6LUT/QKBgQD7rRczPaKLn8d4xktl
bKA+yVMvU92Lt7cipB2NNHei2wtNxTlzwmoyE7GdkYvqL0e4xUK0JhI4ho+/sZJh
6FGaRh3um54UXlqBMcidAFqz27VdF34WPFYNLkGv+C758Vi9a2UUTkcxKFkpGasn
Gcg0FwmOJVM5SEzcuSazsKXq8wKBgQC+h5+igKcV3vMNlF5lZ7PrY1DXc65V6n1E
1TtrPn8ikvcGqj84/ZZ4gIjwQjsDu8DISQr/DcITdRpt64YmpCMlv9+0b99P+p5m
0zG1rRxzeSAoQNiZNiVCr1gyNstEZN1c0XYAAMIUukahfRALwrcJHUAkfENwkKUn
baRl7tKagwKBgGRJhfwqLFJc+fx7b+HblkNOsDIkxhx9uaE5KnlxFdc0V93GoG0r
ZGvOAzHZpJpN+tN672if3xTdMfzeAom3LIVAOX8yTZyqOIBQ2TMOyBXxj+SnjtvH
cTbRXuWDDR0V+hmI6P9dber9vAGvs87h7DAtmeYlo3MpB0/RC8YAtyzXAoGADnUi
IuWaSCmTzer9Qrqcx3bQ0ph/mRtjCA8aJCgo44m9Xu8aPwvXGCvAuGJ+MelUVp5c
TEBSAsH3EHI3ysTXueyWYA3BJ0OAM5NBCWUqbIS7cPKCvRWDvEyr55uKzxTMS1fF
mXh1Qbdk1qozxoCu/O0QSfR39Zm0PoquV+HIb1sCgYBOB+7vCS89FeAtsZ0tvqhq
c9mYiRxBBAd1li4tmp3KJW6qY4kceeXSmcTn5WHKeB7qft3fceOGF2UyJAjvllkB
wH6nPXH9qF4cf5PWLg7Mzwpa8t+sVdmVdwn5Sqx/5ms5hXIeCMEfko944KTmJjCN
wBfCzAoruyg8JjnOuMJ1dw==
-----END PRIVATE KEY-----`;
export async function startFakeIdp({ port = 3011, issuer, clientId, clientSecret }) {
const real = {
privateKey: crypto.createPrivateKey(TEST_PRIVATE_KEY),
publicKey: crypto.createPublicKey(TEST_PRIVATE_KEY),
};
const other = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); // never published
const kid = "test-key-1";
/* Who the next sign-in is, and what the provider should do wrong this time.
Set by the suite through POST /who before each attempt. */
let identity = {
sub: "subject-1",
preferred_username: "someone",
name: "Some One",
picture: `${issuer}/picture.png`,
};
let misbehave = { wrongKey: false, wrongAudience: false };
const codes = new Map(); // code -> { challenge, nonce, identity }
const idToken = ({ nonce, sub, picture, ...rest }) => {
// `picture` is deliberately left out of the token — it is offered at
// /userinfo instead, which is what a lean provider does and what InkNote
// used never to ask for.
const key = misbehave.wrongKey ? other.privateKey : real.privateKey;
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid }));
const now = Math.floor(Date.now() / 1000);
const payload = b64url(
JSON.stringify({
iss: issuer,
sub,
aud: misbehave.wrongAudience ? "somebody-else" : clientId,
exp: now + 300,
iat: now,
nonce,
...rest,
})
);
const signature = crypto.sign("sha256", Buffer.from(`${header}.${payload}`), key);
return `${header}.${payload}.${b64url(signature)}`;
};
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, issuer);
if (url.pathname === "/.well-known/openid-configuration") {
return json(res, {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
userinfo_endpoint: `${issuer}/userinfo`,
response_types_supported: ["code"],
id_token_signing_alg_values_supported: ["RS256"],
});
}
/* A picture to be somebody's face — one green pixel, blown up by the
browser. The point is the journey, not the portrait. */
if (url.pathname === "/picture.png") {
// Behind the provider's own door, like a real one's: without the access
// token there is nothing here.
if (req.headers.authorization !== `Bearer ${ACCESS_TOKEN}`) {
return json(res, { error: "unauthorized" }, 401);
}
res.writeHead(200, { "Content-Type": "image/png" });
return res.end(ONE_PIXEL_PNG);
}
/* The rest of what is known about somebody. A provider is free to keep the
picture here rather than in the token, and several do. */
if (url.pathname === "/userinfo") {
if (req.headers.authorization !== `Bearer ${ACCESS_TOKEN}`) {
return json(res, { error: "unauthorized" }, 401);
}
return json(res, { sub: identity.sub, ...(identity.picture ? { picture: identity.picture } : {}) });
}
if (url.pathname === "/jwks") {
const jwk = real.publicKey.export({ format: "jwk" });
return json(res, { keys: [{ ...jwk, kid, use: "sig", alg: "RS256" }] });
}
/* The suite says who is about to sign in, and how the provider should behave. */
if (url.pathname === "/who" && req.method === "POST") {
const body = await new Promise((resolve) => {
let d = "";
req.on("data", (c) => (d += c));
req.on("end", () => resolve(JSON.parse(d || "{}")));
});
identity = { ...identity, ...(body.identity || {}) };
misbehave = { wrongKey: false, wrongAudience: false, ...(body.misbehave || {}) };
return json(res, { ok: true, identity, misbehave });
}
if (url.pathname === "/authorize") {
const back = url.searchParams.get("redirect_uri");
const state = url.searchParams.get("state");
const code = crypto.randomBytes(16).toString("hex");
codes.set(code, {
challenge: url.searchParams.get("code_challenge"),
nonce: url.searchParams.get("nonce"),
identity,
});
// A page with a button rather than an immediate redirect: this is where a
// person types a password or taps a passkey, and the suite should have to
// go through it like one.
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end(`<!doctype html><meta charset="utf-8"><title>Test IdP</title>
<body style="font-family:sans-serif;padding:40px">
<h1>Test identity provider</h1>
<p>Signing in as <b>${identity.preferred_username}</b></p>
<a data-idp-approve href="${back}?code=${code}&state=${encodeURIComponent(state)}">Approve</a>
<a data-idp-deny href="${back}?error=access_denied&state=${encodeURIComponent(state)}">Deny</a>
</body>`);
}
if (url.pathname === "/token" && req.method === "POST") {
const body = await new Promise((resolve) => {
let d = "";
req.on("data", (c) => (d += c));
req.on("end", () => resolve(new URLSearchParams(d)));
});
const entry = codes.get(body.get("code"));
codes.delete(body.get("code")); // one exchange per code, like the real thing
if (!entry) return json(res, { error: "invalid_grant" }, 400);
if (body.get("client_secret") !== clientSecret) return json(res, { error: "invalid_client" }, 401);
const verifier = body.get("code_verifier") || "";
const computed = b64url(crypto.createHash("sha256").update(verifier).digest());
if (computed !== entry.challenge) return json(res, { error: "invalid_grant" }, 400);
return json(res, {
access_token: ACCESS_TOKEN,
token_type: "Bearer",
expires_in: 300,
id_token: idToken({ nonce: entry.nonce, ...entry.identity }),
});
}
json(res, { error: "not_found" }, 404);
});
await new Promise((resolve) => server.listen(port, "0.0.0.0", resolve));
return {
/** Sets who signs in next, and whether the provider misbehaves. */
async setNext(body) {
const r = await fetch(`${issuer}/who`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return r.json();
},
async stop() {
await new Promise((resolve) => server.close(resolve));
},
};
}
+17 -1
View File
@@ -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 mine = async () => {
const r = await fetch("/api/notes", {
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
});
return (await r.json())[0].id;
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
+326
View File
@@ -0,0 +1,326 @@
/* Signing in through an identity provider.
*
* The provider is real enough to be worth testing against: it signs a real
* RS256 token with a key InkNote fetches and checks (e2e/fakeIdp.mjs). So the
* claims under test are not "a button exists" but:
*
* • the whole redirect journey ends in a signed-in app, with an account that
* did not exist before — and never an administrator;
* • the same person coming back a second time is the same account, and an
* account that already existed under that name is linked rather than
* duplicated;
* • the token in the URL is a one-time ticket, not the session token, and the
* address is cleaned behind it;
* • a refusal at the provider, a token signed with the wrong key and a token
* addressed to somebody else all end at the sign-in screen, signed out.
*
* The last group is the point of the exercise. A sign-in that only works is not
* evidence of anything.
*/
import { BASE, api, check, launch, newPage, report } from "./lib.mjs";
import { startFakeIdp } from "./fakeIdp.mjs";
const ISSUER = "http://idp.test:3011";
const ADMIN = {
user: process.env.INKNOTE_ADMIN || "marc",
pass: process.env.INKNOTE_ADMIN_PASS || "geheim123",
};
const unique = Math.floor(Math.random() * 1e6);
const idp = await startFakeIdp({
port: 3011,
issuer: ISSUER,
clientId: "inknote-test",
clientSecret: "test-secret",
});
const browser = await launch();
let adminPage;
const madeUsers = [];
try {
/* An administrator, to look at the accounts this creates and to clean up. */
adminPage = await newPage(browser);
await adminPage.goto(BASE, { waitUntil: "networkidle" });
await adminPage.getByPlaceholder("Username").fill(ADMIN.user);
await adminPage.getByPlaceholder("Password").fill(ADMIN.pass);
await adminPage.getByRole("button", { name: "Sign In" }).click();
await adminPage.waitForSelector("text=Dashboard", { timeout: 20000 });
const accounts = async () => (await api(adminPage, "GET", "/admin/users")).body;
const accountNamed = async (name) => (await accounts()).find((u) => u.username === name);
/* ── the offer ── */
const page = await newPage(browser);
const seen = [];
page.on("framenavigated", (f) => f === page.mainFrame() && seen.push(f.url()));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
const methods = await page.evaluate(async () => (await fetch("/api/auth/methods")).json());
check(methods?.oidc?.enabled === true, "the server says there is a second way in", JSON.stringify(methods));
check(
!JSON.stringify(methods).includes("secret") && !JSON.stringify(methods).includes("client"),
"and says nothing else about it to a stranger",
JSON.stringify(methods)
);
check((await page.locator("[data-oidc-signin]").count()) === 1, "the sign-in screen offers it");
check(
(await page.locator("[data-oidc-signin]").innerText()).includes("Test IdP"),
"under the name it was given",
await page.locator("[data-oidc-signin]").innerText()
);
check((await page.getByPlaceholder("Password").count()) === 1, "and the password form is still there");
/* ── someone the app has never seen ── */
const newcomer = `sso-newcomer-${unique}`;
await idp.setNext({
identity: { sub: `subject-new-${unique}`, preferred_username: newcomer, name: "A Newcomer" },
});
await page.locator("[data-oidc-signin]").click();
await page.waitForLoadState("networkidle");
check(page.url().startsWith(`${ISSUER}/authorize`), "pressing it leads to the provider", page.url());
const authorizeUrl = new URL(page.url());
check(
authorizeUrl.searchParams.get("code_challenge_method") === "S256" &&
!!authorizeUrl.searchParams.get("state") &&
!!authorizeUrl.searchParams.get("nonce"),
"with PKCE, a state and a nonce",
authorizeUrl.search.slice(0, 120)
);
await page.locator("[data-idp-approve]").click();
await page.waitForSelector("text=Dashboard", { timeout: 20000 });
await page.waitForTimeout(1500);
check(true, "approving there signs in here");
const created = await accountNamed(newcomer);
madeUsers.push(created?.id);
check(!!created, "an account was made for them", newcomer);
check(created?.isAdmin === false, "and it is not an administrator");
check(created?.signsInWithProvider === true, "and has no password of its own");
check(created?.linkedToProvider === true, "and is marked as belonging to the provider");
/* The picture belongs where the person icon was: in the account button at the
top of the sidebar. That is the place it is looked for. */
check(
(await page.locator('[title="Account"] [data-avatar]').count()) === 1,
"the account button in the sidebar wears the picture instead of the person icon"
);
/* ── what the two people involved see ──
* The one signing in: no password tab, because there is no password behind
* it. The administrator: which accounts these are, without asking anybody. */
await page.getByTitle("Account").click();
await page.waitForTimeout(900);
const tabs = await page.locator("button").allInnerTexts();
check(
!(await page.getByRole("button", { name: "Password", exact: true }).count()),
"the account panel offers no password to change",
tabs.join(" | ").slice(0, 120)
);
check(
(await page.locator("[data-account-sso]").count()) === 1,
"and says which provider it signs in through instead"
);
check(
(await page.locator("[data-account-sso]").innerText()).includes("Test IdP"),
"by name",
await page.locator("[data-account-sso]").innerText()
);
/* The picture the provider has of them. It is fetched from *this* server with
a bearer token — an `<img src>` cannot carry one, so the bytes come through
the API and become an object URL. Which is also why "it is on the page" is
not enough: ask the browser whether it actually decoded. */
const drawn = await page.locator("[data-avatar]").first().evaluate((img) => ({
src: img.currentSrc || img.src,
width: img.naturalWidth,
}));
/* The provider keeps this one out of the token and behind its own door, which
is what a lean provider does — so this passing means both the second
question to /userinfo and the token sent with the picture request. */
check(drawn.width > 0, "the profile picture from the provider is drawn", JSON.stringify(drawn));
check(
drawn.src.startsWith("blob:"),
"from bytes this server handed over, not from the provider's address",
drawn.src.slice(0, 40)
);
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
await adminPage.getByTitle("Account").click();
await adminPage.waitForTimeout(700);
await adminPage.getByRole("button", { name: "Administration" }).click();
await adminPage.waitForTimeout(1500);
const ssoRow = adminPage.locator(`text=${newcomer}`).locator("xpath=ancestor::div[1]");
check(
(await adminPage.locator("[data-user-sso]").count()) >= 1,
"the administrator sees which accounts come from the provider",
`${await adminPage.locator("[data-user-sso]").count()} marked`
);
check(
(await ssoRow.innerText()).includes("SSO"),
"on the row of the account itself",
(await ssoRow.innerText()).replace(/\n/g, " ").slice(0, 100)
);
check(
(await adminPage.locator("body").innerText()).includes("Give a password"),
"and is offered to give it a password rather than to reset one"
);
check(
(await adminPage.locator(`[data-avatar="${created.id}"]`).count()) === 1,
"and sees their picture beside the name"
);
await adminPage.keyboard.press("Escape");
await adminPage.waitForTimeout(400);
await adminPage.keyboard.press("Escape");
await adminPage.waitForTimeout(600);
/* The address is cleaned, and what was in it was a ticket, not the token. */
const returned = seen.find((u) => u.includes("oidc="));
check(!!returned, "the provider sent the browser home with something in the URL");
check(
!!returned && !returned.includes(await page.evaluate(() => localStorage.getItem("inknote_token"))),
"which was not the session token"
);
check(!page.url().includes("oidc"), "and the address was cleaned afterwards", page.url());
/* A session lives in two places — IndexedDB and localStorage — so "signed out
again" means a browser that never saw this one. Every case below gets one. */
const signedOutBrowser = () => newPage(browser);
/* ── the ticket is single use ── */
const replay = await signedOutBrowser();
await replay.goto(returned, { waitUntil: "networkidle" });
await replay.waitForTimeout(2000);
check(
(await replay.getByPlaceholder("Password").count()) === 1,
"the same ticket a second time is worth nothing",
replay.url()
);
await replay.context().close();
/* ── the same person again is the same account ── */
const again = await signedOutBrowser();
await again.goto(BASE, { waitUntil: "networkidle" });
await again.locator("[data-oidc-signin]").click();
await again.waitForLoadState("networkidle");
await again.locator("[data-idp-approve]").click();
await again.waitForSelector("text=Dashboard", { timeout: 20000 });
await again.waitForTimeout(1200);
const afterSecond = (await accounts()).filter((u) => u.username.startsWith(`sso-newcomer-${unique}`));
check(afterSecond.length === 1, "signing in again does not make a second account", `${afterSecond.length}`);
await again.context().close();
/* ── an account that already existed here ── */
const existing = `sso-existing-${unique}`;
const madeByAdmin = await api(adminPage, "POST", "/admin/users", {
username: existing,
password: "e2epass123",
displayName: "Already Here",
});
madeUsers.push(madeByAdmin.body?.id);
check(madeByAdmin.status === 201, "an ordinary account exists under that name");
await idp.setNext({
identity: { sub: `subject-existing-${unique}`, preferred_username: existing, name: "Already Here" },
});
const second = await signedOutBrowser();
await second.goto(BASE, { waitUntil: "networkidle" });
await second.locator("[data-oidc-signin]").click();
await second.waitForLoadState("networkidle");
await second.locator("[data-idp-approve]").click();
await second.waitForSelector("text=Dashboard", { timeout: 20000 });
await second.waitForTimeout(1200);
/* An account that had a password and was then linked: it keeps the password,
and still manages it nowhere but through an administrator. */
await second.getByTitle("Account").click();
await second.waitForTimeout(900);
check(
!(await second.getByRole("button", { name: "Password", exact: true }).count()),
"an account linked to the provider is offered no password tab either"
);
check(
(await second.locator("[data-account-sso]").innerText()).includes("administrator"),
"and is told who changes the one it has",
await second.locator("[data-account-sso]").innerText()
);
await second.keyboard.press("Escape");
await second.waitForTimeout(400);
const linked = (await accounts()).filter((u) => u.username === existing);
check(linked.length === 1, "the identity is linked to it rather than duplicated", `${linked.length}`);
check(
linked[0]?.signsInWithProvider === false,
"and it keeps the password it already had — one way in does not close the other"
);
check(
linked[0]?.linkedToProvider === true,
"while still being shown as one that can use the provider"
);
await second.context().close();
/* ── the ways it must fail ── */
const signedOut = async (page) => (await page.getByPlaceholder("Password").count()) === 1;
const denied = await signedOutBrowser();
await denied.goto(BASE, { waitUntil: "networkidle" });
await denied.locator("[data-oidc-signin]").click();
await denied.waitForLoadState("networkidle");
await denied.locator("[data-idp-deny]").click();
await denied.waitForTimeout(2000);
check(await signedOut(denied), "a refusal at the provider ends signed out");
check(
(await denied.locator("body").innerText()).includes("did not allow"),
"and says so on the sign-in screen",
(await denied.locator("body").innerText()).slice(0, 120)
);
await denied.context().close();
const refusedBy = async (label, misbehave) => {
await idp.setNext({
identity: { sub: `subject-forged-${unique}`, preferred_username: `sso-forged-${unique}`, name: "Forged" },
misbehave,
});
const forged = await signedOutBrowser();
await forged.goto(BASE, { waitUntil: "networkidle" });
await forged.locator("[data-oidc-signin]").click();
await forged.waitForLoadState("networkidle");
await forged.locator("[data-idp-approve]").click();
await forged.waitForTimeout(2000);
check(await signedOut(forged), label);
check(
!(await accounts()).some((u) => u.username === `sso-forged-${unique}`),
`${label} — and no account was made from it`
);
await forged.context().close();
};
await refusedBy("a token signed with a key the provider does not publish is refused", { wrongKey: true });
await refusedBy("a token addressed to another client is refused", { wrongAudience: true });
/* ── an account made this way has no password ── */
const tryPassword = await adminPage.evaluate(async (name) => {
const r = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: name, password: "" }),
});
return r.status;
}, newcomer);
check(tryPassword === 401, "and cannot be signed into with a password", `${tryPassword}`);
} catch (e) {
check(false, "suite ran to the end", e.stack || e.message);
} finally {
/* Tidy up whatever the run made, including the accounts the provider caused. */
try {
for (const id of madeUsers.filter(Boolean)) await api(adminPage, "DELETE", `/admin/users/${id}`);
await adminPage?.context().close();
} catch {
/* the report matters more than the cleanup */
}
await browser.close();
await idp.stop();
process.exit(report());
}
+60 -1
View File
@@ -303,6 +303,46 @@ try {
await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
/* ── one line under another ──
* Two lines in the same gap carry the same position, so swapping them changes
* nothing but their sequence. The write was computed correctly and then thrown
* away by a "did anything change?" test that sorted before comparing — and a
* sorted comparison cannot see a sequence. The line sprang back every time. */
await p.locator("[data-add-separator]").click();
await p.waitForTimeout(1800);
const lines = () =>
p.locator("[data-separator]").evaluateAll((els) => els.map((e) => e.innerText.trim()));
check((await p.locator("[data-separator]").count()) === 2, "a second line can be drawn");
// First bring them together, so what follows is the case that was broken:
// two lines in one gap, where only their sequence tells them apart.
await drag(p.locator("[data-separator]").first(), p.locator("[data-separator]").nth(1));
const lineOrder = await lines();
await drag(p.locator("[data-separator]").first(), p.locator("[data-separator]").nth(1));
const swapped = await lines();
check(
swapped[0] === lineOrder[1] && swapped[1] === lineOrder[0],
"a line dropped on another in the same gap goes under it",
`${lineOrder.join(" | ")} → ${swapped.join(" | ")}`
);
// And it was written, not only drawn.
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1800);
check(
(await lines()).join(" | ") === swapped.join(" | "),
"and stays under it after a reload",
(await lines()).join(" | ")
);
// Back to one line, so what follows reads the notebook it expects.
await p.locator("[data-separator]").first().click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Remove separator").click();
await p.waitForTimeout(600);
await p.getByRole("button", { name: "Remove", exact: true }).click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 1, "and one of them can be taken away again");
/* ── a new note goes on top, and takes nothing with it ──
*
* It used to be given no position at all and left to the tie-break, which put
@@ -344,6 +384,25 @@ try {
(await layout()).join(" | ")
);
/* ── and taking one away is the same promise, in reverse ──
* A line's position counts the rows above it, so deleting a note above one
* left the line counting rows that were no longer there: everything under it
* appeared to jump up past the line, which is what made deleting feel as
* though it scrambled the notebook. */
const beforeDelete = await layout();
await p.locator("[data-note-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-context-menu]").getByText("Move to Trash", { exact: true }).click();
await p.waitForTimeout(700);
await p.locator("[data-confirm-dialog]").getByRole("button", { name: "Move to Trash", exact: true }).click();
await p.waitForTimeout(2500);
const arrangedRows = await layout();
check(
arrangedRows.join(" | ") === beforeDelete.slice(1).join(" | "),
"deleting a note takes out only that note — the lines keep the rows they were drawn above",
`${beforeDelete.join(" | ")} → ${arrangedRows.join(" | ")}`
);
/* ── the choices reach the server, and the screen ──
* A notebook divided into three parts is easier to read at a glance when the
* lines differ; grey-on-grey they all look like the same line repeated. */
@@ -390,7 +449,7 @@ try {
"and the question is gone with it, not left standing over the app"
);
check(
(await titles()).join(",") === afterNew.filter((row) => !row.startsWith("──")).join(","),
(await titles()).join(",") === arrangedRows.filter((row) => !row.startsWith("──")).join(","),
"with every note still there",
(await titles()).join(", ")
);
+121
View File
@@ -270,6 +270,127 @@ try {
await p.getByTitle("Zoom out").click();
await p.waitForTimeout(1500);
/* ── the document, and nothing else ──
* The app laid out as an overlay over itself: the document takes the whole
* browser window, and the sidebar, the note list and the title bar go. The
* browser's own full screen is deliberately not asked for — this is about
* giving the page to the document, not the screen. */
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
* and all — and that the spot under the pointer is the spot that stays put,
* which is the whole difference between a viewer that feels steady and one
* that jumps to the top of the page at every step. */
const zoomNow = () => p.locator("[data-pdf-zoom]").evaluate((el) => Number(el.dataset.pdfZoom));
const pageRect = () =>
p.locator('[data-pdf-page="1"]').evaluate((el) => {
const r = el.getBoundingClientRect();
return { top: r.top, height: r.height };
});
const firstPage = await p.locator('[data-pdf-page="1"]').boundingBox();
// A point well inside the page, and low enough that holding it still means
// scrolling rather than doing nothing.
const at = { x: firstPage.x + firstPage.width * 0.5, y: firstPage.y + firstPage.height * 0.6 };
const beforeRect = await pageRect();
const fraction = (at.y - beforeRect.top) / beforeRect.height;
const zoomBefore = await zoomNow();
const wheeled = await p.evaluate(
([x, y]) => {
const el = document.elementFromPoint(x, y);
let prevented = null;
const spy = (e) => (prevented = e.defaultPrevented);
// Added after the viewer's own listener, so it runs after it and can see
// whether the default was called off.
document.addEventListener("wheel", spy, { passive: true });
el.dispatchEvent(
new WheelEvent("wheel", { deltaY: -120, ctrlKey: true, bubbles: true, cancelable: true, clientX: x, clientY: y })
);
document.removeEventListener("wheel", spy);
return prevented;
},
[at.x, at.y]
);
await p.waitForTimeout(2000);
check((await zoomNow()) > zoomBefore, "Ctrl and the wheel zoom in", `${zoomBefore} → ${await zoomNow()}`);
check(wheeled === true, "and the browser is stopped from zooming itself instead", `defaultPrevented ${wheeled}`);
const afterRect = await pageRect();
const held = afterRect.top + fraction * afterRect.height;
check(
Math.abs(held - at.y) < 12,
"and the spot under the pointer stays under the pointer",
`${Math.round(at.y)} → ${Math.round(held)}`
);
await p.evaluate(
([x, y]) => {
document
.elementFromPoint(x, y)
.dispatchEvent(
new WheelEvent("wheel", { deltaY: 120, ctrlKey: true, bubbles: true, cancelable: true, clientX: x, clientY: y })
);
},
[at.x, at.y]
);
await p.waitForTimeout(1800);
check((await zoomNow()) === zoomBefore, "and the other way out again", `${await zoomNow()}`);
/* The buttons hold something else still: the page being read. "Show me this
page larger" should not answer by moving to another one. */
await p.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded();
await p.waitForTimeout(1200);
const readingBefore = await p.locator("[data-pdf-position]").innerText();
await p.getByTitle("Zoom in").click();
await p.waitForTimeout(2200);
check(
(await p.locator("[data-pdf-position]").innerText()) === readingBefore,
"zooming from the buttons keeps the page being read",
`${readingBefore} → ${await p.locator("[data-pdf-position]").innerText()}`
);
await p.getByTitle("Zoom out").click();
await p.waitForTimeout(1500);
/* ── hiding it ── */
await p.locator("[data-pdf-hide-ink]").click();
await p.waitForTimeout(600);
+2 -1
View File
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
URL="${INKNOTE_URL:-http://localhost:3000}"
SUITES=("$@")
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise 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
@@ -32,6 +32,7 @@ for s in "${SUITES[@]}"; do
echo
echo "═══ $s ═══"
docker run --rm --network host \
--add-host idp.test:127.0.0.1 \
-v "$PWD:/w" -v "$PWD/shots:/out" -w /w \
-e INKNOTE_URL="$URL" \
-e INKNOTE_ADMIN="${INKNOTE_ADMIN:-marc}" \
+143
View File
@@ -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());
}
+32
View File
@@ -120,6 +120,38 @@ try {
});
check(!/ne-page-spacer/.test(body || ""), "and no page furniture reaches the stored note");
check(/table/.test(body || ""), "which does hold the table itself", String(body || "").slice(0, 60));
/* ── the edge being dragged is drawn on the cell, not in it ──
* The editor marks the column under the pointer with a small div in each of
* its cells. Positioned along the cell's right edge it painted the cell's own
* text a second time, squeezed into the two pixels under the bar, one letter
* to a line — an absolutely positioned child inside a collapsed-border table
* cell sends Chromium down another painting path. The div is never drawn now;
* it only says which cells the column is made of. */
const cell = await p.locator(".ProseMirror table th").first().boundingBox();
await p.mouse.move(cell.x + cell.width - 1, cell.y + cell.height / 2);
await p.waitForTimeout(500);
const edge = await p.evaluate(() => {
const marks = [...document.querySelectorAll(".column-resize-handle")];
const host = marks[0]?.parentElement;
return {
marks: marks.length,
drawn: marks.map((m) => getComputedStyle(m).display),
onCell: host ? getComputedStyle(host).boxShadow : "",
painted: marks.every((m) => m.getBoundingClientRect().width === 0),
};
});
check(edge.marks > 0, "holding over a column edge marks that column", JSON.stringify(edge.marks));
check(
edge.drawn.every((d) => d === "none") && edge.painted,
"the marker itself is never painted",
JSON.stringify(edge.drawn)
);
check(
/inset/.test(edge.onCell) && edge.onCell !== "none",
"and the line is drawn on the cell that carries it",
edge.onCell
);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
+14 -1
View File
@@ -38,7 +38,20 @@
</script>
<style>
body { margin: 0; padding: 0; overflow: hidden; overscroll-behavior: none; }
#root { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); height: 100vh; }
/* --honeybar-h is set by the HoneyOS bar while it is on the page, and is
0 when it is not. The bar sits above the app and pushes it down, so the
app has that much less room; body has overflow:hidden, which means the
difference would not scroll but simply be cut off at the bottom.
It also covers the notch itself, so the padding above must give way —
otherwise the gap is counted twice. max() keeps that from going
negative when there is no bar. */
#root {
box-sizing: border-box;
padding: max(0px, calc(env(safe-area-inset-top) - var(--honeybar-h, 0px)))
env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
height: calc(100vh - var(--honeybar-h, 0px));
}
#splash { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: #0d1117; z-index: 9999; transition: opacity 0.3s; }
#splash.hide { opacity: 0; pointer-events: none; }
#splash .logo { width: 64px; height: 64px; border-radius: 18px; background: linear-gradient(135deg, #388bfd, #a855f7); display: flex; align-items: center; justify-content: center; font-size: 32px; animation: pulse 1.5s infinite; }
+147 -16
View File
@@ -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";
@@ -29,7 +30,6 @@ import { ChapterNotes, NoteList } from "./features/notes/NoteList.jsx";
import {
DEFAULT_NOTEBOOK_SORT,
DEFAULT_SORT,
canReorder,
sortModesFor,
sortNotes,
separatorsFor,
@@ -51,6 +51,44 @@ const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"];
const EMPTY_BODY = { content: "", drawingData: null, drawingStrokes: null };
/** What went wrong out there, said in words rather than in the slug. */
const SIGN_IN_FAILURES = {
provider_refused: "The identity provider did not allow that sign-in.",
account_taken: "That identity belongs to a different account here.",
state_unknown: "That sign-in took too long. Try again.",
provider_unreachable: "The identity provider could not be reached.",
};
/**
* Coming back from the identity provider.
*
* The provider sends the browser to the app with a one-time ticket rather than
* with a token — a token in a URL lands in the history, in the proxy's log and
* in the referrer of the next request. The ticket is swapped here, and the
* address is cleaned either way: reloading the page must not try to redeem a
* ticket that is already spent, and a bookmarked address must not carry one.
*
* Answers with the signed-in user, or null when there was nothing to do.
*/
async function claimProviderSignIn(reportFailure) {
const params = new URLSearchParams(window.location.search);
const ticket = params.get("oidc");
const failure = params.get("oidc_error");
if (!ticket && !failure) return null;
window.history.replaceState({}, "", window.location.pathname);
if (failure) {
reportFailure(SIGN_IN_FAILURES[failure] || "That sign-in could not be completed. Try again.");
return null;
}
try {
return await sync.claimOidcTicket(ticket);
} catch {
reportFailure("That sign-in could not be completed. Try again.");
return null;
}
}
/**
* Root of the application.
*
@@ -59,7 +97,19 @@ const EMPTY_BODY = { content: "", drawingData: null, drawingStrokes: null };
* to `useNoteData`. Rendering is delegated to the three panes below.
*/
export default function App() {
const [dark, setDark] = useState(true);
/* The theme lives on the account, and a copy lives here.
*
* The account is the truth — it should follow you to the phone, like the
* typeface — but it arrives a moment after the page does, and a page that
* starts dark and turns light is worse than one that guesses right. So the
* browser remembers what it drew last time and starts there. */
const [dark, setDark] = useState(() => {
try {
return localStorage.getItem("inknote_theme") !== "light";
} catch {
return true; // private mode: the default is as good as anything
}
});
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [syncStatus, setSyncStatus] = useState("synced");
@@ -77,6 +127,13 @@ export default function App() {
* question. It used to be answered silently by "a chapter is open", so a new
* note went into the chapter however far away the user had since clicked. */
const [inChapter, setInChapter] = useState(false);
/* Rearranging is a mode, and it belongs to the screen rather than to one list
on it: a chapter shown beside or under the notebook is a second list, and
one switch for both is what "I am rearranging now" means. It ends by itself
when the list is left — coming back to a notebook to find every row wearing
a handle, because it was rearranged last week, is not where anybody left
off. */
const [reordering, setReordering] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showNewNb, setShowNewNb] = useState(false);
@@ -109,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.
@@ -127,11 +193,30 @@ export default function App() {
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
// What the account says wins as soon as it is known.
useEffect(() => {
const theme = user?.preferences?.theme;
if (theme) setDark(theme === "dark");
}, [user?.preferences?.theme]);
useEffect(() => {
try {
localStorage.setItem("inknote_theme", dark ? "dark" : "light");
} catch {
/* nothing to remember with; the account still holds it */
}
}, [dark]);
/* Why a sign-in through the provider failed, shown on the sign-in screen —
the failure happens while the browser is away, so there is nobody left
holding the request to report it to. */
const [signInError, setSignInError] = useState("");
/* ── Session ── */
useEffect(() => {
(async () => {
try {
const u = await sync.restoreSession();
const u = (await claimProviderSignIn(setSignInError)) || (await sync.restoreSession());
if (u) {
setUser(u);
await data.reload();
@@ -245,6 +330,12 @@ export default function App() {
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, listChapter),
[data, activeFolder, listChapter]
);
/* And the same for the chapter shown beside or under the list, which is a
second list on the same screen with an arrangement of its own. */
const rearrangeInChapter = useCallback(
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
[data, activeFolder, activeChapter]
);
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
const isTrashView = activeFolder === "trash";
@@ -356,6 +447,21 @@ export default function App() {
const isSearching = searchQuery.trim().length > 0;
/* The order a chapter's own notes are in — its own setting, chosen in its
dialog, and the hand-made one until it is given another. */
const chapterSort = openChapter?.sortMode || DEFAULT_NOTEBOOK_SORT;
/* Whether an arrangement is in effect, and so whether there is anything to
rearrange. Searching suspends it either way — dragging against a narrowed
list would renumber notes it is hiding. */
const listReorderable =
!!activeNotebook && !isSearching && (listChapter ? chapterSort === "manual" : sortMode === "manual");
const chapterReorderable = !!activeNotebook && !isSearching && !!activeChapter && chapterSort === "manual";
const listKey = `${activeFolder}:${listChapter ?? ""}`;
useEffect(() => {
setReordering(false);
}, [listKey, listReorderable]);
const filteredNotes = useMemo(() => {
let list;
if (activeFolder === "trash") list = trashedNotes;
@@ -367,6 +473,14 @@ export default function App() {
look like it had done nothing. */
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
/* A chapter carries its own order.
*
* The notebook's setting governs the notebook's own list; a chapter is a
* place someone made and filled, so it keeps its own — hand-made unless it
* was told otherwise, because filing a note into one only means something if
* it stays where it was put. */
const mode = listChapter ? chapterSort : sortMode;
if (searchQuery.trim()) {
// A search reads every note's text, so results keep the index's relevance
// order instead of being re-sorted by date.
@@ -378,7 +492,7 @@ export default function App() {
const q = searchQuery.toLowerCase();
return list.filter((n) => n.title.toLowerCase().includes(q));
}
return sortNotes(list, sortMode);
return sortNotes(list, mode);
}, [
activeFolder,
listChapter,
@@ -387,13 +501,16 @@ export default function App() {
activeNotes,
trashedNotes,
sortMode,
chapterSort,
]);
/* The notes of the chapter being looked into, in the same order they would
have in a list of their own. */
/* The notes of the chapter being looked into, in the order it is set to. */
const chapterNotes = useMemo(
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []),
[activeChapter, activeNotes, sortMode]
() =>
activeChapter
? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), chapterSort)
: [],
[activeChapter, activeNotes, chapterSort]
);
/* And the lines drawn inside it, which are its own — the notebook's stay in
the notebook, and each chapter's stay in that chapter. */
@@ -826,7 +943,7 @@ export default function App() {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
height: "calc(100vh - var(--honeybar-h, 0px))",
background: T.bg,
color: T.text,
fontFamily: FONT_UI,
@@ -837,7 +954,7 @@ export default function App() {
</div>
);
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} initialError={signInError} />;
// On a phone the list and the editor take turns; on a desktop both are shown.
/* A chapter's notes, unfolded under its own row. Indented and against a rule,
@@ -849,9 +966,15 @@ export default function App() {
const chapterContents = (compact) => (
<ChapterNotes
T={T}
chapterId={activeChapter}
notes={chapterNotes}
separators={chapterSeparators}
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
reorderable={chapterReorderable}
/* One switch for the screen: turning rearranging on in the notebook's
list gives the chapter beside it handles too. Two switches for two
lists that are looked at together would be one too many. */
moving={reordering && chapterReorderable}
onReorder={rearrangeInChapter}
activeNote={activeNote}
onSeparatorContextMenu={separatorContextMenu}
onSelectNote={openNote}
@@ -887,7 +1010,7 @@ export default function App() {
<div
style={{
display: "flex",
height: "100vh",
height: "calc(100vh - var(--honeybar-h, 0px))",
width: "100vw",
overflow: "hidden",
background: T.bg,
@@ -912,8 +1035,8 @@ export default function App() {
<Sidebar
T={T}
user={user}
dark={dark}
onToggleDark={() => setDark(!dark)}
isMobile={isMobile}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
@@ -967,7 +1090,9 @@ export default function App() {
sortMode={sortMode}
sortModes={sortModesFor(!!activeNotebook)}
onSortModeChange={setSortMode}
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
reorderable={listReorderable}
reordering={reordering}
onToggleReorder={() => setReordering((v) => !v)}
separators={separators}
onAddSeparator={addSeparator}
onSeparatorContextMenu={separatorContextMenu}
@@ -983,7 +1108,7 @@ export default function App() {
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
onReorder={rearrangeNotes}
onFileInto={fileIntoChapter}
listKey={`${activeFolder}:${listChapter ?? ""}`}
listKey={listKey}
isCurrent={showsWhere ? !inChapter : null}
onFocusList={showsWhere ? () => setInChapter(false) : undefined}
newNotePlace={
@@ -1189,7 +1314,13 @@ export default function App() {
onSave={(fields) =>
editingChapter.id
? data.editChapter(editingChapter.id, fields)
: data.createChapter(activeNotebook.id, fields.name, fields.color, fields.icon)
: data.createChapter(
activeNotebook.id,
fields.name,
fields.color,
fields.icon,
fields.sortMode
)
}
onClose={() => setEditingChapter(null)}
dark={dark}
+133
View File
@@ -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"))}
+40 -9
View File
@@ -66,7 +66,7 @@
/* Set per account — see UserPreferences. The stack is the fallback chain, so
an unset preference still reads as the serif it always was. */
font-family: var(--ne-font, "Newsreader", Georgia, serif);
font-size: 15px;
font-size: var(--ne-size, 15px);
line-height: var(--ne-line);
padding: 20px 24px;
min-height: 100%;
@@ -126,6 +126,25 @@
padding-left: 12px;
color: var(--ne-muted);
}
/* A quote may hold whole blocks — a heading, a list, a closing line — and inside
it they need the same rhythm as outside. The rule at the top of this file
reaches only the editor's own children, so everything in a quote stood jammed
against its neighbour, with the items of the list further apart than the
blocks around them. It comes after `p { margin: 0 }` and the headings on
purpose: those are exactly as specific, so written earlier they would win.
A paragraph inside a list item is more specific still and keeps its zero. */
.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);
@@ -225,15 +244,27 @@
background: color-mix(in srgb, var(--ne-accent) 18%, transparent);
pointer-events: none;
}
/* Drag handle between columns */
/* The edge being dragged, drawn *on the cell* rather than in it.
*
* The editor marks the column under the pointer by putting a small div in each
* cell of it, and the obvious way to show that is to position the div along the
* cell's right edge. Which worked, and painted grey letters down the bar: an
* absolutely positioned child inside a cell of a `border-collapse: collapse`
* table sends Chromium down another painting path, and the cell's own text came
* out a second time, squeezed into the two pixels under the bar — one letter to
* a line. It appeared with the bar and went with it, which is what named the
* culprit.
*
* So the div is never drawn at all; it only marks which cells belong to the
* column, and the line is an inset shadow on those cells. Nothing is positioned,
* nothing paints twice, and the result is the same line — a continuous one down
* the column, because every cell in it carries its own piece. */
.note-editor .ProseMirror .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background: var(--ne-accent);
pointer-events: none;
display: none;
}
.note-editor .ProseMirror td:has(> .column-resize-handle),
.note-editor .ProseMirror th:has(> .column-resize-handle) {
box-shadow: inset -2px 0 0 var(--ne-accent);
}
.note-editor .ProseMirror.resize-cursor {
cursor: col-resize;
+5
View File
@@ -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 }),
];
}
+14 -7
View File
@@ -87,7 +87,7 @@ function makeSpacerRow(height, delta, colspan) {
}
/** The rows of a table, with the document position of each. */
function tableRows(node, wrapper, domTop, seenBefore) {
function tableRows(node, wrapper, domTop, seenBefore, scale) {
const table = wrapper.matches?.("table") ? wrapper : wrapper.querySelector(":scope table");
const body = table?.querySelector(":scope > tbody");
if (!body) return null;
@@ -108,8 +108,8 @@ function tableRows(node, wrapper, domTop, seenBefore) {
rows.push({
index,
pos,
top: rect.top - domTop - seenBefore - inner,
height: rect.height,
top: (rect.top - domTop) / scale - seenBefore - inner,
height: rect.height / scale,
});
pos += node.child(index).nodeSize;
index++;
@@ -121,6 +121,13 @@ function tableRows(node, wrapper, domTop, seenBefore) {
function computeBreaks(view, geometry) {
const pageHeight = geometry?.pageHeight ?? 0;
const gap = geometry?.gap ?? 0;
/* The text is laid out on a page of a fixed size and the whole sheet is then
* scaled to whatever room the screen has — that is what makes a phone break
* the text in exactly the same places a desktop does. `getBoundingClientRect`
* reports what is on the screen, so every measured length is divided back out
* of that scaling: the page height above, the spacer heights below and the
* margins read from the computed style are all in page units. */
const scale = geometry?.scale || 1;
if (!(pageHeight > 0)) return null;
const pitch = pageHeight + gap;
@@ -156,8 +163,8 @@ function computeBreaks(view, geometry) {
const cs = getComputedStyle(child);
const block = {
pos: offset,
top: rect.top - domTop - seen,
height: rect.height,
top: (rect.top - domTop) / scale - seen,
height: rect.height / scale,
marginTop: px(cs.marginTop),
marginBottom: px(cs.marginBottom),
};
@@ -167,11 +174,11 @@ function computeBreaks(view, geometry) {
* they push everything after the table down as surely as a block spacer
* does, and the next block's natural position has to allow for it. */
if (node.type.name === "table") {
const measured = tableRows(node, child, domTop, seen);
const measured = tableRows(node, child, domTop, seen, scale);
if (measured) {
block.rows = measured.rows.map((r) => ({ ...r, pos: offset + 1 + r.pos }));
block.columns = measured.columns;
block.height = rect.height - measured.inner;
block.height = rect.height / scale - measured.inner;
seen += measured.inner;
}
}
+120
View File
@@ -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) }),
};
},
});
+26 -3
View File
@@ -123,7 +123,7 @@ export function useNoteEditor({
}
/** The editable page itself. */
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel, resolveLinkIcon }) {
export function EditorSurface({ editor, dark, font, minHeight, fontSize, onOpenNote, resolveLinkLabel, resolveLinkIcon }) {
/* Note links carry the label they were created with, so renaming a note would
* leave every reference to it showing the old title.
*
@@ -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,12 @@ 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.
`--ne-size` is the type size *on the page*: a paged note sets it so that
a fixed number of lines fills a sheet, and the sheet is then scaled to
the screen. Panels that show a note without a page — the notes beside a
PDF, a version preview — leave it unset and keep the reading size. */
style={{ minHeight, cursor: "text", ...(fontSize ? { "--ne-size": `${fontSize}px` } : null) }}
>
<EditorContent editor={editor} />
</div>
+43
View File
@@ -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)");
@@ -102,6 +118,33 @@ describe("blocks", () => {
expect(md(doc(quote))).toBe("> zitiert");
});
it("divides the blocks inside a quote, so a list does not swallow what follows", () => {
const quote = {
type: "blockquote",
content: [
{ type: "heading", attrs: { level: 3 }, content: [text("Quartalszahlen")] },
{
type: "bulletList",
content: [
{ type: "listItem", content: [para(text("Umsatz"))] },
{ type: "listItem", content: [para(text("Gewinn"))] },
],
},
para(text("Alles nach Plan.")),
],
} as DocNode;
// The dividing line is quoted as well: an empty line would end the quote.
expect(md(doc(quote))).toBe(
"> ### Quartalszahlen\n>\n> - Umsatz\n> - Gewinn\n>\n> Alles nach Plan."
);
});
it("quotes a quote inside a quote", () => {
const inner = { type: "blockquote", content: [para(text("innen"))] } as DocNode;
const outer = { type: "blockquote", content: [para(text("außen")), inner] } as DocNode;
expect(md(doc(outer))).toBe("> außen\n>\n> > innen");
});
it("fences a code block and keeps its language", () => {
const code = { type: "codeBlock", attrs: { language: "js" }, content: [text("const a = 1;")] };
expect(md(doc(code))).toBe("```js\nconst a = 1;\n```");
+43 -18
View File
@@ -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;
@@ -129,11 +140,17 @@ function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
return withAlignment(n, [`${"#".repeat(level)} ${inline(n.content, o)}`]);
}
case "blockquote":
return children(n, o)
.join("\n")
.split("\n")
.map((l) => (l ? `> ${l}` : ">"));
case "blockquote": {
/* A quote may hold anything a note may hold — headings, lists, tables,
* further quotes — and inside it the blocks need the same blank line
* between them as they do outside. Written on consecutive lines, a
* paragraph after a list is read as more of the last list item, and a
* table loses its header. The blank line is quoted as well (`>` on its
* own), because a truly empty line ends the quote instead of dividing it. */
const lines = stack(n.content || [], o);
while (lines.length && lines[lines.length - 1] === "") lines.pop();
return lines.map((l) => (l ? `> ${l}` : ">"));
}
case "codeBlock": {
const lang = n.attrs?.language || "";
@@ -172,6 +189,26 @@ function children(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
return (n.content || []).flatMap((c) => block(c, o, indent));
}
/**
* Blocks one after another, with a blank line between them.
*
* Without one, Markdown runs adjacent blocks together: a bullet list written
* straight after another list becomes more items of the first, and a quote or a
* code fence on the line after a list item is read as part of that item. That is
* not a quirk of one reader — it is what the format says, so the file rendered
* wrongly everywhere, and reading it back in produced a document that did not
* match the one exported. It holds wherever blocks sit side by side, which is
* why the quote uses it too and not only the top level.
*
* A block that renders to nothing is left out rather than contributing a gap.
*/
function stack(nodes: DocNode[], o: MarkdownOptions, indent = ""): string[] {
return nodes.flatMap((n) => {
const rendered = block(n, o, indent);
return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : [];
});
}
/** Renders list items, indenting anything nested inside them. */
function list(
n: DocNode,
@@ -230,19 +267,7 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str
}
}
/* A blank line between top-level blocks.
*
* Without one, Markdown runs adjacent blocks together: a bullet list written
* straight after another list becomes more items of the first, and a quote or
* a code fence on the line after a list item is read as part of that item.
* That is not a quirk of one reader — it is what the format says, so the file
* rendered wrongly everywhere, and reading it back in produced a document that
* did not match the one exported. */
const blocks = (doc.content || []).flatMap((n) => {
const rendered = block(n, options);
return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : [];
});
lines.push(...blocks);
lines.push(...stack(doc.content || [], options));
if (options.hasDrawing) {
lines.push("", "> ✏️ This note has a drawing, which Markdown cannot hold.");
+13
View File
@@ -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;
@@ -128,9 +132,18 @@ const STYLES = `
li.task .box { font-size: 12pt; line-height: 1; }
li.task div p { margin: 0; }
blockquote { margin: 0 0 3mm; padding-left: 4mm; border-left: 2px solid #ccc; color: #444; }
/* A quote may hold headings and lists. Their own spacing is right between
them and wrong at the ends, where it pushes the rule beside the quote past
the text it belongs to. */
blockquote > :first-child { margin-top: 0; }
blockquote > :last-child { margin-bottom: 0; }
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; }
+114 -88
View File
@@ -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>
);
}
+12 -6
View File
@@ -188,7 +188,10 @@ export function EditorPane({
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 16px",
/* The same inset as the toolbar under it. The mark used to start six
pixels right of the first tool below it, so the two rows did not
share a left edge and the title read as pushed across. */
padding: "8px 12px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
@@ -204,21 +207,24 @@ export function EditorPane({
its box and sat on the middle. Measured, the mark ended up 2px high
and dipped below the line the title never crossed. Sharing a baseline
is what "on one line" means, and it needs no number. */}
<div style={{ display: "flex", alignItems: "baseline", gap: 4, flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 5, flex: 1, minWidth: 0 }}>
<button
onClick={onPickIcon}
data-note-icon={note.icon || "none"}
title="Choose an icon for this note"
aria-label="Choose an icon for this note"
style={{
/* One square box whatever it holds. An emoji and the drawn glyph
have different metrics, and sizing to the content moved the
title sideways the moment a mark was chosen. Laid out as text
/* One box of a fixed width whatever it holds. An emoji and the
drawn glyph have different metrics, and sizing to the content
moved the title sideways the moment a mark was chosen. Just
wide enough for the mark, so the mark itself lands on the
column the tools below it stand in; still a full row tall, so
there is something to hit with a finger. Laid out as text
rather than as a flex box, because only text has a baseline for
the title to line up with. */
display: "inline-block",
textAlign: "center",
width: 26,
width: 20,
height: 26,
lineHeight: "26px",
flexShrink: 0,
+78 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx";
import { EditorToolbar } from "../../editor/EditorToolbar.jsx";
import { tableMenuItems } from "../../editor/tableMenu.js";
@@ -25,6 +25,31 @@ import {
} from "../drawing/strokes.js";
import { COLORS } from "../../ui/constants.jsx";
/* ── the page the text is laid out on ──
*
* One page, one size, everywhere: the text is set on a sheet of exactly these
* units and the whole sheet is then scaled to whatever width the screen offers.
* Laying it out in screen pixels instead is what made a phone and a desktop
* disagree — the same note broke into different lines and different pages on
* each, because a 340-pixel sheet held nothing like what a 760-pixel one did.
* Now the scale is the only difference between them.
*
* The shape is the drawing's own page, so "page 2" means the same sheet whether
* you write on it or draw on it.
*/
const PAGE_W = 760;
const PAGE_H = Math.round((PAGE_W * CANON_H) / CANON_W);
const PAGE_GAP = 16;
/* Twenty-one lines to the page, the way Samsung Notes rules its paper — and the
* type size follows from that rather than being chosen and hoped for. The two
* numbers below are what `editor.css` sets on the writing surface; if either
* moves there, the size recomputed here keeps the count. */
const PAGE_PAD = 20;
const LINE_HEIGHT = 1.7;
const LINES_PER_PAGE = 21;
const TEXT_SIZE = Math.floor((PAGE_H - PAGE_PAD * 2) / LINES_PER_PAGE / LINE_HEIGHT);
import { I } from "../../ui/icons.jsx";
import { BacklinksPanel } from "../../ui/BacklinksPanel.jsx";
@@ -85,20 +110,25 @@ export function NoteEditor({
// new vector strokes so migrating a note never loses what was already drawn.
const legacyRaster = note.drawingData && !hideDrawing;
/* The gap between two sheets.
/* How much of a page fits on this screen: the one number that separates a
* phone from a desktop. Everything about the writing itself — line breaks,
* page breaks, where a paragraph starts — is decided in page units and then
* multiplied by this. */
const scale = stageSize.w / PAGE_W;
/* The gap between two sheets, in page units like everything else.
*
* The same idea as the PDF reader's, which is the point: a page in a note and
* a page in a document should read as the same kind of thing. The text flow
* knows about it too — `editor/pagination.js` holds it open inside the
* document so no paragraph is ever cut in half by it. */
const PAGE_GAP = isMobileViewport ? 10 : 16;
const pitch = stageSize.h + PAGE_GAP;
const pitch = (PAGE_H + PAGE_GAP) * scale;
/* Handed to the editor, and read fresh on every measurement rather than
* captured, so resizing the window re-breaks the text without rebuilding the
* editor and losing the cursor. */
const pageGeometry = useMemo(
() => ({ pageHeight: stageSize.h, gap: PAGE_GAP }),
[stageSize.h, PAGE_GAP]
() => ({ pageHeight: PAGE_H, gap: PAGE_GAP, scale }),
[scale]
);
const commitSheets = (next) => {
@@ -181,8 +211,15 @@ export function NoteEditor({
};
}, [note.id, note.drawingData]);
// Keep one fixed-aspect drawing stage regardless of device dimensions.
useEffect(() => {
/* Keep one fixed-aspect drawing stage regardless of device dimensions.
*
* Measured *before* the first paint, not after. The stage starts at the
* canonical page — 1200 × 1600 — and this runs on every note, because the
* editor is built fresh for each one. As an ordinary effect it ran a frame
* too late, so opening a note drew one frame of a page sixteen hundred pixels
* tall before the measurement pulled it back: the sheet shot up long and
* narrow and then righted itself, every single time. */
useLayoutEffect(() => {
const fitStage = () => {
const host = stageRef.current;
if (!host) return;
@@ -237,6 +274,8 @@ export function NoteEditor({
const recount = () => {
const el = surface();
if (!el || !stageSize.h) return;
/* Read in page units: `scrollHeight` is layout, and the sheet's scaling is
* a transform, which layout knows nothing about. */
const height = el.scrollHeight;
const scroller = scrollerRef.current;
/* Counted in whole pitches — a page and the gap after it — because that is
@@ -244,14 +283,16 @@ export function NoteEditor({
* four of a two-page note is on the note, and a count that ignored it
* would report two pages while showing four, leaving the fourth with no
* canvas to be redrawn on. */
const written = Math.max(1, Math.floor(Math.max(0, height - 1) / pitch) + 1);
const pagePitch = PAGE_H + PAGE_GAP;
const written = Math.max(1, Math.floor(Math.max(0, height - 1) / pagePitch) + 1);
const count = Math.max(written, drawnPages[drawnPages.length - 1] ?? 1);
/* The page under the middle of the viewport is the one you are reading —
* the same rule the PDF reader uses. Measuring from the top instead said
* "page 1" while you were looking at the foot of a two-page note, because
* a window is shorter than a page. */
const middle = scroller ? scroller.scrollTop + scroller.clientHeight / 2 : 0;
const current = Math.max(1, Math.min(count, Math.floor(middle / pitch) + 1));
// The scroller is in screen units, so it is divided back into page ones.
const middle = scroller ? (scroller.scrollTop + scroller.clientHeight / 2) / scale : 0;
const current = Math.max(1, Math.min(count, Math.floor(middle / pagePitch) + 1));
setPages((p) => (p.count === count && p.current === current ? p : { count, current }));
};
@@ -266,7 +307,7 @@ export function NoteEditor({
scroller?.removeEventListener("scroll", recount);
textEditor.off("transaction", recount);
};
}, [textEditor, stageSize.h, pitch, mode, drawnPages]);
}, [textEditor, stageSize.h, scale, mode, drawnPages]);
/* ── DRAWING: painting ──
* Only the page under the pointer is repainted while a stroke is being drawn;
@@ -790,7 +831,7 @@ export function NoteEditor({
maxWidth: "100%",
// Every sheet plus the gaps between them, but not a gap trailing
// off the last one.
minHeight: canvasPages * pitch - PAGE_GAP,
minHeight: canvasPages * pitch - PAGE_GAP * scale,
margin: "0 auto",
}}
>
@@ -821,9 +862,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)",
@@ -857,17 +902,34 @@ export function NoteEditor({
width: "100%",
pointerEvents: mode === "text" ? "auto" : "none",
}}
>
{/* The sheet, at its own size, shrunk to fit the screen.
A transform rather than a smaller type size, because a type
size changes where the lines fall and that is exactly what has
to stay put: scaled this way the phone shows the same lines on
the same pages as the desktop, only smaller. Anchored top-left
so the scaling never moves the sheet away from the paper drawn
behind it. */}
<div
data-text-page-scale={scale}
style={{
width: PAGE_W,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
<EditorSurface
editor={textEditor}
dark={dark}
font={noteFont}
minHeight={stageSize.h}
minHeight={PAGE_H}
fontSize={TEXT_SIZE}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
resolveLinkIcon={resolveLinkIcon}
/>
</div>
</div>
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
{legacyRaster && (
<img
+62 -19
View File
@@ -216,36 +216,73 @@ function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
*/
export function ChapterNotes({
T,
chapterId,
notes,
separators,
reorderable,
moving = false,
onReorder,
activeNote,
onSeparatorContextMenu,
onSelectNote,
onNoteContextMenu,
compact = false,
}) {
// Lines only mean something where the order is the user's own, exactly as in
// the notebook's list — in "newest first" the notes slide past them.
// A chapter is always in the order it was filled in, so its lines always mean
// something and its rows can always be dragged.
const entries = reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry);
/* Its own drag, scoped to this chapter.
*
* Unfolded under its row, these rows sit *inside* a row of the notebook's
* list, so "the nearest row above the pointer" cannot say which of the two
* lists is being dragged in. The scope says it. */
const dnd = useReorderDrag(
!!moving && !!onReorder,
onReorder || (() => {}),
undefined,
`chapter:${chapterId ?? ""}`
);
// Something is in the hand, so the ends of the list need somewhere to drop it.
const carrying = !!moving && !!dnd.draggingId && entries.length > 0;
return (
<>
{entries.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{/* Only while something is actually being carried, and thin.
Standing open for the whole of the mode, the two strips added half a
finger of empty space inside every open chapter — the block grew and
read as though the notes in it had swollen. */}
{carrying && (
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 12 }}>
{dnd.overId === START_ID && <DropLine T={T} />}
</div>
)}
{entries.map((entry) =>
entry.kind === "separator" ? (
<SeparatorRow
<div
key={entry.id}
{...(moving ? dnd.rowProps(entry.id) : {})}
style={{ opacity: dnd.draggingId === entry.id ? 0.45 : 1 }}
>
<SeparatorRow
T={T}
separator={entry.separator}
onContextMenu={onSeparatorContextMenu}
grip={null}
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
/>
{moving && dnd.overId === entry.id && <DropLine T={T} />}
</div>
) : (
<button
<div
key={entry.id}
{...(moving ? dnd.rowProps(entry.id) : {})}
style={{ opacity: dnd.draggingId === entry.id ? 0.45 : 1 }}
>
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{moving && <Grip T={T} {...dnd.gripProps} />}
<button
data-chapter-note={entry.id}
onClick={() => onSelectNote(entry.id)}
onContextMenu={(e) => onNoteContextMenu(e, entry.note)}
@@ -291,9 +328,17 @@ export function ChapterNotes({
{entry.note.title}
</span>
</button>
</div>
{moving && dnd.overId === entry.id && <DropLine T={T} />}
</div>
)
)}
{/* No button to add one here. A line is drawn in the notebook and dragged
{carrying && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 16 }}>
{dnd.overId === END_ID && <DropLine T={T} />}
</div>
)}
{/* No button to add a line here. One is drawn in the notebook and dragged
into the chapter, the same way a note is put in one — a second way to
make the same thing, in a second place, is a second thing to keep
straight. */}
@@ -518,6 +563,10 @@ export function NoteList({
query,
onQueryChange,
reorderable,
/** Whether rearranging is switched on. Held above this list, because a chapter
shown beside it is a second list under the same switch. */
reordering,
onToggleReorder,
separators,
onAddSeparator,
onSeparatorContextMenu,
@@ -546,16 +595,7 @@ export function NoteList({
onImportMarkdown,
onEmptyTrash,
}) {
/* Rearranging is a mode, switched on for as long as it is being done.
*
* It ends by itself when the list is left: coming back to a notebook to find
* every row wearing a handle, because it was rearranged last week, is not
* where anybody left off. */
const [reordering, setReordering] = useState(false);
// Off again on the way to another list, and whenever a hand-made arrangement
// stops being in effect at all — an order the app chose cannot be dragged.
useEffect(() => setReordering(false), [listKey, reorderable]);
const moving = !!reorderable && reordering;
const moving = !!reorderable && !!reordering;
/* Reordering by pointer rather than by the HTML5 drag API, which mobile
browsers never fire from touch — on a phone the arrangement could not be
@@ -644,10 +684,13 @@ export function NoteList({
</h2>
</div>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{reorderable && (
<ReorderToggle T={T} on={reordering} onToggle={() => setReordering((v) => !v)} />
{reorderable && <ReorderToggle T={T} on={!!reordering} onToggle={onToggleReorder} />}
{/* Not inside a chapter: the mode belongs to the notebook, and a
chapter is always in the order it was filled in. A menu there
would offer a choice that cannot be honoured. */}
{!isTrashView && !openChapter && (
<SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />
)}
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
{!isTrashView && (
<AddMenu
T={T}
@@ -11,6 +11,7 @@ import {
sortModesFor,
sortNotes,
withNewSeparator,
withoutRow,
} from "./noteSort";
import type { NoteMeta } from "../../types";
@@ -277,3 +278,71 @@ describe("placeOnTop", () => {
expect(placeOnTop(notes, separators, chapters, "ghost")).toBeNull();
});
});
describe("two lines in the same gap", () => {
/* A separator's position counts the rows above it, so two lines with nothing
between them carry the same number. Which of them comes first is then only
the order they are written in — and the comparator used to claim *both*
directions for that case, so a line dragged under another sprang back. */
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 })];
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
it("keeps the order they were written in", () => {
const two = [
{ id: "s1", label: "Erst", order: 1 },
{ id: "s2", label: "Dann", order: 1 },
];
expect(kinds(mergeEntries(notes, two))).toEqual(["n:a", "s:s1", "s:s2", "n:b"]);
expect(kinds(mergeEntries(notes, [two[1]!, two[0]!]))).toEqual(["n:a", "s:s2", "s:s1", "n:b"]);
});
it("can be swapped by dragging one under the other", () => {
const two = [
{ id: "s1", label: "Erst", order: 1 },
{ id: "s2", label: "Dann", order: 1 },
];
const next = reindexEntries(notes, two, "s1", "s2")!;
expect(next.separators.map((s) => s.id)).toEqual(["s2", "s1"]);
expect(kinds(mergeEntries(notes, next.separators))).toEqual(["n:a", "s:s2", "s:s1", "n:b"]);
});
});
describe("withoutRow", () => {
/* a │ b │ ─── │ c: deleting a note above the line used to leave the line
counting rows that were no longer there, so everything under it appeared to
jump up past it. */
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })];
const separators = [{ id: "s1", label: "Teil 2", order: 2 }];
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
it("closes the gap and takes the lines down with it", () => {
const next = withoutRow(notes, separators, [], "a")!;
expect(next.noteIds).toEqual(["b", "c"]);
expect(next.separators[0]!.order).toBe(1);
const left = [note("b", { sortOrder: 0 }), note("c", { sortOrder: 1 })];
expect(kinds(mergeEntries(left, next.separators))).toEqual(["n:b", "s:s1", "n:c"]);
});
it("leaves a line that was already above the gap where it was", () => {
const next = withoutRow(notes, [{ id: "s1", label: "", order: 1 }], [], "c")!;
expect(next.separators[0]!.order).toBe(1);
});
it("reports nothing to do for a row that is not in the list", () => {
expect(withoutRow(notes, separators, [], "ghost")).toBeNull();
});
});
describe("placeOnTop with several notes", () => {
/* Removing a chapter frees its notes into the notebook. They arrive together,
at the top, in the order they had inside it. */
const notes = [note("x", { sortOrder: 0 }), note("y", { sortOrder: 1 })];
const freed = [note("p"), note("q")];
const separators = [{ id: "s1", label: "", order: 1 }];
it("puts them all in front, in the order given", () => {
const next = placeOnTop([...freed, ...notes], separators, [], ["p", "q"])!;
expect(next.noteIds).toEqual(["p", "q", "x", "y"]);
expect(next.separators[0]!.order).toBe(3);
});
});
+50 -12
View File
@@ -161,11 +161,20 @@ export function mergeEntries(
separator,
}));
return [...rowEntries, ...separatorEntries].sort(
return [...rowEntries, ...separatorEntries].sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
/* Two lines in the same gap keep the order they were written in.
*
* This used to say "a separator comes first" without asking what the other
* one was — so for two separators it claimed *both* directions at once. A
* comparator that contradicts itself has no defined result, and the sort
* returned them in whichever order it happened to walk: a line dragged under
* another sprang back above it, every time. */
if (a.kind === "separator" && b.kind === "separator") return 0;
// A separator sitting at the same position as a row comes first, so
// "a line above this one" lands where it was asked for.
(a, b) => a.order - b.order || (a.kind === "separator" ? -1 : 1)
);
return a.kind === "separator" ? -1 : 1;
});
}
/** The result of a drag: what to write back for each kind of row. */
@@ -256,22 +265,51 @@ export function placeOnTop(
notes: NoteMeta[],
separators: Separator[],
chapters: Chapter[],
id: string
ids: string | string[]
): Arrangement | null {
const note = notes.find((n) => n.id === id);
if (!note) return null;
/* Built from the list *without* the new note, then the note put in front.
const wanted = Array.isArray(ids) ? ids : [ids];
const arriving = wanted
.map((id) => notes.find((n) => n.id === id))
.filter((n): n is NoteMeta => !!n);
if (arriving.length === 0) return null;
/* Built from the list *without* the new notes, then those put in front.
*
* Merging it in first would be measuring the room after the furniture moved:
* the new row already counts as a row, so every line reads as sitting one
* place further down than it was drawn, and renumbering would write that
* Merging them in first would be measuring the room after the furniture
* moved: a new row already counts as a row, so every line reads as sitting
* one place further down than it was drawn, and renumbering would write that
* misreading back as the truth. */
const held = new Set(arriving.map((n) => n.id));
const entries = mergeEntries(
notes.filter((n) => n.id !== id),
notes.filter((n) => !held.has(n.id)),
separators,
chapters
);
entries.unshift({ kind: "note", id, order: 0, note });
entries.unshift(...arriving.map((note) => ({ kind: "note" as const, id: note.id, order: 0, note })));
return toArrangement(entries);
}
/**
* The whole list renumbered with one row taken out of it.
*
* For a note being thrown away or moved elsewhere. A row leaving is the same
* problem as a row arriving, in reverse: a separator's position counts the rows
* above it, so a note deleted above a line left that line one row too *low*, and
* everything below the line appeared to jump up past it. Nothing here moves
* except what is leaving.
*
* `notes` must still contain the row — it is the list as it is drawn, which is
* what the lines were counted against.
*/
export function withoutRow(
notes: NoteMeta[],
separators: Separator[],
chapters: Chapter[],
id: string
): Arrangement | null {
const entries = mergeEntries(notes, separators, chapters);
const at = entries.findIndex((e) => e.id === id);
if (at < 0) return null;
entries.splice(at, 1);
return toArrangement(entries);
}
+118 -14
View File
@@ -48,6 +48,26 @@ const visibilityButton = (T, shown) => ({
color: shown ? T.muted : T.accent,
});
/**
* Why the document did not open, in words.
*
* One message for every failure told a phone that could not reach the server and
* a phone whose file was missing exactly the same thing, and both of them a
* guess ("offline?") that was usually wrong. The reason is known here — say it,
* so a report of "it will not open" arrives with the cause attached.
*/
function openFailure(err) {
if (err?.message === "AUTH") return "Session expired — please sign in again.";
if (typeof navigator !== "undefined" && navigator.onLine === false)
return "You are offline, and this file has not been downloaded to this device yet.";
const status = /^HTTP (\d+)$/.exec(err?.message || "")?.[1];
if (status === "404") return "The server no longer has this file (404).";
if (status) return `The server would not send the file (HTTP ${status}).`;
if (err?.name === "TypeError") return "The server could not be reached.";
// A file that arrived but will not parse: broken, or not a PDF at all.
return `The file could not be opened: ${err?.name || "Error"}: ${err?.message || err}`;
}
/** Pages this far outside the viewport are drawn ahead of being scrolled to. */
const RENDER_MARGIN_PX = 1200;
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
@@ -172,7 +192,7 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
* proportions before anything is drawn, so the scrollbar is honest from the
* first frame instead of jumping as pages arrive.
*/
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, focusPin, onFocusHandled }) {
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, zoomExtras, focusPin, onFocusHandled }) {
const [state, setState] = useState({ status: "loading" });
const [zoom, setZoom] = useState(1);
const [drawing, setDrawing] = useState(false);
@@ -400,13 +420,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
// console, where it can be read when someone reports "it does not open".
console.error(`[pdf] could not open document: ${err?.name || "Error"}: ${err?.message || err}`);
if (!cancelled) {
setState({
status: "error",
message:
err?.message === "AUTH"
? "Session expired — please sign in again."
: "The file could not be opened. Offline and never loaded?",
});
setState({ status: "error", message: openFailure(err) });
}
});
@@ -500,12 +514,93 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
}
};
/* ── zooming, and what stays still while it happens ──
*
* A zoom changes how tall the document is, and the scroll position means
* something different afterwards. Left alone, every step drifts: press + three
* times and you are somewhere else in the document.
*
* What should stay put depends on how the zoom was asked for. From the
* buttons, it is the page you are reading — the request is "show me this page
* larger". From the wheel, it is the point under the pointer: you are pointing
* at a word and asking for *that* to grow, and having the page jump to its top
* instead is exactly what makes a viewer feel unruly.
*/
const keepAnchor = useRef(null);
const keepPage = useRef(null);
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
const anchor = keepAnchor.current;
if (anchor) {
keepAnchor.current = null;
// The document grew by this much, so the point that was under the pointer
// is now that much further from the top.
const ratio = pageWidth / anchor.from;
el.scrollTop = Math.max(0, anchor.py * ratio - anchor.cy);
el.scrollLeft = Math.max(0, anchor.px * ratio - anchor.cx);
return;
}
const page = keepPage.current;
if (page === null) return;
keepPage.current = null;
const target = innerRef.current?.querySelector(`[data-pdf-page="${page}"]`);
// Not `goToPage`: that one glides, which is right for "take me to page 7"
// and wrong for holding still — the glide is a jump with a delay on it.
if (target) el.scrollTop = Math.max(0, target.offsetTop - 8);
}, [pageWidth]);
/** The next step up or down, or nothing at the end of the ladder. */
const nextZoom = (from, dir) =>
dir > 0 ? ZOOM_STEPS.find((s) => s > from + 0.001) : [...ZOOM_STEPS].reverse().find((s) => s < from - 0.001);
const stepZoom = (dir) => {
const i = ZOOM_STEPS.indexOf(zoom);
const next = ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, Math.max(0, (i < 0 ? 2 : i) + dir))];
if (next) setZoom(next);
const next = nextZoom(zoom, dir);
if (!next) return;
keepPage.current = pageNo;
setZoom(next);
};
/**
* Ctrl (or ⌘) and the wheel, as in every other viewer.
*
* Registered by hand rather than with `onWheel`: React attaches wheel
* listeners passively, and a passive listener may not call `preventDefault`.
* Without that call the *browser* zooms the whole page instead — toolbar,
* sidebar and all — which is not what the gesture means over a document.
*
* A trackpad pinch arrives here as the same event, so it works too without
* anything further.
*/
useEffect(() => {
const el = scrollRef.current;
if (!el || state.status !== "ready") return;
const onWheel = (e) => {
if (!e.ctrlKey && !e.metaKey) return;
if (!e.deltaY) return; // a sideways wheel is not a zoom
e.preventDefault();
const next = nextZoom(zoom, e.deltaY < 0 ? 1 : -1);
if (!next) return;
const r = el.getBoundingClientRect();
const cx = e.clientX - r.left;
const cy = e.clientY - r.top;
keepAnchor.current = {
px: el.scrollLeft + cx,
py: el.scrollTop + cy,
cx,
cy,
// Measured in page width rather than in the zoom factor: the fitted
// width is what the pages are actually drawn from, and it changes on
// its own when the window is resized or the margin column appears.
from: pageWidth,
};
setZoom(next);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [zoom, pageWidth, state.status]);
const barButton = {
display: "flex",
alignItems: "center",
@@ -724,14 +819,23 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
{notesToggle}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<button onClick={() => stepZoom(-1)} title="Zoom out" style={barButton}>
{/* The gesture is named in the tooltip, because a viewer that answers
Ctrl + wheel and never says so is only helping the people who
already tried it. */}
<button onClick={() => stepZoom(-1)} title="Zoom out (Ctrl + wheel)" style={barButton}>
−
</button>
<span style={{ minWidth: 42, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
<button onClick={() => stepZoom(1)} title="Zoom in" style={barButton}>
<span data-pdf-zoom={zoom} style={{ minWidth: 42, textAlign: "center" }}>
{Math.round(zoom * 100)}%
</span>
<button onClick={() => stepZoom(1)} title="Zoom in (Ctrl + wheel)" style={barButton}>
+
</button>
{/* How big the document is drawn, and how much room it gets — the same
question asked twice, so the controls stand together. */}
{zoomExtras}
{/* What is shown on top of the document, kept together at the end of
the bar. Drawings and margin notes are two halves of one question —
"how much of my own work do I want to see right now" — and they used
+81 -1
View File
@@ -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,7 @@ export function PdfWorkspace({
resolveLinkIcon,
}) {
const [showNotes, setShowNotes] = useState(false);
const [full, setFull] = useState(false);
// 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 +58,33 @@ 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 ──
*
* The app laid out as an overlay over itself: the document fills the browser
* window, and the sidebar, the note list and the title bar are gone. The
* browser's *own* full screen is deliberately not asked for — it swallows the
* tabs and the address bar too, which is more than was wanted here; this is
* about giving the page to the document, not the screen.
*/
const enterFull = () => setFull(true);
const leaveFull = useCallback(() => setFull(false), []);
/* Escape gets out. A way out that you can only know about is not a way out —
so the button stays visible in the toolbar as well. */
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 +137,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,8 +178,33 @@ export function PdfWorkspace({
</button>
);
/* Beside the zoom rather than out on its own: both answer "how much of this
screen does the document get". */
const fullToggle = (
<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>
);
return (
<div style={{ flex: 1, display: "flex", minHeight: 0, minWidth: 0 }}>
<div
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
@@ -133,6 +212,7 @@ export function PdfWorkspace({
dark={dark}
onUpdate={onUpdate}
notesToggle={notesToggle}
zoomExtras={fullToggle}
focusPin={focusPin}
onFocusHandled={() => setFocusPin(null)}
/>
+37
View File
@@ -0,0 +1,37 @@
/**
* Load the HoneyOS bar, if this instance is configured to have one.
*
* The bar is a strip along the top of every app that belongs to the same
* system: it says where you are and takes you to the others. It lives on the
* HoneyOS server, so it stays in step everywhere without InkNote being rebuilt.
*
* Deliberately fire-and-forget. If HoneyOS is slow, down, or simply not
* configured, nothing here throws and nothing here waits — InkNote is not the
* kind of app that may fail to start because a decoration is missing. Offline
* there is no bar at all, which is the right trade for an offline-first app.
*/
export async function mountHoneybar() {
// The bar mounts itself on load; a second script tag would mean two bars.
if (document.querySelector("script[data-honeybar]")) return;
let origin, key;
try {
const r = await fetch("/api/public/config");
if (!r.ok) return;
const cfg = (await r.json()).honeybar;
if (!cfg?.origin) return;
({ origin, key } = cfg);
} catch {
return; // offline, or no server — either way, no bar
}
const el = document.createElement("script");
el.dataset.honeybar = "";
el.defer = true;
el.src = origin.replace(/\/+$/, "") + "/honeybar/honeybar.js";
// honeybar.js reads its settings from document.currentScript, which is set
// for a script inserted this way just as it is for one written in the HTML.
el.dataset.app = "inknote";
if (key) el.dataset.key = key;
document.head.appendChild(el);
}
+90
View File
@@ -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]);
}
+131 -36
View File
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import * as sync from "../syncEngine";
import * as searchIndex from "../search";
import { sameStrokeDoc } from "../features/drawing/strokes.js";
import { placeOnTop, reindexEntries } from "../features/notes/noteSort";
import { placeOnTop, reindexEntries, sortNotes, withoutRow } from "../features/notes/noteSort";
import type { Arrangement } from "../features/notes/noteSort";
import { extractOnImport } from "../features/pdf/pdfText";
import type {
@@ -13,6 +13,7 @@ import type {
NoteMeta,
Notebook,
Separator,
SortMode,
StrokeDoc,
User,
} from "../types";
@@ -68,10 +69,14 @@ export interface NoteData {
notebookId: string,
name: string,
color?: string | null,
icon?: string | null
icon?: string | null,
sortMode?: SortMode | null
) => Promise<Chapter>;
/** Name, colour, or both — one write for one decision. */
editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise<void>;
/** Everything a chapter is, in one write — it is decided in one dialog. */
editChapter: (
id: string,
updates: { name?: string; color?: string | null; icon?: string | null; sortMode?: SortMode | null }
) => Promise<void>;
deleteChapter: (id: string) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>;
reorderNotebooks: (dragId: string, targetId: string) => void;
@@ -212,6 +217,20 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
/* ── Notes ── */
/**
* Marks a note as changed *now* in the list everything on screen reads from.
*
* The store behind `sync` already stamps the note on every write, but nothing
* on screen reads the store: the lists read this state. Only a title change
* used to stamp it here — so writing in a note left "Recent Notes" and the
* "Last changed" sort showing the order from the last reload, which is exactly
* the moment those two are supposed to react to.
*/
const touch = useCallback((id: string) => {
const updatedAt = new Date().toISOString();
setNotes((p) => p.map((n) => (n.id === id ? { ...n, updatedAt } : n)));
}, []);
const updateNote = useCallback((id: string, edit: NoteEdit) => {
if (edit.title !== undefined) {
const title = edit.title;
@@ -223,15 +242,17 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (edit.content !== undefined) {
const content = edit.content;
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
touch(id);
void sync.updateNoteContent(id, content, edit.links, edit.attachments);
searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content);
}
if (edit.drawingStrokes !== undefined) {
const drawingStrokes = edit.drawingStrokes;
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), drawingStrokes } }));
touch(id);
void sync.updateNoteDrawing(id, drawingStrokes);
}
}, []);
}, [touch]);
/** Replaces a body wholesale — used when restoring an older version. */
const setNoteContent = useCallback((id: string, content: NoteContent) => {
@@ -248,16 +269,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n)));
}, []);
const trashNote = useCallback(async (id: string) => {
await sync.trashNote(id);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n)));
}, []);
const restoreNote = useCallback(async (id: string) => {
await sync.restoreNoteLocal(id);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n)));
}, []);
const permanentDeleteNote = useCallback(async (id: string) => {
await sync.permanentDeleteNoteLocal(id);
searchIndex.removeEntry(id);
@@ -270,11 +281,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
setNotes((p) => p.filter((n) => !n.trashed));
}, []);
const moveNote = useCallback(async (id: string, folderId: string, chapterId: string | null = null) => {
await sync.moveNote(id, folderId, chapterId);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
}, []);
/**
* Everything one list is made of: its notes, its lines, its chapters.
*
@@ -298,7 +304,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
const all = notebook.separators || [];
return {
notes,
all,
mine: all.filter((sep) => (sep.chapterId ?? null) === chapterId),
others: all.filter((sep) => (sep.chapterId ?? null) !== chapterId),
/* Chapters are rows in this list too, and only in the notebook itself — a
@@ -312,7 +317,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
(
notebookId: string,
chapterId: string | null,
all: Separator[],
/** This list's own lines as they were, for telling whether anything moved. */
mine: Separator[],
others: Separator[],
next: Arrangement
) => {
@@ -335,15 +341,15 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
* that share an `updatedAt` second — so "last changed" quietly came out in
* a different order after every drag.
*
* Compared as a set rather than as an array, because the merge above puts
* the other chapters' separators first and that says nothing about whether
* anything changed. */
* Compared **in order**, and only this list's own lines. Sorting the
* comparison was how a line refused to go under another one: two lines in
* the same gap carry the same position, so swapping them changes nothing
* but their sequence — and a sorted comparison cannot see a sequence. The
* move was computed correctly, drawn correctly for a frame, and then
* thrown away as "no change". */
const fingerprint = (seps: Separator[]) =>
seps
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
.sort()
.join("\n");
if (fingerprint(merged) !== fingerprint(all)) {
seps.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.color ?? ""}|${sep.style ?? ""}`).join("\n");
if (fingerprint(next.separators) !== fingerprint(mine)) {
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
void sync.updateNotebook(notebookId, { separators: merged });
}
@@ -363,7 +369,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return;
const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters);
if (!next) return;
applyArrangement(notebookId, chapterId, list.all, list.others, next);
applyArrangement(notebookId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
@@ -387,11 +393,67 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return;
const next = placeOnTop(list.notes, list.mine, list.chapters, note.id);
if (!next) return;
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
/**
* Takes a row out of the list it is drawn in, and renumbers what is left.
*
* A row leaving is the same problem as a row arriving, in reverse. A line's
* position counts the rows above it, so deleting a note above a line left the
* line one row too low — and everything under it appeared to jump up past it.
* Called *before* the note is taken out of the store, because the list as it
* is drawn is what the lines were counted against.
*/
const takeOut = useCallback(
(note: NoteMeta | undefined) => {
if (!note?.folderId || note.trashed) return;
const chapterId = note.chapterId ?? null;
const list = listOf(note.folderId, chapterId);
if (!list) return;
const next = withoutRow(list.notes, list.mine, list.chapters, note.id);
if (!next) return;
applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
const trashNote = useCallback(
async (id: string) => {
takeOut(notesRef.current.find((n) => n.id === id));
await sync.trashNote(id);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n)));
},
[takeOut]
);
/* Back from the bin and back to the top, like anything else arriving in a
list: its old position belongs to an arrangement that has moved on. */
const restoreNote = useCallback(
async (id: string) => {
await sync.restoreNoteLocal(id);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n)));
const note = notesRef.current.find((n) => n.id === id);
if (note) putOnTop({ ...note, trashed: false });
},
[putOnTop]
);
/* Out of one list and in at the top of the other. Both ends need saying: the
list it leaves has to close the gap, or its lines slip. */
const moveNote = useCallback(
async (id: string, folderId: string, chapterId: string | null = null) => {
const before = notesRef.current.find((n) => n.id === id);
takeOut(before);
await sync.moveNote(id, folderId, chapterId);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
if (before) putOnTop({ ...before, folderId, chapterId });
},
[putOnTop, takeOut]
);
const createNote = useCallback(
async (folderId: string, chapterId: string | null = null) => {
const note = await sync.createNote(folderId, chapterId);
@@ -438,8 +500,14 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
*/
const createChapter = useCallback(
async (notebookId: string, name: string, color: string | null = null, icon: string | null = null) => {
const chapter = await sync.createChapter(notebookId, name, color, icon);
async (
notebookId: string,
name: string,
color: string | null = null,
icon: string | null = null,
sortMode: SortMode | null = null
) => {
const chapter = await sync.createChapter(notebookId, name, color, icon, sortMode);
setChapters((p) => [...p, chapter]);
return chapter;
},
@@ -451,18 +519,45 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
* They used to be two calls because they came from two places — a dialog and
* a submenu — and renaming a chapter while recolouring it then meant two
* requests for one decision. */
const editChapter = useCallback(async (id: string, updates: { name?: string; color?: string | null }) => {
const editChapter = useCallback(async (id: string, updates: Parameters<NoteData["editChapter"]>[1]) => {
setChapters((p) => p.map((c) => (c.id === id ? { ...c, ...updates } : c)));
await sync.updateChapter(id, updates);
}, []);
/**
* Removing a chapter: the row goes, the notes come back.
*
* Both halves have to be said in the notebook's arrangement, or the lines
* around them slip. The chapter's row is taken out of the count, and its notes
* arrive at the top in the order they had inside it — their old positions
* were numbered against the chapter's list, which no longer exists.
*/
const deleteChapter = useCallback(
async (id: string) => {
const chapter = chaptersRef.current.find((c) => c.id === id);
const held = notesRef.current.filter((n) => !n.trashed && n.chapterId === id);
await sync.deleteChapter(id);
setChapters((p) => p.filter((c) => c.id !== id));
setNotes((p) => p.map((n) => (n.chapterId === id ? { ...n, chapterId: null } : n)));
const notebookId = chapter?.notebookId;
if (!notebookId) return;
const list = listOf(notebookId, null);
if (!list) return;
const freed = sortNotes(held, "manual").map((n) => ({ ...n, chapterId: null }));
const next = freed.length
? placeOnTop(
[...freed, ...list.notes],
list.mine,
// Without the chapter itself: its row leaves the count as well.
list.chapters.filter((c) => c.id !== id),
freed.map((n) => n.id)
)
: // An empty one only leaves a gap to close.
withoutRow(list.notes, list.mine, list.chapters, id);
if (next) applyArrangement(notebookId, null, list.mine, list.others, next);
},
[]
[applyArrangement, listOf]
);
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
+21 -4
View File
@@ -38,6 +38,7 @@ export interface ReorderDrag {
into?: boolean
) => {
"data-reorder-id": string;
"data-reorder-scope": string;
"data-reorder-into"?: string;
onPointerDown: (e: React.PointerEvent) => void;
};
@@ -45,10 +46,19 @@ export interface ReorderDrag {
gripProps: { "data-reorder-grip": string };
}
/**
* `scope` names the list these rows belong to, and is what keeps two lists on
* the same screen out of each other's way: a chapter unfolded under its row
* holds rows *inside* a row of the notebook's list, so "the nearest row above
* the pointer" is not enough to say which list is being dragged in. Rows are
* looked up by scope, and a note carried in the notebook simply finds no target
* over the notes inside a chapter.
*/
export function useReorderDrag(
enabled: boolean,
reorder: (dragId: string, targetId: string) => void,
fileInto?: (dragId: string, targetId: string) => void
fileInto?: (dragId: string, targetId: string) => void,
scope = ""
): ReorderDrag {
const [overId, setOverId] = useState<string | null>(null);
const [intoId, setIntoId] = useState<string | null>(null);
@@ -73,6 +83,12 @@ export function useReorderDrag(
const onPointerDown = useCallback(
(id: string) => (e: React.PointerEvent) => {
if (!enabled || e.button > 0) return;
/* Only presses that belong to this list. A chapter's rows sit inside a row
of the notebook's list, so its events travel up through one — and a grip
pressed in the chapter would otherwise pick up the chapter's whole row
as well. The nearest scoped row decides whose press it is. */
const own = (e.target as HTMLElement)?.closest?.("[data-reorder-scope]") as HTMLElement | null;
if (own && own.dataset.reorderScope !== scope) return;
const fromGrip = !!(e.target as HTMLElement)?.closest?.("[data-reorder-grip]");
// A finger on the row itself is a scroll or a long press, not a drag.
if (e.pointerType !== "mouse" && !fromGrip) return;
@@ -84,7 +100,7 @@ export function useReorderDrag(
e.preventDefault();
}
},
[enabled]
[enabled, scope]
);
useEffect(() => {
@@ -103,7 +119,7 @@ export function useReorderDrag(
// tracked with enter/leave handlers, because a finger generates no
// pointerover on anything it did not press.
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
const row = el?.closest?.("[data-reorder-id]") as HTMLElement | null;
const row = el?.closest?.(`[data-reorder-scope="${scope}"]`) as HTMLElement | null;
const target = row?.dataset.reorderId ?? null;
const next = target && target !== d.id ? target : null;
/* A chapter is both a place and a neighbour, so which one it is depends on
@@ -156,7 +172,7 @@ export function useReorderDrag(
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", stop);
};
}, [enabled, reorder, fileInto, stop]);
}, [enabled, reorder, fileInto, scope, stop]);
// A drag that outlives its list would leave the page unselectable.
useEffect(
@@ -172,6 +188,7 @@ export function useReorderDrag(
draggingId,
rowProps: (id: string, into = false) => ({
"data-reorder-id": id,
"data-reorder-scope": scope,
...(into ? { "data-reorder-into": "" } : {}),
onPointerDown: onPointerDown(id),
}),
@@ -34,6 +34,21 @@ describe("blocks", () => {
expect(quote.content![0]!.type).toBe("heading");
});
it("keeps a quote together across the `>` lines that divide it", () => {
const quote = first("> #### Kopf\n>\n> - eins\n> - zwei\n>\n> *Alles* nach **Plan**.");
expect(quote.type).toBe("blockquote");
expect(quote.content!.map((n) => n.type)).toEqual(["heading", "bulletList", "paragraph"]);
// A quoted blank line divides; it must not be read as the end of the quote.
expect(plain(quote.content![2]!)).toBe("Alles nach Plan.");
expect(quote.content![2]!.content![0]!.marks![0]!.type).toBe("italic");
});
it("reads a quote inside a quote", () => {
const outer = first("> außen\n>\n> > innen");
expect(outer.content!.map((n) => n.type)).toEqual(["paragraph", "blockquote"]);
expect(plain(outer.content![1]!)).toBe("innen");
});
it("joins the lines of a paragraph", () => {
expect(plain(first("erste Zeile\nzweite Zeile"))).toBe("erste Zeile zweite Zeile");
});
@@ -44,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");
+25
View File
@@ -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 === "[") {
+4
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import NoteApp from "./App.jsx";
import { mountHoneybar } from "./honeybar.js";
// Hide splash screen
const hideSplash = () => {
@@ -19,6 +20,9 @@ ReactDOM.createRoot(document.getElementById("root")).render(
setTimeout(hideSplash, 200);
// Not awaited: the bar is a decoration and must never hold up the app.
mountHoneybar();
// Register service worker
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
+103 -9
View File
@@ -16,6 +16,7 @@ import type {
NoteVersionSummary,
Notebook,
QueueEntry,
SortMode,
StrokeDoc,
SyncStatus,
User,
@@ -86,6 +87,7 @@ interface ServerChapter {
color?: string | null;
icon?: string | null;
sort_order?: number;
sort_mode?: string | null;
}
interface ServerNote {
@@ -253,7 +255,9 @@ const api = {
this.forgetToken();
throw new SyncError("AUTH");
}
if (!r.ok) throw new SyncError("REQUEST_FAILED");
// The status travels with the failure: "it would not open on my phone" is a
// different bug at 404 than at 502, and without it both look the same.
if (!r.ok) throw new SyncError("REQUEST_FAILED", `HTTP ${r.status}`);
return r.blob();
},
};
@@ -352,6 +356,41 @@ export async function login(username: string, password: string): Promise<User> {
return acceptAuth(await api.post<AuthResponse>("/auth/login", { username, password }));
}
/** The ways in that this server offers. */
export interface AuthMethods {
password: boolean;
oidc: { enabled: boolean; label?: string };
}
/**
* What the sign-in screen may offer, asked before anyone has signed in.
*
* Falls back to the password form on any failure: offline, or a server older
* than this route, must not leave the screen with no way in at all.
*/
export async function authMethods(): Promise<AuthMethods> {
try {
const m = await api.get<AuthMethods>("/auth/methods");
return { password: m?.password !== false, oidc: { enabled: !!m?.oidc?.enabled, label: m?.oidc?.label } };
} catch {
return { password: true, oidc: { enabled: false } };
}
}
/**
* Where the browser goes to sign in at the provider.
*
* A full page visit, not fetch: the provider needs its own screen, its own
* cookies and possibly a passkey prompt, and none of that can happen inside an
* XHR. It comes back to the app with a one-time ticket.
*/
export const oidcStartUrl = (): string => `${api.base}/auth/oidc/start`;
/** Swaps that ticket for the same token a password login would have produced. */
export async function claimOidcTicket(ticket: string): Promise<User> {
return acceptAuth(await api.post<AuthResponse>("/auth/oidc/claim", { ticket }));
}
/* There is no register(): this instance has no public sign-up. Accounts are
* created by an administrator through the functions under "Administration". */
@@ -390,6 +429,7 @@ export async function restoreSession(): Promise<User | Partial<User> | null> {
export async function logout(): Promise<void> {
api.forgetToken();
forgetAvatars();
await db.clearAll();
}
@@ -547,11 +587,25 @@ type QueuedAction =
| { action: "deleteNotebook"; data: { id: string } }
| {
action: "createChapter";
data: { localId: string; notebookId: string; name: string; color?: string | null; icon?: string | null };
data: {
localId: string;
notebookId: string;
name: string;
color?: string | null;
icon?: string | null;
sortMode?: SortMode | null;
};
}
| {
action: "updateChapter";
data: { id: string; name?: string; sortOrder?: number; color?: string | null; icon?: string | null };
data: {
id: string;
name?: string;
sortOrder?: number;
color?: string | null;
icon?: string | null;
sortMode?: SortMode | null;
};
}
| { action: "deleteChapter"; data: { id: string } }
| {
@@ -667,6 +721,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
name: d.name,
color: d.color ?? null,
icon: d.icon ?? null,
sortMode: d.sortMode ?? null,
});
if (r.id !== d.localId) {
const local = (await db.getAllChapters()).find((c) => c.id === d.localId);
@@ -678,6 +733,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
color: d.color ?? null,
icon: d.icon ?? null,
sortOrder: local?.sortOrder ?? 0,
sortMode: d.sortMode ?? null,
});
// Notes filed into it while it was local, and anything still queued
// behind this create — both address a name only this device knew.
@@ -694,6 +750,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
sortOrder: a.data.sortOrder,
color: a.data.color,
icon: a.data.icon,
sortMode: a.data.sortMode,
});
break;
case "deleteChapter":
@@ -829,6 +886,10 @@ export async function fetchChapters(): Promise<Chapter[]> {
color: c.color ?? null,
icon: c.icon ?? null,
sortOrder: c.sort_order || 0,
/* Read here or it is lost: every sync replaces the local mirror with what
this function returns, and a chapter that came back without its order
would drop to the hand-made one seconds after being set. */
sortMode: (c.sort_mode as SortMode | null) ?? null,
}));
const local = await db.getAllChapters();
// Anything made offline is kept: its create is still in the queue, and the
@@ -843,13 +904,14 @@ export async function createChapter(
notebookId: string,
name: string,
color: string | null = null,
icon: string | null = null
icon: string | null = null,
sortMode: SortMode | null = null
): Promise<Chapter> {
const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length;
if (_online && api.token) {
try {
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon });
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder };
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon, sortMode });
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder, sortMode };
await db.putChapter(chapter);
notifyData("chapter", chapter);
return chapter;
@@ -858,9 +920,9 @@ export async function createChapter(
}
}
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, _pendingCreate: true };
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, sortMode, _pendingCreate: true };
await db.putChapter(chapter);
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon } });
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon, sortMode } });
notifyData("chapter", chapter);
notifyStatus("pending");
return chapter;
@@ -868,7 +930,7 @@ export async function createChapter(
export async function updateChapter(
id: string,
updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null }
updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null; sortMode?: SortMode | null }
): Promise<void> {
const chapter = (await db.getAllChapters()).find((c) => c.id === id);
if (!chapter) return;
@@ -1402,6 +1464,38 @@ export async function fetchAttachmentBlob(id: string): Promise<Blob> {
return api.getBlob(`/attachments/${id}`);
}
/* ─── Profile pictures ───
*
* From the identity provider, stored on our own server and served from there,
* so nothing on the page talks to the provider and a picture keeps working when
* it is unreachable. It comes through the API with a bearer token, which an
* `<img src>` cannot carry — so the bytes are fetched once and turned into an
* object URL, exactly as an attached image is.
*
* Cached by account for the life of the page, since it is drawn in two places
* and changes only at the next sign-in.
*/
const avatarUrls = new Map<string, Promise<string | null>>();
export function avatarUrl(userId: string): Promise<string | null> {
const known = avatarUrls.get(userId);
if (known) return known;
const pending = api
.getBlob(`/users/${userId}/avatar`)
.then((blob) => URL.createObjectURL(blob))
.catch(() => null);
avatarUrls.set(userId, pending);
return pending;
}
/** Forgets them, so signing out does not leave one person's face for the next. */
export function forgetAvatars(): void {
for (const pending of avatarUrls.values()) {
pending.then((url) => url && URL.revokeObjectURL(url)).catch(() => {});
}
avatarUrls.clear();
}
/* ─── PDFs ───
*
* A PDF rides on the attachment machinery — encrypted at rest, named by content
+16
View File
@@ -126,6 +126,10 @@ export interface Chapter {
/** An emoji standing in for the default folder mark. */
icon?: string | null;
sortOrder: number;
/* How its own notes are ordered — the notebook's setting governs the
notebook's own list, not what is inside a chapter. Null is the hand-made
order, which is what a chapter is in until it is told otherwise. */
sortMode?: SortMode | null;
/** Set while a chapter created offline has not reached the server yet. */
_pendingCreate?: boolean;
}
@@ -161,6 +165,8 @@ export type ChapterView = "column" | "inline";
export interface UserPreferences {
noteFont: NoteFont;
chapterView?: ChapterView;
/** Light or dark, kept on the account so it follows you to the next device. */
theme?: "dark" | "light";
}
export interface User {
@@ -171,6 +177,16 @@ export interface User {
isAdmin?: boolean;
/** Interface settings, kept on the account so they follow you to the phone. */
preferences?: UserPreferences;
/** True when this account has an identity at the provider and can sign in with it. */
linkedToProvider?: boolean;
/**
* True when this account has no password of its own — the provider made it.
* The interface uses this to stop offering a password to change; an
* administrator can still set one, which is the way back in if the provider is
* ever unreachable. An account that had a password first and was later linked
* keeps it, and is `linkedToProvider` without being this.
*/
signsInWithProvider?: boolean;
/**
* How many versions a notebook keeps when it names no number of its own.
*
+98 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
import { Avatar } from "./Avatar.jsx";
import { useBackdropClose } from "./backdrop";
/* ═══════ ACCOUNT MENU ═══════ */
@@ -23,7 +24,26 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
};
const chooseFont = (noteFont) => choosePreference({ noteFont });
const chooseChapterView = (chapterView) => choosePreference({ chapterView });
const chooseTheme = (theme) => choosePreference({ theme });
const [tab, setTab] = useState("profile"); // profile | username | password
/* Two different things. `viaProvider` — this account has no password here at
all, so the tab for changing one would lead to a form that can only fail;
the server refuses it too, and this is the half that says why. `linked` —
it can sign in through the provider, which is also true of an account that
had a password first and kept it. */
const viaProvider = !!user?.signsInWithProvider;
const linked = !!user?.linkedToProvider;
/* Only to put a name to it. Asked here rather than carried on the account,
because it is a server setting and not a property of the person. */
const [providerName, setProviderName] = useState("");
useEffect(() => {
if (!linked) return;
let alive = true;
sync.authMethods().then((m) => alive && setProviderName(m?.oidc?.label || ""));
return () => {
alive = false;
};
}, [linked]);
const [displayName, setDisplayName] = useState(user?.displayName || "");
const [newUsername, setNewUsername] = useState(user?.username || "");
const [usernamePass, setUsernamePass] = useState("");
@@ -88,7 +108,8 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
setSaving(false);
};
const saveUsername = async () => {
if (!newUsername || !usernamePass) return;
// The password is only part of this when there is one to give.
if (!newUsername || (!usernamePass && !viaProvider)) return;
clear();
setSaving(true);
try {
@@ -157,6 +178,8 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
borderBottom: `1px solid ${T.border}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<Avatar user={user} size={34} />
<h3
style={{
margin: 0,
@@ -168,6 +191,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
>
Account
</h3>
</div>
<button
onClick={onClose}
style={{
@@ -207,6 +231,12 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
>
Username
</button>
{/* Not shown to an account that signs in through the provider — even
one that still has a password here. Passwords for those accounts
are an administrator's business: the one that exists is the way
back in if the provider is unreachable, not a second thing for its
owner to keep. */}
{!linked && (
<button
onClick={() => {
setTab("password");
@@ -216,6 +246,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
>
Password
</button>
)}
</div>
<div style={{ padding: "18px 22px 22px" }}>
{msg && (
@@ -280,6 +311,61 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
{saving ? "Saving..." : "Save Changes"}
</button>
{/* How this account gets in. Shown because it explains the tab
that is missing beside it, and because "my password does not
work" is what an account with no password feels like. */}
{linked && (
<div
data-account-sso
style={{ marginTop: 18, fontSize: 11.5, color: T.muted, lineHeight: 1.55 }}
>
Signed in through {providerName || "your identity provider"}.
{viaProvider
? " This account has no password here; an administrator can give it one if the provider is ever unreachable."
: " The password here still works as a way in, and is changed by an administrator."}
</div>
)}
{/* Light or dark. It used to be a sun in the corner of the sidebar,
reached for by accident and forgotten on the next device; it is
a setting, so it lives with the settings and on the account. */}
<div style={{ marginTop: 22 }}>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
Appearance
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}>
{[
["dark", "Dark", I.moon],
["light", "Light", I.sun],
].map(([value, label, icon]) => {
const active = (user.preferences?.theme || "dark") === value;
return (
<button
key={value}
data-theme-option={value}
onClick={() => chooseTheme(value)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "10px 6px",
borderRadius: 10,
border: `1.5px solid ${active ? T.accent : T.border}`,
background: active ? T.accent + "15" : "transparent",
color: active ? T.accent : T.text,
cursor: "pointer",
fontSize: 13,
}}
>
<span style={{ display: "flex" }}>{icon}</span>
{label}
</button>
);
})}
</div>
</div>
{/* Kept on the account rather than in this browser: a choice about
how your own notes are set should follow you to the phone, not
be made again on every device. The export is untouched — this
@@ -378,6 +464,14 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
<div style={{ fontSize: 11, color: T.muted, marginTop: -6, marginBottom: 10 }}>
3–32 characters · letters, digits, . _ -
</div>
{viaProvider ? (
<div style={{ fontSize: 11, color: T.muted, marginBottom: 12, lineHeight: 1.5 }}>
You sign in through the identity provider, so there is no password to confirm
with. The name here is this app's own — changing it does not touch the one over
there.
</div>
) : (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Confirm Password
</label>
@@ -390,6 +484,8 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
if (e.key === "Enter") saveUsername();
}}
/>
</>
)}
<button
onClick={saveUsername}
disabled={saving}
@@ -411,7 +507,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
</>
)}
{tab === "password" && (
{tab === "password" && !linked && (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Current Password
+48 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
import { Avatar } from "./Avatar.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
import { useBackdropClose } from "./backdrop";
@@ -263,6 +264,10 @@ export function AdminPanel({ currentUser, onClose, dark }) {
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{/* The picture the provider has of them, when it has one. It
says at a glance which rows are people who came from over
there, which the badge says in words. */}
<Avatar user={u} size={30} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{u.username}</span>
@@ -281,11 +286,42 @@ export function AdminPanel({ currentUser, onClose, dark }) {
ADMIN
</span>
)}
{/* Which accounts can sign in through the provider — an
account created by it and one that merely linked to it
both wear this, because from here they are the same
kind of thing. Whether there is also a password is a
second question, answered on the line below. */}
{u.linkedToProvider && (
<span
data-user-sso
title={
u.signsInWithProvider
? "Signs in through the identity provider, and has no password here"
: "Can sign in through the identity provider, and has a password here as well"
}
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.04em",
padding: "2px 7px",
borderRadius: 6,
background: T.faint + "22",
color: T.muted,
}}
>
SSO
</span>
)}
{u.isSelf && <span style={{ fontSize: 11, color: T.faint }}>you</span>}
</div>
<div style={{ fontSize: 11, color: T.muted, marginTop: 3 }}>
{u.displayName} · {u.noteCount} note{u.noteCount === 1 ? "" : "s"} · since{" "}
{fmtDate(u.createdAt)}
{/* Said here rather than only in the badge: this is the
account whose owner has nothing to type on the sign-in
screen, which is exactly what an administrator needs to
know before the provider is ever unreachable. */}
{u.signsInWithProvider && <> · no password here</>}
</div>
</div>
<button
@@ -296,7 +332,7 @@ export function AdminPanel({ currentUser, onClose, dark }) {
}}
style={{ ...btn("ghost"), whiteSpace: "nowrap" }}
>
Reset password
{u.signsInWithProvider ? "Give a password" : "Reset password"}
</button>
{!u.isSelf && (
<button
@@ -327,6 +363,17 @@ export function AdminPanel({ currentUser, onClose, dark }) {
{resetFor === u.id && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${T.border}` }}>
{/* The way back in. Worth saying, because on an account with
no password this reads like an odd thing to be offered —
and because it adds a way in rather than replacing the
one the provider gives. */}
{u.signsInWithProvider && (
<div style={{ fontSize: 11.5, color: T.muted, marginBottom: 8, lineHeight: 1.5 }}>
{u.username} signs in through the identity provider and has no password here. Giving
one adds a second way in — useful the day the provider cannot be reached — and does
not take the first one away.
</div>
)}
<input
type="password"
value={resetPass}
+54 -7
View File
@@ -1,19 +1,32 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
/**
* Sign-in only.
*
* This is a private instance with no public sign-up: accounts are created by an
* administrator under Account → Administration. There is deliberately nothing
* here that hints at how, because that would only be useful to someone who has
* no account and should not get one.
* administrator under Account → Administration, or by the identity provider if
* one is configured. There is deliberately nothing here that hints at how,
* because that would only be useful to someone who has no account and should
* not get one — which is also why the provider is named and nothing else about
* it is.
*/
export function AuthScreen({ onLogin, dark }) {
export function AuthScreen({ onLogin, dark, initialError = "" }) {
const [username, setUsername] = useState("");
const [pass, setPass] = useState("");
const [err, setErr] = useState("");
const [err, setErr] = useState(initialError);
const [loading, setLoading] = useState(false);
/* Assume password only until the server says otherwise, so the form is never
missing while the answer is in flight. */
const [methods, setMethods] = useState({ password: true, oidc: { enabled: false } });
useEffect(() => {
let alive = true;
sync.authMethods().then((m) => alive && setMethods(m));
return () => {
alive = false;
};
}, []);
const T = useMemo(
() => ({
@@ -68,7 +81,7 @@ export function AuthScreen({ onLogin, dark }) {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
minHeight: "calc(100vh - var(--honeybar-h, 0px))",
background: T.bg,
fontFamily: "'DM Sans',sans-serif",
}}
@@ -170,6 +183,40 @@ export function AuthScreen({ onLogin, dark }) {
>
{loading ? "..." : "Sign In"}
</button>
{/* The other way in. A whole-page visit rather than a request: the
provider needs its own screen, its own cookies, and on a phone the
passkey prompt that goes with them. */}
{methods.oidc?.enabled && (
<>
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: "18px 0" }}>
<div style={{ flex: 1, height: 1, background: T.border }} />
<div style={{ fontSize: 11, color: T.muted, textTransform: "uppercase", letterSpacing: 0.6 }}>or</div>
<div style={{ flex: 1, height: 1, background: T.border }} />
</div>
<a
href={sync.oidcStartUrl()}
data-oidc-signin
style={{
display: "block",
width: "100%",
padding: "12px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
textAlign: "center",
textDecoration: "none",
boxSizing: "border-box",
}}
>
Continue with {methods.oidc.label || "single sign-on"}
</a>
</>
)}
</div>
</div>
);
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import * as sync from "../syncEngine";
/**
* The picture an account wears.
*
* It comes from the identity provider and is stored on our own server, so it is
* fetched through the API with a bearer token — which an `<img src>` cannot
* carry. The bytes are turned into an object URL once per account and per page,
* the same way an attached image is drawn.
*
* Nothing is rendered for an account without one. This deliberately does *not*
* fall back to initials in a coloured circle: the places that use it already
* have something to draw — the person icon in the sidebar, a name in the
* administration list — and replacing those for everybody would be a redesign
* on the back of a feature that only some accounts have.
*/
export function Avatar({ user, size = 24, style = {} }) {
const [url, setUrl] = useState(null);
const [broken, setBroken] = useState(false);
useEffect(() => {
setBroken(false);
if (!user?.id || !user?.hasAvatar) {
setUrl(null);
return undefined;
}
let alive = true;
sync.avatarUrl(user.id).then((u) => alive && setUrl(u));
return () => {
alive = false;
};
}, [user?.id, user?.hasAvatar]);
if (!url || broken) return null;
return (
<img
data-avatar={user.id}
src={url}
alt=""
width={size}
height={size}
// A picture from elsewhere is any shape and any proportion; the frame is
// ours, and the picture is cropped into it rather than stretched.
onError={() => setBroken(true)}
style={{ width: size, height: size, borderRadius: "50%", objectFit: "cover", flexShrink: 0, ...style }}
/>
);
}
+37 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx";
import { SORT_LABELS, SORT_MODES } from "../features/notes/noteSort";
import { IconChooser } from "./IconChooser.jsx";
import { I } from "./icons.jsx";
import { useBackdropClose } from "./backdrop";
@@ -23,6 +24,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const [name, setName] = useState(chapter?.name || "");
const [icon, setIcon] = useState(chapter?.icon ?? null);
const [colour, setColour] = useState(chapter?.color ?? null);
const [sortMode, setSortMode] = useState(chapter?.sortMode || "manual");
const inputRef = useRef(null);
const backdrop = useBackdropClose(onClose);
@@ -46,7 +48,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const ready = !!name.trim();
const save = () => {
if (!ready) return;
onSave({ name: name.trim(), icon, color: colour });
onSave({ name: name.trim(), icon, color: colour, sortMode });
onClose();
};
@@ -144,6 +146,37 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
}}
/>
{/* A chapter orders its own notes.
The notebook's setting governs the notebook's own list; a chapter is
a place someone made and filled, so a reference chapter can stay
alphabetical while the notebook around it stays as it was arranged.
Lines and handles belong to the hand-made order and are only offered
there — in "newest first" the notes would slide past a line. */}
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Order</div>
<select
value={sortMode}
aria-label="Chapter order"
data-chapter-sort
onChange={(e) => setSortMode(e.target.value)}
style={{
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13,
fontFamily: "inherit",
outline: "none",
}}
>
{SORT_MODES.map((mode) => (
<option key={mode} value={mode}>
{SORT_LABELS[mode]}
</option>
))}
</select>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
@@ -175,7 +208,9 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
</div>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Icon</div>
<IconChooser T={T} value={icon} onPick={setIcon} height={170} />
{/* A little taller than it was: the headings between the groups cost a
row, and at 170 the first group of marks was the only one in view. */}
<IconChooser T={T} value={icon} onPick={setIcon} height={200} />
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 20 }}>
<button
+221 -50
View File
@@ -1,82 +1,255 @@
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
/**
* The marks a note or a chapter can wear, and the grid for choosing one.
*
* A fixed list rather than the system emoji keyboard. That keyboard is a
* different thing on every machine — on Android it is the one for typing, which
* means opening it inside a text field the app does not have — and it offers
* three thousand pictures for a decision that is "which of these is my invoice
* notebook". A few dozen, searchable by what they are for, is the whole job.
* A list of one's own rather than the system emoji keyboard. That keyboard is a
* different thing on every machine, and it offers three thousand pictures for a
* decision that is "which of these is my invoice notebook". Some dozens,
* searchable by what they are for, is the whole job — and the search box is a
* text field, so the keyboard is still there for whoever wants it.
*
* The words are what make it usable: nobody hunts a pictogram grid by shape.
* They are also why the list is written out rather than generated.
*
* One component, used by both dialogs that offer a mark. Two grids would drift.
* There are two ways a mark gets in. **Adding one to this list is one line** — a
* `{ char, words }` in whichever group it belongs to. Nothing else: what is
* stored on a note is the character itself, not a number into this list, so the
* order may change and entries may come and go without anything already chosen
* being disturbed. Give it the words someone would actually search for — what
* the thing is *for*, not what it depicts. And **anything else can be typed into
* the search box**, which is where the system emoji keyboard lives on a phone;
* see `typedMark`.
*
* The groups exist because the list outgrew a single wall of pictures. They are
* headings, not filters: searching looks through all of them at once.
*
* One component, used by both dialogs that offer a mark. Two grids would drift —
* and did: for a while there were two copies of this list, and only one of them
* was ever extended.
*/
export const ICONS = [
export const ICON_GROUPS = [
{
name: "Notes",
icons: [
{ char: "📄", words: "note paper document default" },
{ char: "📝", words: "note write draft memo" },
{ char: "✏️", words: "draft writing pencil unfinished" },
{ char: "📌", words: "pin important stick" },
{ char: "🔖", words: "bookmark marker reference" },
{ char: "⭐", words: "star favourite important" },
{ char: "❗", words: "important urgent attention" },
{ char: "❓", words: "question open unclear" },
{ char: "✅", words: "done finished checked task" },
{ char: "🕒", words: "later waiting time pending" },
{ char: "🚩", words: "flag follow up marked" },
{ char: "💡", words: "idea thought concept" },
{ char: "🔧", words: "tool fix repair maintenance" },
{ char: "🔨", words: "build work project diy" },
{ char: "🧠", words: "thinking notes knowledge brain" },
{ char: "🔍", words: "research looking into search" },
{ char: "🗒️", words: "list scratch pad jotting" },
],
},
{
name: "Home & money",
icons: [
{ char: "🏠", words: "house home flat property" },
{ char: "🔑", words: "keys landlord tenancy access" },
{ char: "🔌", words: "power electricity socket energy" },
{ char: "💧", words: "water plumbing bill" },
{ char: "🔥", words: "heating gas fire boiler" },
{ char: "🚗", words: "car vehicle travel garage" },
{ char: "🌡️", words: "heating temperature thermostat climate" },
{ char: "🧾", words: "receipt invoice bill accounting" },
{ char: "💶", words: "money euro payment bank finance" },
{ char: "💳", words: "card payment subscription direct debit" },
{ char: "🏦", words: "bank account finance" },
{ char: "📊", words: "chart numbers report statistics" },
{ char: "📈", words: "growth investment shares budget" },
{ char: "🛠️", words: "repair maintenance handyman fixing" },
{ char: "🔧", words: "tool fix repair maintenance" },
{ char: "🔨", words: "build work project diy" },
{ char: "🧹", words: "cleaning chores household" },
{ char: "🪑", words: "furniture room interior" },
{ char: "📮", words: "post letter mail correspondence" },
{ char: "🗑️", words: "waste bin rubbish disposal" },
],
},
{
name: "Work & study",
icons: [
{ char: "💼", words: "work job office business" },
{ char: "🏢", words: "company employer firm office" },
{ char: "📅", words: "calendar date appointment plan" },
{ char: "✈️", words: "travel holiday flight trip" },
{ char: "🍳", words: "cooking recipe kitchen food" },
{ char: "🛒", words: "shopping list groceries" },
{ char: "🎁", words: "gift present birthday" },
{ char: "🎵", words: "music song audio" },
{ char: "📷", words: "photo picture camera" },
{ char: "🎬", words: "film video movie watch" },
{ char: "🗓️", words: "schedule planning week year" },
{ char: "📚", words: "book reading study library" },
{ char: "🎓", words: "school study course learning" },
{ char: "💼", words: "work job office business" },
{ char: "👥", words: "meeting people team colleagues" },
{ char: "🗣️", words: "talk interview conversation minutes" },
{ char: "📞", words: "call phone contact" },
{ char: "✉️", words: "email letter message" },
{ char: "📎", words: "attachment paperwork documents" },
{ char: "🗂️", words: "files archive folder sorting" },
{ char: "📦", words: "package delivery order storage" },
{ char: "⚖️", words: "legal contract law insurance" },
],
},
{
name: "Tech",
icons: [
{ char: "🖥️", words: "computer server it hardware" },
{ char: "🌐", words: "web internet network domain" },
{ char: "🔒", words: "password secret secure private" },
{ char: "🐳", words: "docker container server" },
{ char: "🧠", words: "thinking notes knowledge brain" },
{ char: "❤️", words: "love personal family" },
{ char: "🐾", words: "pet animal dog cat" },
{ char: "🌱", words: "garden plant growing nature" },
{ char: "⚙️", words: "settings configuration setup" },
{ char: "🧩", words: "plugin module integration piece" },
{ char: "🗄️", words: "database storage backup archive" },
{ char: "📡", words: "network router connection signal" },
{ char: "🔋", words: "power battery devices charging" },
{ char: "🖨️", words: "printer scanner paperwork device" },
{ char: "📱", words: "phone mobile app device" },
{ char: "🧪", words: "experiment testing trying out" },
],
},
{
name: "Life",
icons: [
{ char: "✈️", words: "travel holiday flight trip" },
{ char: "🧳", words: "packing luggage journey" },
{ char: "🏖️", words: "holiday beach break rest" },
{ char: "🚗", words: "car vehicle travel garage" },
{ char: "🚲", words: "bike cycling commute" },
{ char: "🍳", words: "cooking recipe kitchen food" },
{ char: "🍽️", words: "restaurant eating out dinner" },
{ char: "☕", words: "coffee break cafe morning" },
{ char: "🛒", words: "shopping list groceries" },
{ char: "🎁", words: "gift present birthday" },
{ char: "🎂", words: "birthday anniversary celebration" },
{ char: "🎄", words: "christmas holidays festive" },
{ char: "🎵", words: "music song audio" },
{ char: "📷", words: "photo picture camera" },
{ char: "🎬", words: "film video movie watch" },
{ char: "🎨", words: "art drawing design creative" },
{ char: "🎮", words: "games gaming console hobby" },
{ char: "⚽", words: "sport football hobby" },
{ char: "🏃", words: "running fitness health sport" },
{ char: "🎯", words: "goal aim target plan" },
{ char: "🌱", words: "garden plant growing nature" },
{ char: "🐾", words: "pet animal dog cat" },
{ char: "❤️", words: "love personal family" },
{ char: "👶", words: "child children family kids" },
{ char: "💊", words: "health medicine doctor" },
{ char: "🩺", words: "doctor health medical appointment" },
{ char: "📦", words: "package delivery order storage" },
{ char: "🗂️", words: "files archive folder sorting" },
{ char: "🦷", words: "dentist teeth appointment" },
{ char: "🧘", words: "wellbeing calm mindfulness routine" },
],
},
];
export function IconChooser({ T, value, onPick, height = 200 }) {
/** Every mark, in one flat list — for searching, and for anything that just
wants to know whether a character is one of ours. */
export const ICONS = ICON_GROUPS.flatMap((g) => g.icons);
/**
* What the tiles under it are.
*
* Across the whole grid, and deliberately unlike the tiles: small, faint and
* flush left. Shaped like one it would be a button that does nothing when
* pressed. `name` is set only on the real groups, so a test can count them.
*/
function Heading({ T, name, children }) {
return (
<div
{...(name ? { "data-icon-group": name } : {})}
style={{
gridColumn: "1 / -1",
marginTop: 4,
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.7,
textTransform: "uppercase",
color: T.muted,
cursor: "default",
userSelect: "none",
}}
>
{children}
</div>
);
}
/** A group heading matches too: "tech" should find the tech ones. */
const matches = (icon, group, q) =>
icon.words.includes(q) || icon.char === q || group.toLowerCase().includes(q);
/**
* What was typed, when it is a picture rather than a word.
*
* The list above is deliberately short — but the search box is a text field, and
* on a phone a text field is exactly where the system emoji keyboard lives. So a
* mark nobody thought of can still be used: type or paste it, and it is offered
* as a tile. That is the second way marks get in, and it needs no release.
*
* Eight code points, the same limit the server keeps, so one glyph with its
* modifiers fits and a pasted sentence does not. Letters, digits and spaces mean
* a word was typed and a search is meant, not a mark.
*/
export function typedMark(query) {
const raw = [...query.trim()].slice(0, 8).join("");
if (!raw || /[\p{L}\p{N}\s]/u.test(raw)) return null;
if (!/\p{Extended_Pictographic}/u.test(raw)) return null;
return ICONS.some((i) => i.char === raw) ? null : { char: raw, words: "typed" };
}
export function IconChooser({ T, value, onPick, height = 200, autoFocus = false, onEscape = null }) {
const [query, setQuery] = useState("");
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ICONS;
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
if (!q) return ICON_GROUPS;
return ICON_GROUPS.map((g) => ({ ...g, icons: g.icons.filter((i) => matches(i, g.name, q)) })).filter(
(g) => g.icons.length > 0
);
}, [query]);
/* Marks that are not in the list: what was just typed, and what this row is
already wearing if it was typed some other day. Without the second one, a
mark chosen that way was invisible when the dialog was opened again and
looked as though it had been lost. */
const loose = useMemo(() => {
const own = value && !ICONS.some((i) => i.char === value) ? { char: value, words: "chosen" } : null;
const typed = typedMark(query);
return [own, typed && typed.char !== own?.char ? typed : null].filter(Boolean);
}, [query, value]);
const tile = (icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => onPick(icon.char)}
style={{
height: 40,
borderRadius: 10,
cursor: "pointer",
fontSize: 20,
lineHeight: 1,
border: value === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: value === icon.char ? `${T.accent}22` : "transparent",
}}
>
{icon.char}
</button>
);
return (
<>
<input
autoFocus={autoFocus}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && onEscape?.()}
aria-label="Search icons"
placeholder="Search — invoice, house, travel…"
placeholder="Search — invoice, house, travel… or type an emoji"
style={{
width: "100%",
padding: "9px 12px",
@@ -100,7 +273,11 @@ export function IconChooser({ T, value, onPick, height = 200 }) {
}}
>
{/* "None" belongs in the grid rather than off in a corner: taking a mark
off is the same kind of choice as putting one on. */}
off is the same kind of choice as putting one on. It stays put while
searching — it is not one of the pictures being searched through —
and it carries a heading like every other row, or it stands alone in
a line of empty space and reads as a gap. */}
<Heading T={T}>None</Heading>
<button
type="button"
data-icon-option="none"
@@ -120,27 +297,21 @@ export function IconChooser({ T, value, onPick, height = 200 }) {
>
—
</button>
{shown.map((icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => onPick(icon.char)}
style={{
height: 40,
borderRadius: 10,
cursor: "pointer",
fontSize: 20,
lineHeight: 1,
border: value === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: value === icon.char ? `${T.accent}22` : "transparent",
}}
>
{icon.char}
</button>
{loose.length > 0 && (
<>
<Heading T={T}>Typed</Heading>
{loose.map(tile)}
</>
)}
{shown.map((group) => (
<Fragment key={group.name}>
<Heading T={T} name={group.name}>
{group.name}
</Heading>
{group.icons.map(tile)}
</Fragment>
))}
{shown.length === 0 && (
{shown.length === 0 && loose.length === 0 && (
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
Nothing by that name.
</div>
+11 -139
View File
@@ -1,75 +1,22 @@
import { useMemo, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx";
import { IconChooser } from "./IconChooser.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Choosing the mark a note or a chapter wears beside its name.
* Choosing the mark a note wears beside its name, and the colour that goes with
* it.
*
* A fixed list rather than the system emoji keyboard. That keyboard is a
* different thing on every machine — on Android it is the one for typing, which
* means opening it inside a text field the app does not have — and it offers
* three thousand pictures for a decision that is "which of these is my invoice
* notebook". A few dozen, searchable by what they are for, is the whole job.
*
* The words are what makes it usable: nobody hunts a pictogram grid by shape.
* They are also the reason the list is written out rather than generated.
*/
const ICONS = [
{ char: "📄", words: "note paper document default" },
{ char: "📝", words: "note write draft memo" },
{ char: "📌", words: "pin important stick" },
{ char: "⭐", words: "star favourite important" },
{ char: "❗", words: "important urgent attention" },
{ char: "✅", words: "done finished checked task" },
{ char: "🕒", words: "later waiting time pending" },
{ char: "💡", words: "idea thought concept" },
{ char: "🔧", words: "tool fix repair maintenance" },
{ char: "🔨", words: "build work project diy" },
{ char: "🏠", words: "house home flat property" },
{ char: "🔌", words: "power electricity socket energy" },
{ char: "💧", words: "water plumbing bill" },
{ char: "🔥", words: "heating gas fire boiler" },
{ char: "🚗", words: "car vehicle travel garage" },
{ char: "🧾", words: "receipt invoice bill accounting" },
{ char: "💶", words: "money euro payment bank finance" },
{ char: "🏦", words: "bank account finance" },
{ char: "📊", words: "chart numbers report statistics" },
{ char: "📅", words: "calendar date appointment plan" },
{ char: "✈️", words: "travel holiday flight trip" },
{ char: "🍳", words: "cooking recipe kitchen food" },
{ char: "🛒", words: "shopping list groceries" },
{ char: "🎁", words: "gift present birthday" },
{ char: "🎵", words: "music song audio" },
{ char: "📷", words: "photo picture camera" },
{ char: "🎬", words: "film video movie watch" },
{ char: "📚", words: "book reading study library" },
{ char: "🎓", words: "school study course learning" },
{ char: "💼", words: "work job office business" },
{ char: "🖥️", words: "computer server it hardware" },
{ char: "🌐", words: "web internet network domain" },
{ char: "🔒", words: "password secret secure private" },
{ char: "🐳", words: "docker container server" },
{ char: "🧠", words: "thinking notes knowledge brain" },
{ char: "❤️", words: "love personal family" },
{ char: "🐾", words: "pet animal dog cat" },
{ char: "🌱", words: "garden plant growing nature" },
{ char: "⚽", words: "sport football hobby" },
{ char: "🏃", words: "running fitness health sport" },
{ char: "💊", words: "health medicine doctor" },
{ char: "🩺", words: "doctor health medical appointment" },
{ char: "📦", words: "package delivery order storage" },
{ char: "🗂️", words: "files archive folder sorting" },
];
/**
* The picker.
* The grid itself is `IconChooser` — the same one the chapter dialog uses. It
* had been copied here, and the copy fell behind: marks added to one list did
* not exist in the other, and which of the two you got depended on which dialog
* you happened to open.
*
* `value` and `colour` are what the row wears now; "None" is always offered,
* because a mark that cannot be taken off again is a trap.
*/
export function IconPicker({ title, value = null, colour = null, onSave, onClose, dark }) {
const [query, setQuery] = useState("");
const [picked, setPicked] = useState(value);
const [tint, setTint] = useState(colour);
const backdrop = useBackdropClose(onClose);
@@ -86,12 +33,6 @@ export function IconPicker({ title, value = null, colour = null, onSave, onClose
[dark]
);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ICONS;
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
}, [query]);
const save = (char, colourValue) => {
onSave(char, colourValue);
onClose();
@@ -125,62 +66,7 @@ export function IconPicker({ title, value = null, colour = null, onSave, onClose
>
<h3 style={{ margin: "0 0 12px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && onClose()}
aria-label="Search icons"
placeholder="Search — invoice, house, travel…"
style={{
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13,
outline: "none",
boxSizing: "border-box",
}}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(42px, 1fr))",
gap: 6,
marginTop: 14,
maxHeight: 240,
overflowY: "auto",
}}
>
{shown.map((icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => setPicked(icon.char)}
style={{
height: 42,
borderRadius: 10,
cursor: "pointer",
fontSize: 21,
lineHeight: 1,
border: picked === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: picked === icon.char ? `${T.accent}22` : "transparent",
}}
>
{icon.char}
</button>
))}
{shown.length === 0 && (
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
Nothing by that name.
</div>
)}
</div>
<IconChooser T={T} value={picked} onPick={setPicked} height={240} autoFocus onEscape={onClose} />
{/* The tint is for the marks that are drawn from the app's own set — a
coloured emoji ignores it, which is why it is offered quietly. */}
@@ -218,24 +104,10 @@ export function IconPicker({ title, value = null, colour = null, onSave, onClose
</div>
</div>
{/* No "No icon" button down here: the grid's own "—" says it, and two
ways to do one thing differ in what else they do — that one saved and
closed in a single press while every other choice waited for Save. */}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
<button
type="button"
data-icon-clear
onClick={() => save(null, null)}
style={{
padding: "8px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
cursor: "pointer",
fontSize: 13,
marginRight: "auto",
}}
>
No icon
</button>
<button
type="button"
onClick={onClose}
+23 -8
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { DEFAULT_FOLDERS, coverBackground } from "./constants.jsx";
import { I } from "./icons.jsx";
import { Avatar } from "./Avatar.jsx";
import { FONT_UI } from "./theme";
const SYNC_LABELS = {
@@ -128,8 +129,7 @@ function InstallRow({ T, canInstall, reason, onInstall }) {
export function Sidebar({
T,
dark,
onToggleDark,
user,
isMobile,
open,
onClose,
@@ -152,12 +152,14 @@ export function Sidebar({
return (
<div
data-sidebar
data-sidebar-open={open ? "yes" : "no"}
style={{
position: isMobile ? "fixed" : "relative",
zIndex: isMobile ? 900 : 1,
width: isMobile ? (open ? "100vw" : 0) : open ? 260 : 0,
minWidth: isMobile ? 0 : open ? 260 : 0,
height: isMobile ? "100vh" : "auto",
height: isMobile ? "calc(100vh - var(--honeybar-h, 0px))" : "auto",
background: T.sidebar,
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
display: "flex",
@@ -213,11 +215,24 @@ export function Sidebar({
</span>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button onClick={onToggleDark} style={iconButton(T)}>
{dark ? I.sun : I.moon}
</button>
<button onClick={onOpenAccount} title="Account" style={iconButton(T)}>
{I.user}
{/* Light or dark used to live here, next to the account. It is a
setting, not something reached for while writing, and it now sits
with the other settings — which leaves this corner to the one
button that is a place to go. */}
<button
onClick={onOpenAccount}
title="Account"
style={
user?.hasAvatar
? { ...iconButton(T), width: 34, height: 34, padding: 0, borderRadius: "50%" }
: iconButton(T)
}
>
{/* The picture instead of the person icon, for an account that has
one — as large as the button, since a face at twenty pixels is
a smudge. Not initials for the rest: the icon is what this
button has always been, and only some accounts bring a face. */}
{user?.hasAvatar ? <Avatar user={user} size={34} /> : I.user}
</button>
</div>
</div>
+50
View File
@@ -0,0 +1,50 @@
/* The list of marks is written out by hand, and adding one is meant to be a
* single line. These are the two mistakes that line can make. */
import { describe, expect, it } from "vitest";
import { ICONS, ICON_GROUPS, typedMark } from "./IconChooser.jsx";
describe("the marks a note can wear", () => {
it("holds each character once", () => {
// A repeat is invisible in the grid and breaks the row it sits in: the
// character is the key, and it is also what a saved note stores.
const seen = ICONS.map((i) => i.char);
expect(seen.length).toBe(new Set(seen).size);
});
it("gives every mark words to be found by", () => {
// Nobody hunts a pictogram grid by shape, so a mark with no words is a mark
// nobody will ever find.
const wordless = ICONS.filter((i) => i.words.trim().split(/\s+/).length < 2);
expect(wordless.map((i) => i.char)).toEqual([]);
});
it("keeps every mark in a group", () => {
expect(ICON_GROUPS.every((g) => g.name && g.icons.length > 0)).toBe(true);
expect(ICONS.length).toBe(ICON_GROUPS.reduce((n, g) => n + g.icons.length, 0));
});
});
describe("a mark typed into the search box", () => {
it("offers a picture that is not in the list", () => {
expect(typedMark("🦖")?.char).toBe("🦖");
expect(typedMark(" 🛰️ ")?.char).toBe("🛰️");
});
it("offers nothing for a word, since that is a search", () => {
expect(typedMark("house")).toBe(null);
expect(typedMark("")).toBe(null);
expect(typedMark("2")).toBe(null);
// Punctuation is not a picture either, whatever it is made of.
expect(typedMark("!?")).toBe(null);
});
it("offers nothing for a mark the list already has", () => {
expect(typedMark("🏠")).toBe(null);
});
it("takes one glyph, not a pasted sentence", () => {
// The server keeps eight code points; anything longer is cut to the same.
expect([...(typedMark("🦖🦕🐊🐢🦎🐍🦂🕷️🦀")?.char ?? "")].length).toBe(8);
});
});
+364 -15
View File
@@ -28,8 +28,9 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
- **Conflict-Aware Sync** — A write from a device that missed another's changes keeps the state it replaces as a version, rather than burying it
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
- **Encrypted at Rest** — AES-256-GCM for note content *and* uploaded files on the server
- **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public
sign-up: an administrator creates the accounts
- **JWT Authentication** — Username + password accounts with bcrypt password hashing, and
optionally an identity provider (OpenID Connect). No public sign-up: accounts come from an
administrator or from the provider
- **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density
### Architecture
@@ -168,6 +169,26 @@ The web UI listens on port `3000`, the API only inside the compose network. If a
reverse proxy sits in front, bind the port to localhost (`"127.0.0.1:3000:80"`),
otherwise the app is reachable on the host's address without TLS.
#### The HoneyOS bar
`HONEYBAR_ORIGIN` puts a strip along the top of every page that leads to
the other apps of the same system — it is served by HoneyOS, so what it looks
like and which apps it lists are settled there, not here. Empty means no bar at
all, and nothing about the app changes.
The value takes effect on restart: the page asks the server for it
(`GET /api/public/config`) and loads the script itself. It is deliberately not
built into the image — an image is pulled ready-made, and a service worker keeps
whole responses, so anything frozen into `index.html` would outlive the setting.
`HONEYBAR_KEY` is only needed when HoneyOS sits on a *different site*
than InkNote (`honeyos.example.com` vs `notes.example.org`). The browser then
withholds the HoneyOS session, and the bar has no other way to ask for the list
of apps. Under a shared domain it can stay empty.
Offline there is no bar — it comes from another server. The app itself is
unaffected, which is the right way round for one that is built to work offline.
---
## API Reference
@@ -180,9 +201,13 @@ otherwise the app is reachable on the host's address without TLS.
| GET | `/api/auth/me` | — | `{id, username, displayName, isAdmin}` |
| PUT | `/api/auth/username` | `{newUsername, password}` | updated user |
| PUT | `/api/auth/password` | `{currentPassword, newPassword}` | `{success}` |
| GET | `/api/auth/methods` | — *(no token needed)* | `{password, oidc:{enabled,label}}` |
| GET | `/api/auth/oidc/start` | — | redirect to the provider |
| GET | `/api/auth/oidc/callback` | *(the provider's `code` and `state`)* | redirect home with a one-time ticket |
| POST | `/api/auth/oidc/claim` | `{ticket}` | `{token, user}` |
**There is no registration endpoint.** This is a private instance: accounts are created by an
administrator, and nothing on the login screen suggests otherwise.
administrator or by the identity provider, and nothing on the login screen suggests otherwise.
Usernames are 3–32 characters of letters, digits, `.`, `_` or `-`, and match case-insensitively.
Servers created before this carried e-mail logins; on first start they migrate automatically,
@@ -217,6 +242,135 @@ Deleting an account destroys its notes, notebooks, drawings and version history
deleted person's session stops working immediately: tokens are signed rather than stored, so
`authMiddleware` checks on every request that the account still exists.
### Signing in through an identity provider
Optional, and off unless four settings are present. With them, the sign-in screen
offers the provider beside the password form; without them nothing about it
appears anywhere.
#### Setting it up, with PocketID
**1. A client at the provider.** In PocketID that is a new OIDC client; three
things about it matter, whatever the fields happen to be called:
- the **callback URL** is `https://notes.example.com/api/auth/oidc/callback`,
with `notes.example.com` replaced by the address you reach InkNote at. The path
is fixed and is compared character for character — a mismatch is refused by the
provider before InkNote hears about it at all.
- it is **not** a public client: InkNote is a server and keeps the secret there.
- PKCE may be required; InkNote sends a challenge either way.
PocketID shows the client id and secret once. Copy both.
**2. The settings in `.env`** — the first four are what switch it on, the fifth
is only the name on the button. Then `docker compose up -d`, which recreates the
container with them:
```bash
INKNOTE_PUBLIC_URL=https://notes.example.com # where you reach InkNote
INKNOTE_OIDC_ISSUER=https://id.example.com # where PocketID lives, no trailing path
INKNOTE_OIDC_CLIENT_ID=<from PocketID>
INKNOTE_OIDC_CLIENT_SECRET=<from PocketID>
INKNOTE_OIDC_LABEL=PocketID # the button reads "Continue with PocketID"
```
Two more exist and are rarely needed: `INKNOTE_OIDC_SCOPES` (default
`openid profile email`) and `INKNOTE_OIDC_REDIRECT_URI`, for the case where the
callback is not `<public url>/api/auth/oidc/callback`.
**3. Read the boot log.** It says which of the two happened, and there is no
third:
```
✅ OpenID Connect enabled — https://id.example.com (redirect https://notes.example.com/api/auth/oidc/callback)
⚠️ OpenID Connect is half configured and stays off — missing: INKNOTE_OIDC_CLIENT_SECRET
```
Half a configuration is a typo rather than a decision, so it is named rather than
quietly ignored. An issuer on plain `http://` is called out too — the client
secret and the authorization code travel to it.
**What goes wrong, and what it looks like**
| Symptom | Cause |
| --- | --- |
| Nothing on the sign-in screen, and the boot log says nothing about OIDC at all | the variables never reached the container. Compose passes only what its `environment:` block names — a `docker-compose.yml` on the server that predates this feature ignores the whole `.env` section. Check with `docker compose exec inknote-server env \| grep INKNOTE_OIDC`; if that is empty, copy the current `docker-compose.yml` over and `docker compose up -d` |
| Nothing on the sign-in screen | one of the four is unset — see the boot log |
| Still the old sign-in screen after an update | the app is installed as a PWA and its service worker is serving the shell it had. Close every tab (or the installed app) and open it again |
| Back at the sign-in screen saying the provider could not be reached | the **server container** cannot resolve or reach the issuer. Your browser reaching it proves nothing: behind a reverse proxy those are different networks. `docker compose exec inknote-server sh -c 'wget -qO- $INKNOTE_OIDC_ISSUER/.well-known/openid-configuration'` |
| The provider refuses before InkNote is involved | the callback URL there is not exactly `<public url>/api/auth/oidc/callback` |
| "That sign-in took too long" | more than ten minutes between pressing the button and finishing, or the server restarted in between — pending sign-ins are held in memory on purpose |
#### What it actually does
**It is a second way to the same token.** The password login mints a signed JWT,
and every route — the WebSocket included — knows only that; the provider route
ends at the same `jwt.sign`. Nothing downstream had to learn what OIDC is, which
is why the whole thing is one file ([`backend/oidc.js`](backend/oidc.js)) with no
new dependency: discovery is one document, the exchange is one form post, and
checking an ID token is a signature and five claims — Node imports a JWK
directly.
**Authorization Code with PKCE, exchanged on the server.** The client secret
never reaches the browser, and neither does the session token: the callback
sends the browser home with a **one-time ticket**, good for a minute and consumed
on first use, which the app swaps for its token. A token in a redirect URL would
land in the history, in the proxy's log and in the referrer of the next request.
**Accounts.** The provider's `sub` is stored on the account and is the link from
then on, so either side may be renamed without breaking it. On a first sign-in
the subject is bound to an account of the same name if there is one, and
otherwise **a new account is made**. Never an administrator: those rights are
granted in this app, and a group at the provider must not be a way around that.
That first binding trusts the provider's usernames, which is worth being explicit
about: whoever can set `preferred_username` over there decides which account here
they land in the first time. Where the names are set by the provider's own
administrator — PocketID, Authentik, Keycloak as they are normally run — that is
exactly the intent. Do not point this at one where people choose their own name
freely.
**Both doors stay open, and passwords become an administrator's business.** An
account created by the provider has no password here at all: the login route
refuses one exactly as it refuses a wrong password, at the same speed. An
administrator can still *set* one — deliberately, since that is the way back in
on the day the provider is unreachable — and the button in the list says "Give a
password" rather than offering to reset something that does not exist.
An account that signs in through the provider is offered no password tab of its
own, and that includes one which had a password before it was linked. The
password it kept is the emergency door, not a second thing for its owner to
maintain; the account panel says so, and names the provider it signs in through.
The administration list marks every linked account, and says which of them have
no password here.
**The picture comes along.** The server looks for a `picture` claim in the ID
token and, when it is not there, asks the provider's `userinfo` endpoint — a
provider is free to keep the token lean, and several do, PocketID among them.
The answer is used only if its `sub` matches the token whose signature was
checked. The picture is then fetched at each sign-in and stored, encrypted, in a
table of its own; if it lives at the provider, the access token goes with the
request, since avatars are often behind the provider's own login. That token is
never sent anywhere but to the provider itself.
Whatever happens, the server log says so in one line — stored, or exactly why
not: no claim, refused with a 401, not an image, too large, unreachable.
It is served back through the API, so nothing on the page talks to the provider, no third-party cookie is needed for it to appear, and it keeps
working when the provider does not. The bytes are fetched once per account and
turned into an object URL, since an `<img src>` cannot carry a bearer token; the
picture then shows in the sidebar, in the account panel and beside the name in
the administration list. It arrives with the *next* sign-in: that is the one
moment this app talks to the provider at all.
**The refresh token is deliberately unused.** The provider is asked once, at
sign-in; InkNote's own token carries the session from then on, and nothing here
needs the provider to be reachable afterwards. That is what keeps the app working
offline, which is most of the point of it.
On a phone the redirect opens the system browser and returns to the installed
app, so a passkey at the provider works as it does anywhere else.
### Notebooks
| Method | Endpoint | Description |
@@ -253,6 +407,7 @@ Connect to `ws://localhost:4000/ws`, then send:
Receive real-time events: `note_created`, `note_updated`, `note_deleted`, `notebook_created`, etc.
---
## Sync Engine (Client)
@@ -332,6 +487,18 @@ devices get different starts, because they want different things:
The row under the finger is found with `elementFromPoint`, not with enter/leave
handlers — a finger generates no `pointerover` on anything it did not press.
**Two lines in the same gap keep the order they were written in.** A separator's
position counts the rows above it, so two lines with nothing between them carry
the same number, and only the order they are stored in can tell them apart. That
took two fixes for one symptom. The comparator used to say "a separator comes
first" without asking what the other one was — claiming both directions at once
for that case, and a comparator that contradicts itself has no defined result.
And the write was then thrown away: the "did anything change?" test **sorted**
the separators before comparing them, to ignore the other chapters' lines riding
along in the same field — and a sorted comparison cannot see a sequence. The move
was computed correctly, drawn for a frame, and discarded. It compares this list's
own lines, in order.
**A drop lands the row under the one it was released on, and only ever there.**
Dropping above a row and dropping below the row before it are the same move
described twice, so every gap could be aimed at from two sides — which made a
@@ -446,18 +613,20 @@ 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 |
| `editor` | 36 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
| `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` | 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` | 18 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
| `pdf` | 91 | import, page proportions, lazy loading, and reading with the network off |
| `drawing` | 22 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
| `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` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 64 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 51 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging |
| `export` | 23 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 75 | a hand-made order survives a search; a new note lands on top and a deleted one takes only itself; one line goes under another and stays there |
| `chapters` | 65 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog |
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
| `tables` | 8 | a long table is broken between its rows, with none left lying across the gap, and no page furniture reaches the stored note |
| `tables` | 11 | a long table is broken between its rows, with none left lying across the gap, no page furniture reaches the stored note, and the column edge being dragged is drawn on the cell rather than in it |
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
Each suite creates a throwaway account through the admin API and deletes it
@@ -506,10 +675,51 @@ 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.
**Twenty-one lines to the page, and the same twenty-one on every screen.** The
text is laid out on a page of one fixed size — 760 × 1013 units, the drawing's
own proportions — and the whole sheet is then scaled to whatever width the screen
has. Set in screen pixels instead, as it was, a phone and a desktop disagreed
about everything that matters: a 340-pixel sheet held eighteen lines where a
760-pixel one held far more, so the same note broke into different lines on
different pages depending on what you read it on. Now the only thing a narrow
screen changes is how big the page looks. The type size is not chosen but
computed from the page and the line count, so the count survives a change to
either. A desktop therefore shows larger writing on a smaller-looking sheet, the
way a ruled notebook app does.
**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. The
time it sorts by is stamped on every write, not only on a rename: the store
behind the sync engine always did that, but the lists on screen read the app's
own copy of the notes, and writing *in* a note used to leave that copy untouched
— so "Recent" showed the order from the last reload.
**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, for the same reason. On the account rather than in the browser, because a choice about how your
own notes are set should follow you to the phone rather than be made again on
every device. The server accepts those three values and drops anything else —
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
for by accident, forgotten by the next device and lost on every reload. The
browser keeps its own copy of the theme so a reload does not flash the other one
while the account is being fetched; the account is what decides. On the account
rather than in the browser, because a choice about how your own notes are set
should follow you to the phone rather than be made again on every device. The
server accepts only the values it knows for each of them and drops anything else:
that column is written by a browser, so it is an input like any other.
**You write on paper.** Each page is a sheet of its own, centred, with a gap
@@ -521,6 +731,30 @@ sheet's margin, rather than an unexplained void.
The sheet used to appear only once you started drawing, which made the writing
mode look like text floating in an empty pane.
**The sheet is measured before the first paint**, not after. It starts at the
canonical page the drawing lives in — 1200 × 1600 — and is fitted to the pane by
a measurement; as an ordinary effect that ran a frame late, so every note opened
by drawing one frame of a sheet sixteen hundred pixels tall and then righting
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
children, so inside a quote everything stood jammed against its neighbour — with
the items of a list further apart than the blocks around them, which read as the
quote being broken. The rule for a quote's children is written *after* the ones
for paragraphs and headings on purpose: they are exactly as specific, so earlier
it would lose. A paragraph inside a list item is more specific still and keeps
its zero.
**Pages are counted, not stored.** The toolbar says *Page 2 of 4* in the same
words the PDF reader uses. A page is the same 3:4 the canvas uses, so "page 2"
means the same thing writing or drawing. The note itself stays one continuous
@@ -559,6 +793,19 @@ Anything else too tall to fit anywhere — a full-page image — is left where i
and runs over the gap: moving it down only puts the same problem one sheet
further on.
**The column edge you drag is drawn on the cell, not in it.** The editor marks
the column under the pointer by putting a small div in each of its cells, and the
obvious thing to do is position that div along the cell's right edge. It works,
and it paints grey letters down the bar: an absolutely positioned child inside a
cell of a `border-collapse: collapse` table sends Chromium down another painting
path, and the cell's own text comes out a second time, squeezed into the two
pixels under the bar, one letter to a line. It appeared with the bar and went
with it — which named the culprit, since the DOM at that spot held nothing but
three empty markers. The marker is `display: none` now and only says which cells
the column is made of; the line is an inset shadow on those cells, matched with
`:has()`. Nothing is positioned, so nothing paints twice, and the line looks the
same — continuous down the column, because each cell carries its own piece.
Two details that are easy to get wrong and were:
- **The spacer's height is not the distance the text moves.** Margins that used
@@ -620,6 +867,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`
@@ -676,6 +942,30 @@ 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 means the whole window, not the whole screen.** The workspace is
laid out as an overlay over the app: the document takes the browser window, and
the sidebar, the note list and the title bar go. The browser's own full screen is
deliberately not asked for — it swallows the tabs and the address bar too, which
is more than was wanted; this is about giving the page to the document, not the
screen. Escape leaves, and so does the button, which sits beside the zoom while
full: both answer "how much room does the document get", and a way out that is
only known and not visible is not a way out.
**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
stay put depends on how the zoom was asked for, and the two answers are
different: from the buttons it is **the page you are reading**, because the
request is "show me this page larger"; from **Ctrl + the wheel** it is the point
under the pointer, because you are pointing at a word and asking for that to
grow. The wheel listener is registered by hand rather than through `onWheel`:
React attaches wheel listeners passively, a passive listener may not call
`preventDefault`, and without that call the *browser* zooms the whole application
— toolbar, sidebar and all. A trackpad pinch arrives as the same event and needs
nothing further. The suite checks all three: the step, that the default really
was prevented, and that a point picked on the page is still under the pointer
afterwards.
**pdf.js is not in the main bundle.** It is larger than the rest of the
application put together, so it sits behind a dynamic import: a session that
never opens a PDF never downloads it. The worker is bundled too, not fetched
@@ -1105,6 +1395,34 @@ still reads newest-first — untouched rows all carry position 0 and fall back t
it — so nothing is lost by starting there, while starting in "last changed" made
both separators and dragging look broken until the sort menu was found.
**A chapter orders its own notes.** The notebook's setting governs the
notebook's own list; a chapter is a place someone made and filled, so it carries
a mode of its own — set in the same dialog as its name, mark and colour, because
they are one decision about one thing. It starts in the hand-made order, which is
what filing a note into a chapter means: it stays where it was put. A chapter in
one of the other orders loses its lines and its handles, exactly as the notebook
does, because neither means anything against an order the app is choosing.
While it *is* in the hand-made order, its rows are dragged with the **same
switch** as the list beside it — two switches for two lists looked at together is
one too many. The two lists keep out of each other's way by scope: a chapter
unfolded under its row holds rows *inside* a row of the notebook's list, so "the
nearest row above the pointer" cannot say which list a drag belongs to. The two
strips at the ends of a chapter's list appear only while something is being
carried; standing open for the whole of the mode they added half a finger of
empty space to every open chapter.
**A row leaving is the same problem as a row arriving, in reverse.** Deleting a
note above a line left the line counting rows that were no longer there, so
everything under it appeared to jump up past it — which is what made deleting
feel as though it scrambled the notebook. `withoutRow` renumbers the list the row
is leaving, and every place a row leaves one says so: the bin, a move to another
notebook or chapter, and a chapter being removed. Restoring from the bin and
moving into a list are arrivals, and arrive at the top like anything else — an
old position belongs to an arrangement that has moved on. Removing a chapter is
both at once: its row leaves the count, and the notes it held arrive at the top
in the order they had inside it.
**A new note is put at the very top, and the list is renumbered to say so.**
Left to the tie-break it landed near the top by age, which is not the same thing:
a separator's position counts the rows above it, so a row appearing without a
@@ -1153,6 +1471,25 @@ editor **and in every link pointing at the note**, so it is recognised by one si
everywhere. A note's mark takes a
colour too; a chapter's uses the colour the chapter already has.
Marks get in two ways. **Anything can be typed into the search box** — it is a
text field, which on a phone is exactly where the system emoji keyboard lives, so
a mark the list never thought of is offered as a tile and used like any other.
Eight code points at most, the same limit the server keeps, so one glyph with its
modifiers fits and a pasted sentence does not; letters and digits mean a word was
typed and a search is meant.
The list itself lives in one place,
[`ui/IconChooser.jsx`](frontend/src/ui/IconChooser.jsx),
and **adding one is a single line** — a `{ char, words }` in whichever group it
belongs to. Nothing else has to be touched: what is stored on a note is the
character itself and not a number into the list, so entries may be added, moved
or dropped without disturbing anything already chosen. The words are the whole
point of the entry, so give it the ones somebody would actually type — what the
thing is *for*, not what it depicts. Both dialogs that offer a mark render that
same component; there were two copies of the list once, and only one of them
ever grew. The groups are headings, not filters — a search looks through all of
them at once, and a group with no match is not drawn.
**Separators** are lines drawn between notes, optionally named, stored on the
notebook beside the sort mode. Renaming and removing are both right-click, which
is how everything else in that list is acted on: a left-click on the label used
@@ -1290,7 +1627,14 @@ Writing the exporter also turned up a real bug in it: blocks were emitted
without a blank line between them, so a list written straight after another list
became more items of the first, and a quote or code fence on the next line was
read as part of the item above. That is not one reader's quirk — it is what the
format says — so the file rendered wrongly everywhere, not only here.
format says — so the file rendered wrongly everywhere, not only here. The same
rule was missing again one level down, inside a quote, until quotes were made to
carry more than a sentence.
Coming back the other way, a quote is read as a unit across the `>` lines that
divide it: a quoted blank line separates the blocks inside the quote, it does not
end it. Everything the exporter can put in a quote — headings, lists, tables,
code, further quotes — is read back out of one.
---
@@ -1314,7 +1658,12 @@ A note without a drawing skips the first question instead of showing it with two
thirds greyed out.
**Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code,
tables and task state all have equivalents and are carried over; a link to
tables and task state all have equivalents and are carried over. A quote may
hold whole blocks — a heading, a list, a closing line — and inside it they are
divided by a **quoted** blank line (`>` on its own), the same way blocks are
divided everywhere else: written on consecutive lines, the sentence after a
quoted list is read as more of the last bullet by every other reader, and a
truly empty line would end the quote instead of dividing it. A link to
another exported note becomes a relative link to its file, and a link to a note
outside the export degrades to its plain title rather than a dead reference.
A note with images or a drawing arrives as a ZIP so the files travel with it,