Compare commits

..
40 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
marcandClaude Opus 5 312c4e190e Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:46:28 +02:00
marcandClaude Opus 5 a9391cdcf2 One direction to drop in, and a new note that stays on top
Every new note pulled the arrangement apart. A note was made with no
position and left to the tie-break, which put it 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 renumber left every line one row too
high. placeOnTop builds the order from the list *without* the new note and
puts it in front: measuring the room after the furniture has moved would
write the misreading back as the truth.

A drop now lands the row under the one it was released on, and only 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. The strip
above the first row is the one place a drop under something cannot reach.

A chapter could only be dropped into, never dragged past: taking the whole
row as a place quietly took reordering away. The bottom edge of its header
is the gap now, the rest of the row is still the chapter.

A link showed the sheet of paper whatever it pointed at. The @ menu dropped
the mark on the way into the link, and the refresh that keeps a link current
ran once, while the editor was still empty -- content is poured in afterwards
without an update event, so nothing ever looked again.

The mark in the title bar and the box in a task list were each centred on a
line box rather than on the letters in it, which is not the same as being on
one line. The mark shares the title's baseline; the box is centred on the
capitals and sized in em.

Move to... is divided under headings that are deliberately not shaped like
entries, and each row carries the mark its chapter or notebook wears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:45:16 +02:00
marcandClaude Opus 5 2ffa357dfc Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:23:56 +02:00
marcandClaude Opus 5 d68b0775c2 One dialog per thing, and rows dragged into a chapter
A separator had three menu entries — rename, colour, line — plus an input
that appeared in the row itself: three shapes of interaction for one small
thing, and none of them showed the result. Choosing "dotted" from a list
of words is a guess. It is one Edit now, and the dialog draws the line as
it will look with every shape drawn beside its name, from the very rules
the list draws with: a sample that can disagree with the result is worse
than none.

A chapter goes the same way — name, mark and colour in one dialog, the
same one whether it is being made or changed, with the row drawn at the
top. Asked from three entries, getting a new chapter to look right took
three visits. The name dialog it used is gone.

A row is put into a chapter by dragging it onto it: the chapter lights
up and takes what is dropped rather than growing a line above it, since
a chapter is a place and not a neighbour. Notes and separators both, and
the "+ Separator" inside a chapter goes with it — a second way to make
the same thing in a second place is a second thing to keep straight.

A link now wears the mark of the note it points at, in the text and in
the @ menu. It travels when the note's mark changes, as the label
already did.

Two alignment faults: a task list's checkbox was nudged down by a fixed
0.35em measured once against one font, so it floated above the line it
belonged to — it is centred in a box one line tall now. And the title
bar's mark was spaced like the buttons at the far end and grew with its
contents, so picking an emoji shifted the title sideways.

Fourteen suites green: 374 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:23:26 +02:00
marcandClaude Opus 5 59844046c4 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:23:48 +02:00
marcandClaude Opus 5 f1d3e38f8f Marks, lines, and a table that breaks
A long table lay straight across the gap between two sheets with a row
cut in half, and a shorter one jumped to the next sheet whole leaving
the rest of the first blank. Tables are broken between their rows now,
which is where a table breaks on paper. The spacer there is a tr with
one borderless cell: a div inside a tbody is not something the browser
keeps. Its own spacer rows are subtracted again on the next measurement
and counted into the shift every block below inherits — miss that and
everything under a broken table is measured a page too high.

A dialog closed in the middle of an edit if a selection was dragged past
its edge: the click lands on the nearest common ancestor of press and
release, which is the backdrop, and every dialog read that as "clicked
beside me". They remember where the press began now. All ten.

A note and a chapter can wear a mark of their own beside the name,
chosen from a short list searched by what a thing is for rather than
from the system emoji keyboard — a different thing on every machine, and
three thousand pictures for a decision that is "which of these is the
invoice notebook". A separator takes its own colour and one of five
shapes, and a chapter can be divided like the notebook it sits in: both
places a chapter is shown draw it with one component now, so it cannot
be divisible in the column and not in the unfolded block.

Also: a chapter's colour could not be cleared, because the update rode
on COALESCE and null there means "leave it alone". A table's header row
is legible on the dark theme. Three entries in the table's own menu were
still German. Opening a notebook opens its first note, on a wide screen
only — on a phone the list is the screen.

Fourteen suites green: 364 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:23:16 +02:00
marcandClaude Opus 5 0c312e53c2 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:45:36 +02:00
marcandClaude Opus 5 c96885d5b3 A notebook opens in its own order
The hand-made order is where a notebook's own arrangement lives:
separators show, rows can be put where they belong, nothing slides about
by itself. Starting in "last changed" made both of those look broken
until the sort menu was found — pressing "+ Separator" there drew a line
the order cannot display, so the button appeared dead. A notebook nobody
has arranged still reads newest-first, because untouched rows all carry
position 0, so nothing is lost by starting there. The views keep "last
changed": they are questions asked of every notebook at once and have
nowhere to keep an arrangement. The button that draws a line is now only
offered in the order that can show one.

Removing a chapter left you stuck in the confirmation. Every destructive
action goes through one helper that takes the question down again; this
one set the dialog directly and bypassed it, so the chapter was removed
and the prompt stayed over the app. Closing now happens where the dialog
is rendered, so it happens whoever asked and however they asked.

The suites did not catch that, and the reason is worth keeping: they
read the list straight through the overlay, which is in the DOM the
whole time, and passed while the screen was in fact stuck. Both now
assert the question is gone afterwards.

The separator is no wider than its name. It sat in a fixed 132px slot —
chosen so the row would not jump when renaming began — with a short name
floating in a gap it did not fill.

Thirteen suites green: 345 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:45:03 +02:00
marcandClaude Opus 5 cc0b58dd2f Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:29:35 +02:00
marcandClaude Opus 5 165ccf3cf7 A chapter you can see, in the colour you chose
A chapter did not show until the notebook had a note in it. "Nothing
here" was asked of the notes alone, so a notebook whose only contents
were chapters showed the empty-list panel with the chapters hidden
behind it — making a chapter in a fresh notebook appeared to do nothing
at all. It is asked of every row now, and a notebook with chapters but
no loose notes says so in one line rather than looking like a list that
failed to load.

The colour is chosen while the chapter is being made, in the dialog that
asks for its name. It is what tells one chapter from the next, and
having to right-click a fresh one to say which it is meant two visits
for one decision. Renaming and recolouring afterwards are one entry in
its menu and one write; the server already took the colour on create,
the client simply never sent it.

The word "here" is gone. The line along the top of the pane says which
of the two places you are in, and saying it twice was noise.

Thirteen suites green: 338 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:28:57 +02:00
marcandClaude Opus 5 ebf1f1bc62 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:14:04 +02:00
marcandClaude Opus 5 b2a5f051fe A filed note stays filed, and the handles are asked for
Three things that all came from chapters being thought through too late.

A note put into a chapter came back out of it a few seconds later. The
server had it right the whole time; every sync rebuilds the local copy
from the server's answer, and the function doing that never carried
chapter_id — so each sync handed back a note belonging to no chapter and
the notebook took it in again. The bin's query left the column out too,
which would have unfiled anything restored from it.

A separator drawn in a notebook vanished when a chapter was opened.
"Which chapter is open" and "which list is on screen" were one value.
Opened beside or below, the list still shows the notebook — but
everything around it had switched to the chapter, so the notebook's own
separators were filtered away and a drag among its rows was measured
against the chapter's notes, found nothing, and did nothing.

And where a new note lands is now visible and yours to choose: pressing
in either place makes it the one being worked in, marked with a line
along its top and the word "here", and the + button names it.

Rearranging becomes a mode, switched on beside the sort menu — until
then no row wears a grip and none can be dragged. Filing a note is back
in the row's own menu, where starring and exporting already are; the
button that had appeared in every row is gone with its dialog. The
account panel closes with Escape.

Thirteen suites green: 335 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:59:29 +02:00
marcandClaude Opus 5 4bb86bc5ac Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:20:35 +02:00
marcandClaude Opus 5 d74fea4905 Chapters as rows, in two views, in their own colours
Three things Marc asked for, and one of them turned out to be the hinge
the other two hang from.

**A chapter is a row like any other.** Same grip, same drag, same place
in a hand-made arrangement as a note or a separator. That needed notes
and chapters numbered from *one* counter: numbered separately they are
two sequences that cannot be interleaved again, and a chapter dragged
between two notes springs back to the top. Positions therefore have to be
named rather than read off an array index, so POST /notes/reorder now
takes `positions: [{id, order}]` beside the older `ids`, and chapters
have a reorder route of their own.

**Two ways for a chapter to open**, chosen per account beside the
typeface, for the same reason that one lives there: it is a choice about
how your own notes are laid out and it should follow you to the phone.
Beside the list, its notes in a further column the way OneNote lays out
pages — the notebook's own list carrying on to the left, chapter rows and
all. Or unfolded under the chapter's own row, indented against a rule in
its colour. A phone has room for neither and fills the list instead, with
a way back, which is what it did before there was a choice.

**Its own colour**, from eight named ones rather than the full palette:
the point of chapters is that they are different places, and twenty
near-identical blues do not help anyone tell them apart.

The colour is validated on the way in — it is written by a browser, so
only a plain #rrggbb gets through and anything else becomes no colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:20:14 +02:00
marcandClaude Opus 5 6cad632a0c Record the digest of the image that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:44:13 +02:00
marcandClaude Opus 5 632ea5dcd9 Move a note in one press, and stop Draw inventing a page
Moving lived under a right-click, behind a submenu: two steps before the
list of places appeared at all, and on a phone a long press followed by a
flyout. There is a button on the row now, and it opens one screen with
the chapters of this notebook and the other notebooks together, marking
where the note already is.

The spare sheet in Draw mode was drawn as solid paper, so pressing Draw
on a one-page note made a second page appear from nowhere while the count
still said "of 1". It exists for a good reason — pages otherwise come
only from how far the writing runs, so drawing on page two would mean
writing two pages first — but it should look like the offer it is. Dashed
outline, half opacity, and it says what it is for. It counts once
something is on it.

And the two buttons above the list did not match: the one I added last
round was larger and bolder than the one it sat beside, and the older
one's target was nineteen pixels tall, which a finger misses. Both are
one shape now. A separator's label matches a chapter's name in size for
the same reason — two type treatments in one list is noise, and the
difference was not saying anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:43:51 +02:00
marcandClaude Opus 5 9d5ac1db27 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:04:39 +02:00
71 changed files with 8992 additions and 1203 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
+448 -262
View File
@@ -1,322 +1,508 @@
# Changelog
What changed, newest first. One entry per round that went out as an image, with
the digests, so it stays possible to tell what is actually running on the server.
What changed, newest first — and the digests of every image that went out, so it
stays possible to tell what is actually running on the server. Digests are read
back from the registry (`docker buildx imagetools inspect`), never taken from the
output of `docker push`.
Digests are recorded from the registry (`docker buildx imagetools inspect`),
not from the output of `docker push`.
Kept short on purpose. A day's work usually went out as several images, and a
round that repaired what an earlier round the same day had broken says nothing
worth keeping on its own — so each day is one entry, describing where it ended
up, with every digest of that day listed. Where the server image is named as
unchanged in substance, the digest still moved: `build.sh` builds both images and
a rebuild is not bit-identical.
---
## Unreleased
## 2026-08-26 — The same page on every screen
**Images** — web `sha256:e475c080…`, server `sha256:49a98c66…`
The server image carries no change of its own; `build.sh` builds both.
**Changed**
- **Twenty-one lines to the page, and the same twenty-one everywhere.** The text
is now laid out on a page of one fixed size and the whole sheet is scaled to
the screen, instead of being set in screen pixels. Set that way, a phone and a
desktop disagreed about everything that matters — the same note broke into
different lines on different pages depending on what you read it on, and a
phone page held eighteen lines where a desktop held far more. The type size is
computed from the page and the line count rather than chosen, 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.
- **Full screen for a PDF means the browser window, not the screen.** Asking the
browser for its own full screen took the tabs and the address bar with it,
which was more than was wanted. The overlay over the app — the part that
removes the sidebar, the list and the title bar — is the whole feature now.
The button moved next to the zoom, where the other question about how much
room the document gets already lives.
**Fixed**
- **"Recent Notes" is now sorted by when you last wrote in a note**, not by when
you last renamed one. The store always stamped every write; the lists on screen
read the app's own copy of the notes, and writing *in* a note left that copy
untouched — so the dashboard and the "Last changed" sort showed the order from
the last reload.
- **A PDF that will not open says why.** One message covered every failure and
guessed at the cause ("offline?"), which was usually wrong and left a report of
"it will not open on my phone" with nothing in it. The status now travels with
the failure: a missing file, a server that refused, a server that could not be
reached and a file that will not parse are four different messages.
## 2026-08-25 — Colour, a page you can see the edge of, and a document on its own
**Images** — web `sha256:3e1b47e1…`, server `sha256:89e474cc…`
(earlier that day: web `783ccf92…`, `d7c66235…`; server `2141f145…`, `308ed1b7…`)
The server changed earlier in the day for the bar's runtime configuration; this
last pair carries everything below and no backend change of its own. The two
entries under it are the same day's earlier rounds, kept apart because they are
about different things.
**Added**
- **Chapters — folders inside a notebook.** A note is in one or in none; tapping
a chapter opens its own list, with a way back out. Removing a chapter never
removes what it held: the notes come back to the notebook, and the dialog says
so with the number affected. Separators now belong to the list they were drawn
in, so a line drawn in one chapter does not appear in another.
- **A marker pen and a colour for the letters**, both under one button in the
toolbar — two buttons would be two panels for one question. A highlight is a
`<mark>`, the element that *means* highlighted, and marked text is painted with
dark ink whatever the theme, the way a real marker leaves the writing alone.
Markdown has no colour, so both travel as HTML like underline does, and both
are read back — including a bare `<mark>`, which is what most other writers
emit.
- **The PDF fills the screen.** The button asks the browser for its own full
screen *and* lays the document out over the app; either alone is half the job,
and a browser that refuses the first still gets the second. Escape leaves, and
the button stays visible while full — a way out that is only known is not a way
out.
- **Dragging in from the left edge shows the sidebar** on a phone or tablet, and
dragging it back hides it. It must start at the edge, travel far enough to be
meant, and be more sideways than downwards; without the first rule every
sideways drag inside a note opens it, without the third every scroll does.
**Changed**
- **A notebook's design is its own.** A 45-degree stripe texture was baked into
every cover at 1.5% white — meant to be imperceptible, visibly banding on the
darker gradients, and a second pattern fighting the one actually chosen. And
the same pair of rings sat in the middle of every notebook. The mark belongs
to the design now, and the designs whose pattern already fills the face wear
none.
**Fixed**
- **A submenu was being clipped away on a wide screen.** The menu had been given
`overflow-y: auto`, which cuts off anything flying out sideways — so the list
of notebooks under *Move to…* was invisible and every click on it landed on
the note list behind. The side was also inverted: `right: 100%` places a panel
on the left.
- **A separator was effectively invisible on a phone** — a one-pixel hairline in
the border colour with faint grey text on near-black.
- **The dashboard leads with what you were last writing**, the shelf of notebooks
second — and "Recent" is sorted by time whatever order the notebooks are in.
It is a question about time, and a hand-made arrangement is the wrong answer.
- **Tapping the blank part of a page starts writing there.** The text is only as
tall as what has been written, so most of a fresh page is beside the editable
area rather than in it, and a tap down there landed on nothing at all: no
caret, and on a phone no keyboard. The caret goes to the position nearest the
tap rather than to the end.
- **The page has an edge on a phone.** The paper runs to the edges of the screen
there, but it was drawn with no border and no shadow, so nothing said where one
page ended and the next began.
---
## 2026-08-12 — Two-part covers, and an app that can be installed
## 2026-08-25 — The bar that ties the apps together, and Ctrl over a PDF
**Images** — web `sha256:1217b81b…`, server `sha256:3303d3dc…`
**Images** — web `sha256:783ccf92…`, server `sha256:2141f145…`
(earlier that day: web `d7c66235…`, server `308ed1b7…`)
The server image changed: it takes a `cover_pattern` column, added on start.
The server image changed for the bar's runtime configuration; the second pair of
digests carries the PDF work as well, and no backend change of its own.
**Changed**
- **A cover is now a colour and a pattern, chosen in turn**, rather than a list
with the combinations someone happened to write out. Every combination is
available, and each pattern is previewed over the colour in hand. Covers saved
during the day the patterns were one list are read back as the pair they were
drawn as, so nothing repaints itself.
**Fixed**
- **The install button never appeared, even over HTTPS.** Two causes, both in
this app rather than in anyone's configuration:
- `beforeinstallprompt` fires once, early — usually before React has mounted —
and is never fired again, so a listener added from inside a component simply
missed it. It is caught at the top of `index.html` now.
- The manifest is fetched **without credentials** unless the link says
otherwise. Behind a reverse proxy that authenticates in front of the app,
that request comes back as the proxy's login page and the browser reads it
as an invalid manifest — while the app itself works perfectly, because its
own requests carry the cookie. The link now says
`crossorigin="use-credentials"`.
- **The service worker could cache a proxy's login page as the application.**
Nothing checked that a response was the thing that had been asked for, so a
lapsed session could be stored as the app shell: it then "worked offline" by
showing a login screen it could never get past, curable only by clearing the
site's data by hand. Redirected and unsuccessful responses are no longer
cached, and the cache name is bumped so anything already stored under the old
rules is dropped.
---
## 2026-08-12 — Making the phone work
**Images** — web `sha256:5ea846f3…`, server unchanged at `sha256:06f94f43…`
**Fixed**
- **A note made offline lost everything written into it.** Its create reached
the server, the server named it something else, and every edit still queued
behind it went on addressing the name only that device knew — a 404, five
retries, then thrown away. The note arrived empty and untitled with no sign
anything had been lost. The queue is now repointed along with the database.
The same held for a notebook made offline.
- **The order of notes could not be changed on a phone.** The list used the
HTML5 drag API, which mobile browsers never fire from touch. It uses pointer
events now, and every row has a grip: pressing the row itself still scrolls,
and holding it still opens the menu.
- **"Move to…" opened off the side of the screen** and could not be opened at
all with a finger. It was pinned to the right of the menu and reacted only to
hover. It now opens on a tap, unfolds in place on a narrow screen, and flips
to the other side on a wide one when there is no room.
- The menu itself is placed by measuring rather than by assuming it is 220px
wide, so it can no longer hang off an edge; rows are tall enough to hit with a
thumb, and Escape closes it.
**Changed**
- **The install entry is always there.** It used to appear only when the browser
offered a prompt, so where installing was impossible there was nothing on
screen at all — no button, no reason. It now says which of the fixable causes
applies: served over plain HTTP, or a browser that wants to be asked through
its own menu.
---
## 2026-08-12 — Covers, the search box, and room to write in the margin
**Images** — web `sha256:c1222665…`, server `sha256:06f94f43…`
The server image changed: it sends the default number of versions with the
account, so the notebook dialog can name it instead of guessing.
### The bar
**Added**
- **Patterned notebook covers** — ruled paper, graph paper, pinstripe, bands,
dots, cross-hatch, linen, washes, a ledger. Drawn entirely in CSS: no image
files, nothing to upload, crisp at any size, and they work offline like
everything else. Existing notebooks keep exactly the cover they had; the new
ones are appended, never inserted.
- **A strip along the top that leads to the other apps.** It is served by
HoneyOS, so what it looks like and which apps it lists are settled there —
InkNote only says where to find it. `HONEYBAR_ORIGIN` in the `.env`
switches it on, empty leaves everything exactly as before.
- **`GET /api/public/config`** hands out that origin, and the key that goes with
it when HoneyOS lives on a different site. Runtime, not build time: an image
is pulled ready-made, so a value baked in at build time could never be changed
from the `.env` — and a service worker keeps whole responses, headers
included, so anything frozen into `index.html` outlives the setting in the
browser. Aether learned that one the hard way first.
**Changed**
- **The search box in a notebook is simply there**, and the button that used to
open it is gone. A magnifying glass cost a click and having to remember that
searching was possible; the box is one line and says for itself what it does.
- **Margin notes are bigger** — the column went from 208px to 288px and a card
from three short lines to about five. Three lines is a caption, not a remark
on a passage, and the space beside the page was sitting mostly empty.
- The notebook dialog's cover grid scrolls, so the dialog cannot run past the
bottom of a laptop screen and take the *Create* button with it.
- **The app now measures its height against the bar**, in `#root` and in the
four places that asked for the full window. With `overflow: hidden` on the
body the surplus would not have scrolled — it would have been cut off at the
bottom, silently. And `#root` padded itself around the phone's notch, which
the bar now covers, so that gap would have been counted twice. Without a bar
`--honeybar-h` is `0px` and every one of these is what it was.
### Reading a PDF
**Added**
- **Ctrl (or ⌘) and the wheel zoom the document**, as in every other viewer, and
a trackpad pinch with it — it arrives as the same event. The listener is
registered by hand rather than through `onWheel`: React attaches wheel
listeners passively, a passive listener may not call `preventDefault`, and
without that the *browser* zooms the whole application instead, toolbar and
all. **The point under the pointer stays under the pointer**: you are pointing
at a word and asking for that to grow, not to be sent to the top of the page.
**Fixed**
- The notebook edit dialog was still part German — *"Versionen pro Notiz"*,
*"Vorgabe"* — and would not say what the server's default actually is. It now
names it (50), read from the server rather than written into the interface,
since `MAX_VERSIONS_PER_NOTE` can be changed and a hardcoded number would
quietly start lying.
- **Zooming with the buttons drifted.** A zoom changes how tall the document is,
so the scroll position means something else afterwards, and nothing was holding
anything still — three presses of + and you were elsewhere in the file. They
hold the page being read now, which is what "show me this page larger" asks
for.
---
## 2026-08-11 — Margins and a stray placeholder
## 2026-08-22 — A bigger face, and the theme where the settings are
**Images** — web `sha256:3a181ff3…`, server unchanged at `sha256:0832ee2b…`
**Images** — web `sha256:fe35b0e7…`, server `sha256:a557fb4d…`
The server image changed: `theme` is now one of the settings an account keeps.
**Changed**
- **Narrower margins on the sheet** — the column went from about 73 characters
to about 80, leaving roughly 8% of the paper as margin either side instead of
117px of empty page down each edge.
- **The profile picture fills the account button**, 34px instead of 20 — a face
at twenty pixels is a smudge — and the same size in the account panel.
- **Light or dark moved into Account → Profile**, beside the typeface and how a
chapter opens, which is the kind of thing it is. It used to be a sun in the
corner of the sidebar: reached for by accident, and gone again on the next
device. That corner now holds one button, the way into the account.
- **And it stays.** The theme was pure screen state before, so every reload
landed on dark whatever had been chosen. It belongs to the account now and
follows you to the phone, with a copy in the browser so a reload does not flash
the other one while the account is being fetched. The server takes `dark` or
`light` and drops anything else, like every other setting written by a browser.
---
## 2026-08-21 — Asking for the picture properly
**Images** — web `sha256:4310304d…`, server `sha256:7a486929…`
The server image changed; the web image only because `build.sh` builds both.
**Fixed**
- **"Start writing..." was printed at the foot of every page with a break after
it.** The placeholder extension styles `[contenteditable]:empty::before`, and
the spacer holding a gap open is exactly that — empty, and not editable.
- **The profile picture never arrived**, and two assumptions of mine were why.
The picture was only looked for in the ID token — a provider may keep its token
lean and hold the rest at its `userinfo` endpoint, which is what PocketID does,
and that endpoint was never asked. And the image was then fetched with no
credentials at all, while providers commonly keep avatars behind their own
login. The endpoint is now asked when something is missing (and its answer
trusted only if the `sub` matches the verified token), and the access token
goes with the picture request — but only when the picture lives at the
provider, because a token sent to a third party is a credential given away.
- **And it failed in silence**, which is the part that actually cost the time:
there was no way to see which of those two it was. Every outcome is now one
line in the server log — stored, no claim, refused, not an image, too large,
unreachable.
The test provider now behaves like a lean one: it leaves the picture out of the
token and hands it over only against the access token, so the check passing is
evidence for both new paths.
---
## 2026-08-21 — The face from the provider, and whose business a password is
**Images** — web `sha256:e525b11d…`, server `sha256:95d56c46…`
The server image changed: a table for profile pictures and the route that serves
them.
- **The profile picture comes along.** When the provider sends one — PocketID
does — the server fetches it at each sign-in and keeps it, encrypted, in a
table of its own rather than in `users`, where a blob would ride along with
every `SELECT *` for the sake of one corner of the screen. It is served back
through the API: nothing on the page talks to the provider, no third-party
cookie has to survive for a face to appear, and the picture keeps working when
the provider does not. It shows in the sidebar, in the account panel and beside
the name in the administration list, and arrives with the **next** sign-in —
the one moment this app speaks to the provider at all.
- **An account that signs in through the provider has no password tab**, and that
now includes one that had a password before it was linked — the usual case for
whoever set this up, and the reason the tab was still there. The password it
kept is the emergency door rather than a second thing for its owner to
maintain: it is set by an administrator, and the account panel says so.
---
## 2026-08-18 — Which accounts belong to the provider
**Images** — web `sha256:5fb51ed4…`, server `sha256:ce1f4649…`
- **The administration list marks every account the provider can sign in**, not
only the ones it created. Those are two different things and the badge had been
hung on the wrong one: an account that already existed and was *linked* — which
is what happens to the account you were using when you first sign in this way —
showed nothing at all. The row says which it is: an account with no password
here says so, and the button offers to **give** it one rather than to reset
something that does not exist, with a line explaining that this adds a second
way in and takes nothing away.
- **The account panel says how you get in.** The password tab is gone for an
account that has none — it was already, and the server refuses that route too —
but nothing said why. It now names the provider, and tells an account that has
both that the password still works.
---
## 2026-08-18 — A second way in
**Images** — web `sha256:232982cb…`, server `sha256:5ed577b1…`
The server image changed: OpenID Connect, and an `oidc_sub` column on users.
- **Signing in through an identity provider**, when one is configured — issuer,
client id and secret, and the address this app is reached at; without them
nothing about it appears anywhere. It is a second way to the *same* token the
password login mints, 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.
- **The browser is handed a one-time ticket, never the token.** The code is
exchanged on the server, so the client secret stays there, and a token in a
redirect URL would otherwise sit in the history, in the proxy's log and in the
referrer of the next request. The ticket is good for a minute and for one use.
- **An account is made for somebody the app has not seen**, and an account that
already exists under that name is linked to the identity rather than
duplicated — by subject from then on, so either side can be renamed. Never an
administrator: those rights are granted in the app, and a group at the provider
must not be a way around it.
- **Both doors stay open.** An account made this way has no password here at all
— the interface stops offering to change one and the login route refuses it as
it refuses a wrong one, at the same speed — but an administrator can still set
one, which is the way back in the day the provider is unreachable.
- **A suite that signs in at a provider of its own** (`e2e/oidc.mjs`), which
signs real RS256 tokens with a key InkNote fetches and checks. It holds down
the four ways the journey must fail as well as the way it must work: a refusal,
a replayed ticket, a token signed with an unpublished key, and one addressed to
another client.
---
## 2026-08-17 — Quotes that hold more than a sentence, and one list of marks
**Images** — web `sha256:30f67832…`, server `sha256:6ffe4b58…`
No backend source changed this round.
- **A quote may hold whole blocks — and now has room for them.** A heading, a
list and a closing line inside a quote are ordinary blocks, but 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 the
list further apart than the blocks around them.
- **The same divide when it leaves as Markdown.** Blocks inside a quote 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 separated
by a **quoted** blank line now — `>` on its own, since an empty line would end
the quote — which is the same rule the top level has had since 11 August, and
reading one back gives the blocks it was written with, quotes inside quotes
included.
- **Marks are one list again.** There were two copies, in the note dialog and the
chapter dialog, and only one of them had ever been extended — so which marks
existed depended on which dialog you happened to open. One grid, and with it
twice as many marks, **divided under headings** now that there are too many for
one wall of pictures.
- **A mark nobody thought of can simply be typed.** The search box is a text
field, which on a phone is exactly where the emoji keyboard lives, so anything
pasted or typed into it is offered as a tile — one glyph with its modifiers, the
same eight code points the server keeps.
- **The changelog is a third of its length.** A day usually went out as several
images, and a round that repaired what an earlier round the same day had broken
reads as noise afterwards. One entry per day now, saying where it ended up, with
every digest of that day listed.
---
## 2026-08-17 — A notebook arranged by hand
**Images** — web `sha256:c5e460cf…`, server `sha256:ac3b0527…`
(earlier that day: web `b7c9f8b7…`, `c1bcdcc4…`, `b8bc284a…`, `18a68a2d…`;
server `a0fd140a…`, `6c9af404…`, `a66cc8eb…`, `febc4b93…`)
The server gained one column: `sort_mode` on chapters.
**Arrangement**
- **A drop lands the row *under* the one it was released on, and only there.**
Dropping above a row and below its predecessor are the same move described
twice, so every gap could be aimed at from two sides. A strip **above the first
row** covers the one place a drop *under* something cannot reach.
- **A new note appears at the very top and nothing else moves.** A row arriving
without a position left every separator counting rows that were no longer above
it, and a little more of the order came undone with each note. Every way in and
every way out — new, restored, moved, binned, or a chapter removed — now
renumbers the list.
- **A chapter is a place *and* a neighbour**: its header takes a row dropped on
it, its bottom edge is the gap under it. It is always in the user's own order,
and its notes are dragged under the same switch as the list beside them.
- **A chapter can be given an order of its own** — in the same dialog as its name,
mark and colour. In any order but the hand-made one it loses its lines and
handles, as the notebook does.
- **Two lines could not be put in one gap.** They carry the same position, and the
comparator claimed both directions at once for that case; the write that fixed
it was then discarded by the "did anything change?" test, which sorted before
comparing and so could not see a sequence.
**Elsewhere**
- ***Move to…* is divided under headings** — the notebook itself, *Chapters here*,
*Other notebooks* — each row wearing its chapter's mark or its notebook's cover.
The headings are deliberately not shaped like entries.
- **A separator and a chapter each have one *Edit*** instead of an entry per
property, and the separator dialog draws the line as it will look.
- **A link wears the mark of the note it points at**, in the text and in the `@`
menu, instead of one sheet-of-paper glyph for everything.
- **The mark, the title and the task-list box sit on the line they belong to.**
Boxes had been centred, which is not the same as letters being aligned; the
mark and title share a baseline, the checkbox is centred on the capitals and
sized in em, and the mark stands in the same column as the tools below it.
- **Grey letters ran down the bar while dragging a column edge.** An absolutely
positioned child in a cell of a `border-collapse` table sends Chromium down
another painting path, and the cell's own text came out a second time, squeezed
under the bar. The marker is never drawn now; the line is an inset shadow on the
cells the column is made of.
- **Opening a note flashed a long narrow page**, because the drawing stage was
measured a frame after it was drawn. Measured before the first paint now.
---
## 2026-08-16 — Marks, lines, and a table that breaks
**Images** — web `sha256:1f9f5141…`, server `sha256:3b2f8b44…`
The server gained `icon` on notes and chapters, `icon_color` on notes, and colour
and style on separators.
- **A note and a chapter can wear a mark**, chosen from a short list searched by
what a thing is *for* — "invoice", "house", "travel" — rather than from the
system emoji keyboard, which is a different thing on every machine.
- **A separator takes its own colour and one of five shapes.** Three faint grey
lines through one notebook read as the same line repeated.
- **A long table lay across the gap between two sheets** with a row cut in half.
Tables are broken between their rows now, which is where a table breaks on
paper; the spacer is a `tr`, since a `div` inside a `tbody` is not something the
browser keeps.
- **A dialog closed in the middle of an edit** when a selection was dragged past
its edge: the click lands on the nearest common ancestor of press and release.
All ten of them remember where the press began.
- **A chapter's colour could not be cleared** — "None" sent null, and the update
rode on `COALESCE`, which reads null as "leave it alone".
- A table's header row is legible on the dark theme, and opening a notebook opens
its first note on a wide screen.
---
## 2026-08-12 — Chapters, and a phone that works
**Images** — web `sha256:c077f0e0…`, server `sha256:c13af5d5…`
(earlier that day: web `65799ecb…`, `2041f103…`, `95657aaf…`, `3700247c…`,
`790ff73a…`, `1217b81b…`, `5ea846f3…`, `c1222665…`; server `a51b96ad…`,
`ed0b9970…`, `b4db0725…`, `181d77be…`, `3303d3dc…`, `06f94f43…`)
The server gained a `chapters` table, `chapter_id` on notes, a `color` column and
a reorder route for chapters, named positions on the note reorder, a
`cover_pattern` column, and the per-account setting for how a chapter opens.
- **Chapters — folders inside a notebook.** A note is in one or in none. Removing
a chapter never removes what it held, and the dialog says so with the number
affected. They sit at the same level as notes and separators: one counter, one
grip, one kind of drag.
- **Two ways for a chapter to open**, chosen per account: a further column beside
the list, or unfolded under its own row. A phone fills the list instead.
- **A chapter is made complete in one dialog** — name and colour together — and a
chapter with no notes in it is visible at last; "nothing here" had been asked of
the notes alone.
- **A notebook starts in the hand-made order**, which is where its arrangement
lives. The views across all notebooks keep "last changed": they have nowhere to
keep an arrangement. **Rearranging is a mode**, switched on beside the sort menu.
- **A filed note came back out of its chapter by itself.** The server had it right
the whole time; the sync that follows rebuilds every note from the server's
answer, and the function doing that never carried `chapter_id`.
- **Removing a chapter left you stuck in the confirmation** — that one path set
the dialog directly instead of going through the helper that takes it down. The
suites had read the list straight through the open overlay and passed; they now
assert the question is gone.
- **The order of notes could not be changed on a phone**: the list used the HTML5
drag API, which mobile browsers never fire from touch. Pointer events and a grip
per row. **"Move to…" opened off the side of the screen** and reacted only to
hover; it opens on a tap and flips side when there is no room.
- **A note made offline lost everything written into it** — its create reached the
server, the server named it something else, and every queued edit went on
addressing the name only that device knew. The queue is repointed with the
database now.
- **The install button never appeared, even over HTTPS.** `beforeinstallprompt`
fires before React mounts, so it is caught in `index.html`; and the manifest is
fetched without credentials unless the link says `use-credentials`, so behind an
authenticating proxy the browser read the login page as the manifest.
- **The service worker could cache a proxy's login page as the application** and
then "work offline" by showing a login screen it could never get past.
Redirected and unsuccessful responses are no longer cached.
- **A cover is a colour and a pattern, chosen in turn** — every combination, drawn
in CSS, with existing notebooks keeping exactly the cover they had.
- The search box in a notebook is simply there, the margin-note column is half
again as wide, and a submenu is no longer clipped away on a wide screen.
---
## 2026-08-11 — Writing on paper
**Images** — web `sha256:b764a9d8…`, server unchanged at `sha256:0832ee2b…`
**Images** — web `sha256:3a181ff3…`, server `sha256:0832ee2b…`
(earlier that day: web `b764a9d8…`, `dc804f7b…`, `ed11f034…`, `3b040d99…`;
server `1648498f…`)
The server gained a column and two routes for extracted PDF text.
**Added**
- **You write on paper.** Each page is a sheet of its own, centred, with a gap
after it — the same way the PDF reader draws its pages. The sheet used to
appear only once you started drawing, which left the writing mode looking like
text floating in an empty pane, and made the space either side of the column
read as a void rather than as a margin.
- Text is broken against those sheets, so no paragraph is ever cut in half by a
gap. The breaks are decorations: nothing enters the document, reaches the
server, or appears in an export.
**Fixed**
- **A note with a drawing could not be typed in at all.** The box wrapping each
page's ink layer lay over the text and swallowed every click meant for it. The
canvas inside was already deaf to the pointer; its wrapper was not. Introduced
the same day, with per-page drawing.
---
## 2026-08-11 — Drawing on every page, and PDFs findable everywhere
**Images** — web `sha256:dc804f7b…`, server `sha256:0832ee2b…`
The server image changed this round: it takes a new column and two routes.
`docker compose pull && docker compose up -d` — the column is added on start.
**Added**
- **A PDF is searchable on every device, not only on the one that opened it.**
The device that imports the file reads it there and then — the server still has
no PDF library and never opens one — and the extracted text is stored beside
the file, encrypted, so every other device can find the document without ever
downloading it. This is what makes an archive of scanned invoices work: they
are filed and then never opened, so waiting for someone to open one meant
waiting forever.
- **A drawing runs over as many pages as the note has.** The ink used to be stuck
on page one while the writing carried on without it. Pages are numbered the way
the PDF reader numbers them, and there is always one blank page past the end to
start a new one on. The bin clears the page being looked at; undo still works on
the drawing as a whole.
- A drawing that lives entirely on page one is still stored in the old shape, so
an ordinary note is written exactly as it always was and an older client still
shows it.
**Fixed**
- **A drawing exported in a ZIP was lost on re-import.** The exporter has written
`drawings/<note>.png` since the interface was put into English; the importer
was still looking for `zeichnungen/<note>.png` and found nothing. Both names
are read now, and so are the further pages of a drawing.
---
## 2026-08-11 — The writing column
**Images** — web `sha256:ed11f034…`, server unchanged at `sha256:1648498f…`
**Changed**
- The note column is centred again. It had been pinned to the left margin so the
start of every line would always be in the same place; on a wide window that
left the whole right half of the pane as one dead block.
- A page surface was drawn behind the text to give that block a meaning, and
reverted the same day: it made the sheet sit hard against the left edge, which
was worse than the problem it solved.
---
## 2026-08-11 — Pages, typefaces, alignment
**Images** — web `sha256:3b040d99…`, server `sha256:1648498f…`
**Added**
- **Page count while writing.** The toolbar says *Page 2 of 4*, in the same words
the PDF reader uses. Pages are counted, never stored: the note stays one
document, and a test asserts no page ever reaches the stored body.
- **The typeface is a per-account setting** — serif, sans or mono, chosen under
Account → Profile. On the account rather than in the browser, so it follows you
to the phone.
- **Text alignment**, with its own TipTap extension rather than the official one,
so the schema stays in files this project owns. Left is the default and is
never written into the document. In Markdown, non-left blocks travel as
`<div align="…">` — the one form most readers still honour — and the importer
reads it back onto the paragraphs.
**Fixed**
- The Markdown exporter did not put a blank line between top-level blocks, so
consecutive paragraphs ran together in other readers.
---
## 2026-08-11 — English throughout, and the PDF toolbar
**Added**
- **Search inside a notebook**, from the note list. The sidebar's global search
was removed: searching from inside a notebook should mean searching *that*
notebook.
- **New note / Upload PDF / Import Markdown** all live under the one `+` button.
**Changed**
- The interface is English throughout. It had drifted into two languages.
- The two visibility switches — drawing and margin notes — sit together at the
far right of the PDF toolbar, because they mean the same kind of thing.
- The drawing tools appear next to the *Draw* button, not past everything else.
- A separator is renamed and removed by right-click, with a confirmation for the
removal.
**Fixed**
- Hiding the drawing also hid the margin notes. Each switch now hides only its
own thing.
after it, and the text is broken against those sheets so no paragraph is cut in
half. The breaks are decorations: nothing enters the document, reaches the
server, or appears in an export. The toolbar says *Page 2 of 4*.
- **A drawing runs over as many pages as the note has**, numbered the way the PDF
reader numbers them, with one blank page past the end to start on. A drawing
that lives on page one alone is still stored in the old shape.
- **A PDF is searchable on every device**, not only the one that imported it: the
importing device extracts the text and stores it beside the file, encrypted. The
server still has no PDF library.
- **The typeface is a per-account setting** — serif, sans or mono — so it follows
you to the phone. **Text alignment** likewise, with its own extension so the
schema stays in files this project owns; non-left blocks travel as
`<div align="…">` in Markdown.
- **A note with a drawing could not be typed in at all**: the box wrapping each
page's ink layer lay over the text and swallowed every click.
- **A drawing exported in a ZIP was lost on re-import** — the exporter had written
`drawings/…` since the interface went English, the importer still looked for
`zeichnungen/…`.
- **"Start writing..." was printed at the foot of every page** with a break after
it: the placeholder styles `[contenteditable]:empty::before`, and the spacer
holding a gap open is exactly that.
- The Markdown exporter began putting a blank line between top-level blocks, and
the margins narrowed from 73 to about 80 characters.
---
## 2026-08-10 — Notes on passages
**Added**
- **Pinned notes in a PDF.** A remark can be attached to a passage, and is shown
in the margin beside the page — not written on top of it. On a phone, where
there is no margin, the same notes open as a bottom sheet.
- **Margin notes can be linked from the notes panel**, so a pinned remark can be
written about at length and still lead back to the passage it came from.
**Fixed**
- A grey drag indicator stayed visible in the note list after a drag. It was a
`borderTop` sitting beside a `border` shorthand in one style object.
- The server stored an overlay holding only pinned notes as `NULL`, throwing
every one of them away, because it counted strokes and nothing else.
- **Pinned notes in a PDF.** A remark is attached to a passage and shown in the
margin beside the page, never written on top of it; on a phone it opens as a
bottom sheet. Such a remark can be linked from the notes panel and written about
at length.
- The server stored an overlay holding only pinned notes as `NULL`, throwing every
one of them away, because it counted strokes and nothing else.
---
## 2026-08-09 — Arranging a notebook
**Added**
- **Sorting per notebook**, remembered on the notebook itself, including an
arrangement made by hand — which comes back intact after filtering.
- **Separators** to group notes inside a hand-made arrangement.
- **Markdown import**, single files or a ZIP this app exported.
- **Moving notes between notebooks.**
**Fixed**
- Switching to Draw shrank the page from 733px to 460px. The editor was a flex
item at the default `flex: 0 1 auto`, so it was only as wide as its widest row
— the toolbar, which loses a dozen buttons in Draw.
- **Sorting per notebook**, remembered on the notebook, including an arrangement
made by hand — which comes back intact after filtering.
- **Separators**, **Markdown import** (single files or a ZIP this app exported),
and **moving notes between notebooks**.
- Switching to Draw shrank the page from 733px to 460px: the editor was a flex
item at `flex: 0 1 auto`, so it was only as wide as its widest row — the
toolbar, which loses a dozen buttons in Draw.
---
## 2026-08-08 — PDFs
**Added**
- Import a PDF, read it, select its text, draw on each page, and export it with
the drawings written in.
- Notes written beside a PDF.
the drawings written in. Notes written beside a PDF.
- Uploaded files are encrypted at rest, like note bodies already were, and stored
as deduplicated, content-addressed attachments.
- Unused attachments are freed; versions are kept by count rather than by age.
as deduplicated, content-addressed attachments; unused ones are freed, and
versions are kept by count rather than by age.
- Export as Markdown or PDF; the browser suites moved into the repository.
**Changed**
- Vector clocks were never read. Replaced with a conflict check that works.
- Public sign-up removed — an administrator creates the accounts.
- Vector clocks were never read — replaced with a conflict check that works.
- Public sign-up removed: an administrator creates the accounts.
---
## Earlier
TipTap editor, version history, full-text search and attachments (2026-08-07);
drawing sync with WebP/JPEG support (2026-06-21); the first syncing alpha and PWA
(2026-02-18).
The interface became English throughout, and search inside a notebook replaced
the sidebar's global search (2026-08-11). Before that: the TipTap editor, version
history, full-text search and attachments (2026-08-07); drawing sync with
WebP/JPEG support (2026-06-21); the first syncing alpha and PWA (2026-02-18).
+6 -1
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.
@@ -34,7 +39,7 @@ Beides muss vor einem Push grün sein.
# Unit (~4 s)
docker run --rm -v "$PWD/frontend:/w" -w /w node:20-alpine npx vitest run
# Browser, zwölf Suiten – braucht den Dev-Stack
# Browser, vierzehn Suiten – braucht den Dev-Stack
./e2e/run.sh # alle
./e2e/run.sh editor drawing # einzelne
```
+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,
};
+437 -23
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),
});
/**
@@ -297,6 +312,15 @@ const signedInUser = (row) => ({ ...publicUser(row), defaultMaxVersions: MAX_VER
* kept: this column is written by the browser, so it is an input like any other,
* and a fixed shape is what stops it becoming a place to put arbitrary data. */
const NOTE_FONTS = ["serif", "sans", "mono"];
/* How a chapter opens: as a further column beside the notes, the way OneNote
* 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;
@@ -306,7 +330,13 @@ function readPreferences(raw) {
parsed = null;
}
const font = parsed?.noteFont;
return { noteFont: NOTE_FONTS.includes(font) ? font : "serif" };
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",
};
}
// Login used to be e-mail based. Rebuild the table with a username column and
@@ -439,6 +469,7 @@ db.exec(`
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
@@ -447,6 +478,58 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id);
`);
/* A mark of its own, so a row can be found by its shape rather than by reading
* every title. One short string — an emoji is one or two code points, and
* anything longer than a handful is not a mark. */
if (!tableColumns("notes").includes("icon")) {
db.exec("ALTER TABLE notes ADD COLUMN icon TEXT");
}
if (!tableColumns("chapters").includes("icon")) {
db.exec("ALTER TABLE chapters ADD COLUMN icon TEXT");
}
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)");
@@ -646,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();
@@ -700,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" });
@@ -710,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);
@@ -725,14 +1015,25 @@ app.put("/api/auth/profile", authMiddleware, (req, res) => {
});
app.put("/api/auth/preferences", authMiddleware, (req, res) => {
const { noteFont } = 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
);
const next = { ...current, ...(noteFont !== undefined ? { noteFont } : {}) };
const next = {
...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);
res.json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId)));
@@ -742,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);
@@ -756,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);
@@ -906,6 +1219,9 @@ const SORT_MODES = ["manual", "updated", "oldest", "created", "title"];
/* An upper bound on the arrangement, so a malformed client cannot store an
* unbounded blob on a notebook. */
const MAX_SEPARATORS = 200;
/** The ways a separator's rule can be drawn. Anything else is left off. */
const SEPARATOR_STYLES = ["solid", "dashed", "dotted", "double", "thick"];
const MAX_SEPARATOR_LABEL = 80;
/** Keeps only what a separator is allowed to be. */
@@ -922,6 +1238,10 @@ function cleanSeparators(input) {
* itself. `order` counts the notes before it, and that only means anything
* within one list. */
chapterId: typeof s.chapterId === "string" && s.chapterId ? s.chapterId.slice(0, 64) : null,
// Presentation, and it comes from a browser: a colour has to look like one
// and a style has to be one we draw, or it is simply left off.
color: cleanColor(s.color),
style: SEPARATOR_STYLES.includes(s.style) ? s.style : null,
}));
}
@@ -1059,12 +1379,14 @@ function isBigShrink(previous, next) {
/* ─── Note routes ─── */
app.get("/api/notes", authMiddleware, (req, res) => {
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, icon, icon_color, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
res.json(notes);
});
app.get("/api/notes/trash", authMiddleware, (req, res) => {
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
// `chapter_id` travels with it: a note restored from the bin belongs back in
// the chapter it was filed in, not loose in the notebook.
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, icon, icon_color, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
res.json(notes);
});
@@ -1124,16 +1446,27 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => {
*/
const MAX_REORDER = 5000;
app.post("/api/notes/reorder", authMiddleware, (req, res) => {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.slice(0, MAX_REORDER) : null;
if (!ids) return res.status(400).json({ error: "ids must be an array" });
/* Two shapes. `ids` numbers them by their place in the array, which is all
* that was ever needed while notes were the only rows. `positions` names the
* number for each — needed once chapters share the sequence, where a note's
* place in its own array is no longer its position in the list. */
const positions = Array.isArray(req.body?.positions)
? req.body.positions.slice(0, MAX_REORDER)
: Array.isArray(req.body?.ids)
? req.body.ids.slice(0, MAX_REORDER).map((id, index) => ({ id, order: index }))
: null;
if (!positions) return res.status(400).json({ error: "ids or positions must be an array" });
const write = db.transaction(() => {
const update = db.prepare("UPDATE notes SET sort_order = ? WHERE id = ? AND user_id = ?");
ids.forEach((id, index) => {
if (typeof id === "string" && id) update.run(index, id, req.userId);
});
for (const p of positions) {
if (p && typeof p.id === "string" && p.id && Number.isFinite(Number(p.order))) {
update.run(Math.max(0, Math.round(Number(p.order))), p.id, req.userId);
}
}
});
write();
const ids = positions.map((p) => p?.id);
broadcastToUser(req.userId, { type: "notes_reordered", data: { count: ids.length } }, req.deviceId);
res.json({ success: true });
@@ -1200,7 +1533,7 @@ const replaceNoteAttachments = db.transaction((noteId, ids) => {
});
app.put("/api/notes/:id", authMiddleware, (req, res) => {
const { title, content, notebookId, chapterId, starred, baseUpdatedAt, links, attachments } = req.body;
const { title, content, notebookId, chapterId, starred, icon, iconColor, baseUpdatedAt, links, attachments } = req.body;
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!note) return res.status(404).json({ error: "Not found" });
@@ -1255,6 +1588,15 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
WHERE id = ?`)
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.id);
/* Cleared as deliberately as it is set: null means "take the mark off", which
COALESCE above cannot express. */
if (icon !== undefined) {
db.prepare("UPDATE notes SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
}
if (iconColor !== undefined) {
db.prepare("UPDATE notes SET icon_color = ? WHERE id = ?").run(cleanColor(iconColor), req.params.id);
}
if (chapterId !== undefined) {
db.prepare("UPDATE notes SET chapter_id = ? WHERE id = ?").run(chapterUpdate, req.params.id);
} else if (notebookId !== undefined && notebookId !== null && notebookId !== note.notebook_id) {
@@ -1674,19 +2016,41 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
const MAX_CHAPTER_NAME = 120;
/* A colour is written by a browser, so it is an input like any other: only a
* plain `#rrggbb` gets through, and anything else becomes "no colour". */
const cleanColor = (value) =>
typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : null;
/**
* A row's own mark: one emoji, or nothing.
*
* Length is counted in code points, not in UTF-16 units — a single emoji is
* routinely two of the latter, and a flag or a family several more. Cut at eight
* so one grapheme with its modifiers fits and a pasted sentence does not.
*/
const cleanIcon = (value) => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
return [...trimmed].slice(0, 8).join("");
};
app.get("/api/chapters", authMiddleware, (req, res) => {
res.json(
db
.prepare("SELECT id, notebook_id, name, 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 } = 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.
@@ -1697,16 +2061,48 @@ 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, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?)")
.run(id, req.userId, notebookId, label, 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) => {
const positions = Array.isArray(req.body?.positions) ? req.body.positions.slice(0, MAX_REORDER) : null;
if (!positions) return res.status(400).json({ error: "positions must be an array" });
const write = db.transaction(() => {
const update = db.prepare("UPDATE chapters SET sort_order = ? WHERE id = ? AND user_id = ?");
for (const p of positions) {
if (p && typeof p.id === "string" && p.id && Number.isFinite(Number(p.order))) {
update.run(Math.max(0, Math.round(Number(p.order))), p.id, req.userId);
}
}
});
write();
broadcastToUser(req.userId, { type: "chapters_reordered", data: { count: positions.length } }, req.deviceId);
res.json({ success: true });
});
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
const { name, sortOrder } = 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) {
@@ -1716,6 +2112,21 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => {
}
db.prepare("UPDATE chapters SET name = COALESCE(?, name), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?")
.run(label ?? null, sortOrder ?? null, req.deviceId, req.params.id);
/* Colour and mark are set and *cleared* by the same field, so neither can ride
* on COALESCE the way the name does: null means "take it off", and COALESCE
* reads null as "leave it alone" — so choosing "None" quietly did nothing. */
if (color !== undefined) {
db.prepare("UPDATE chapters SET color = ? WHERE id = ?").run(cleanColor(color), req.params.id);
}
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 });
});
@@ -1978,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
+657 -18
View File
@@ -24,21 +24,38 @@ try {
const titles = () =>
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
const addChapter = async (name) => {
/* Name, colour and mark are chosen while the chapter is being made — they are
one decision, and asked one at a time from three menu entries, getting a new
chapter to look right took three visits. */
const addChapter = async (name, colour = null, icon = null) => {
await p.locator("[data-add-chapter]").click();
await p.waitForTimeout(600);
await p.getByLabel("Chapter name").fill(name);
await p.getByRole("button", { name: "Create", exact: true }).click();
if (colour) await p.locator(`[data-colour-option="${colour}"]`).click();
if (icon) {
await p.getByLabel("Search icons").fill(icon);
await p.waitForTimeout(400);
await p.locator("[data-icon-option]").nth(1).click();
}
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2000);
};
/* Filing a note is one of the things a row can have done to it, and they all
live in the row's own menu. A button for this one on every row put a second
icon beside every title, for something done once in a note's life. */
const moveTo = async (noteTitle, target) => {
await p.locator("[data-note-row]").filter({ hasText: noteTitle }).first().click({ button: "right", force: true });
const row = p.locator("[data-note-row]").filter({ hasText: noteTitle }).first();
await row.click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").first().click();
await p.locator("[data-submenu-toggle]").filter({ hasText: "Move to" }).first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: target }).first().click();
await p.waitForTimeout(2500);
};
const openNotebook = async () => {
await p.locator("button").filter({ hasText: "Haus" }).first().click();
await p.waitForTimeout(1200);
};
await createNotebook(p, "Haus");
await p.locator("button").filter({ hasText: "Haus" }).first().click();
@@ -58,7 +75,41 @@ try {
const server = (await api(p, "GET", "/chapters")).body;
check(server.length === 1 && server[0].name === "Rechnungen", "the server has it", JSON.stringify(server));
/* ── in an empty notebook too ──
* "Nothing here" was asked of the notes alone, so a notebook whose only
* contents were chapters showed the empty-list panel with the chapters hidden
* behind it: making a chapter in a fresh notebook appeared to do nothing. */
await createNotebook(p, "Leeres Buch");
await p.locator("button").filter({ hasText: "Leeres Buch" }).first().click();
await p.waitForTimeout(1000);
await addChapter("Nur ein Kapitel", "#a371f7", "invoice");
check(
(await p.locator("[data-chapter-dialog]").count()) === 0,
"the chapter dialog closes once it has been saved"
);
check(
(await p.locator("[data-chapter-row]").count()) === 1,
"a chapter shows in a notebook that has no notes at all",
String(await p.locator("[data-chapter-row]").count())
);
const madeWithColour = (await api(p, "GET", "/chapters")).body.find((c) => c.name === "Nur ein Kapitel");
check(
madeWithColour?.color === "#a371f7" && !!madeWithColour?.icon,
"and the colour and mark picked while making it are stored with it",
JSON.stringify([madeWithColour?.color, madeWithColour?.icon])
);
check(
(await p.locator("[data-no-loose-notes]").count()) === 1,
"and the list says why it is otherwise empty rather than looking broken"
);
await openNotebook();
/* ── filing a note ── */
check(
(await p.locator("[data-move-note]").count()) === 0,
"filing is in the row's menu, not a button standing in every row",
String(await p.locator("[data-move-note]").count())
);
await moveTo("Stromrechnung", "Rechnungen");
check(
(await titles()).join(",") === "Zählerstand",
@@ -66,33 +117,606 @@ try {
(await titles()).join(", ")
);
/* ── and stays filed ──
* It did not. The server had it right, but the sync that follows replaced
* every local note with one belonging to no chapter — so about five seconds
* after being filed, the note walked back out into the notebook by itself.
* A reload is the same journey: the mirror is rebuilt from the server. */
await p.reload();
await p.waitForTimeout(4000);
await openNotebook();
check(
(await titles()).join(",") === "Zählerstand",
"and is still in it after the next sync, not back in the notebook",
(await titles()).join(", ")
);
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
check((await titles()).join(",") === "Stromrechnung", "and is inside the chapter", (await titles()).join(", "));
check((await p.locator("[data-chapter-back]").count()) === 1, "with a way back out");
const inChapter = () =>
p.locator("[data-chapter-note]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
check((await inChapter()).join(",").includes("Stromrechnung"), "and is inside the chapter", (await inChapter()).join(", "));
check((await p.locator("[data-chapter-close]").count()) === 1, "with a way to close it again");
const filed = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Stromrechnung");
check(!!filed?.chapter_id, "the server filed it too", String(filed?.chapter_id));
/* A note made inside a chapter belongs to it, without being moved. */
/* A note made while a chapter is open belongs to it, without being moved. */
await createNote(p);
await setTitle(p, "Wasserrechnung");
await p.waitForTimeout(1500);
const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung");
check(made?.chapter_id === filed?.chapter_id, "a note made inside a chapter is filed there", String(made?.chapter_id));
check(made?.chapter_id === filed?.chapter_id, "a note made while it is open is filed there", String(made?.chapter_id));
await p.locator("[data-chapter-back]").click();
/* ── which of the two am I in ──
* Beside the list, a chapter does not replace the notebook: both are on
* screen at once. A new note used to go into the chapter however far away the
* user had since clicked, and nothing said which of the two was meant. */
check(
(await p.locator('[data-chapter-current="on"]').count()) === 1,
"opening a chapter makes it the place being written into, and shows it"
);
// Into the empty space under the notebook's own notes.
await p.locator("[data-note-list-body]").click({ position: { x: 140, y: 330 } });
await p.waitForTimeout(700);
check(
(await p.locator('[data-list-current="on"]').count()) === 1 &&
(await p.locator('[data-chapter-current="off"]').count()) === 1,
"clicking into the notebook puts you back in the notebook, visibly"
);
await createNote(p);
await setTitle(p, "Gartenzaun");
await p.waitForTimeout(1500);
const loose = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Gartenzaun");
check(!loose?.chapter_id, "and a note made then is the notebook's, not the chapter's", String(loose?.chapter_id));
check(
(await titles()).sort().join(",") === "Gartenzaun,Zählerstand",
"so it appears in the notebook's own list",
(await titles()).join(", ")
);
await p.locator("[data-chapter-close]").click();
await p.waitForTimeout(1200);
check(
(await titles()).join(",") === "Zählerstand",
"and going back shows the notebook again, without the chapter's notes",
(await titles()).sort().join(",") === "Gartenzaun,Zählerstand",
"and the notebook's own list never held the chapter's notes",
(await titles()).join(", ")
);
/* ── the two ways a chapter opens ──
* A further column beside the list, the way OneNote lays out its pages, or
* unfolded under its own row. Chosen on the account, so the answer follows to
* the phone — where there is room for neither and the chapter fills the
* screen instead. */
await addChapter("Verträge");
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
check((await p.locator("[data-chapter-column]").count()) === 1, "a chapter opens beside the list by default");
check(
(await p.locator("[data-chapter-row]").count()) === 2,
"and the notebook's own list keeps every chapter row",
String(await p.locator("[data-chapter-row]").count())
);
await p.getByTitle("Account").click();
await p.waitForTimeout(800);
await p.locator('[data-chapter-view="inline"]').click();
await p.waitForTimeout(1500);
/* Escape closes it. Only a click beside the panel did, which on one this wide
means aiming at a sliver of page. */
await p.keyboard.press("Escape");
await p.waitForTimeout(1200);
check(
(await p.locator("[data-chapter-view]").count()) === 0,
"the account panel closes with Escape, not only by clicking beside it"
);
// No second click: the chapter that was open stays open, and changing the
// setting changes only where it is shown.
check((await p.locator("[data-chapter-notes]").count()) === 1, "the other setting unfolds it under its row");
check((await p.locator("[data-chapter-column]").count()) === 0, "and there is no column then");
const me = await api(p, "GET", "/auth/me");
check(me.body?.preferences?.chapterView === "inline", "the choice is on the account, not in this browser");
/* ── a chapter is a row like any other ──
* Same level as notes and separators, so it can be dragged into place in the
* arrangement rather than being stuck above everything. */
await p.locator("[data-sort-button]").click();
await p.waitForTimeout(300);
await p.locator('[data-sort-option="manual"]').click();
await p.waitForTimeout(1200);
/* Rearranging is asked for, and until it is, no row wears a handle.
* Six dots stood beside every title at all times for something done once in a
* while, and a press meant to open a note could carry it off instead. */
check(
(await p.locator("[data-reorder-grip]").count()) === 0,
"no row wears a handle until rearranging is switched on",
String(await p.locator("[data-reorder-grip]").count())
);
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(600);
check(
(await p.locator("[data-reorder-grip]").count()) >= 2,
"switched on, a chapter has a grip like a note does",
String(await p.locator("[data-reorder-grip]").count())
);
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(600);
check((await p.locator("[data-reorder-grip]").count()) === 0, "and they go away again when it is done");
/* ── a separator drawn in the notebook belongs to the notebook ──
* Opening a chapter used to take the notebook's own separators off the screen
* with it: the list still showed the notebook, but everything around it had
* quietly switched to the chapter. A line vanished for no reason anyone could
* see, and dragging in that list wrote nothing at all. */
await p.locator("[data-add-separator]").click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 1, "a separator can be drawn in the notebook");
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1500);
check(
(await p.locator("[data-separator]").count()) === 1,
"and it is still there once a chapter is opened",
String(await p.locator("[data-separator]").count())
);
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
/* ── a row is put into a chapter by dragging it there ──
* A chapter is a place, so it takes what is dropped on it rather than making
* room beside it. That is also how a chapter gets a line of its own: there is
* no second "+ Separator" inside it, because a second way to make the same
* thing in a second place is a second thing to keep straight. */
check(
(await p.locator("[data-add-chapter-separator]").count()) === 0,
"a chapter has no separator button of its own"
);
/* Dragged with synthesised pointer events from the grip, the way a finger
does it — Playwright drives a mouse, and the branch worth exercising is the
one a phone takes. */
/* Held and dropped in two steps, with a wait between: the highlight is React
state, and asking for it in the same breath as the move that caused it reads
the DOM before it has been drawn. */
/* `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. */
/* 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",
pointerId: 1,
clientX: x,
clientY: y,
bubbles: true,
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();
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";
},
[chapterId, where, scope]
);
};
const letGo = () =>
p.evaluate(() => {
const d = window.__drop;
window.dispatchEvent(
new PointerEvent("pointerup", {
pointerType: "touch",
pointerId: 1,
clientX: d.x,
clientY: d.y,
bubbles: true,
cancelable: true,
})
);
});
const dragOnto = async (dragId, chapterId) => {
const held = await holdOver(dragId, chapterId);
if (held !== "held") return held;
await p.waitForTimeout(400);
const lit = await p.locator("[data-chapter-drop]").count();
await letGo();
await p.waitForTimeout(2200);
return lit === 1 ? "done" : "no-highlight";
};
/* Rows are only draggable while rearranging is switched on — the handles are
asked for rather than standing in every row. */
if ((await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle")) === "off") {
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(700);
}
check(
(await p.locator("[data-reorder-id]").count()) > 0,
"there are handles to drag by",
JSON.stringify({
toggle: await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle"),
grips: await p.locator("[data-reorder-grip]").count(),
rows: await p.locator("[data-reorder-id]").count(),
})
);
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.
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")));
// The two strips at the ends of the list are drop targets, not rows: they
// carry a reorder id and nothing else, least of all a grip to drag by.
const noteId = rowOrder.find(
(id) =>
id &&
!id.startsWith("sep-") &&
id !== "__end__" &&
id !== "__start__" &&
!shownChapters.includes(id)
);
check(
!!noteId && !!shownChapters[0],
"there is a note row and a chapter row to drag between",
JSON.stringify({ rowOrder, shownChapters })
);
const dragged = await dragOnto(noteId, shownChapters[0]);
check(dragged === "done", "the chapter lights up as the row is held over it", dragged);
check(
(await titles()).length === looseBefore - 1,
"a note dragged onto a chapter leaves the notebook's own list",
`${looseBefore} → ${(await titles()).length}`
);
const filedByDrag = (await api(p, "GET", "/notes")).body.filter((n) => n.chapter_id).length;
check(filedByDrag >= 1, "and the server has it filed there", String(filedByDrag));
/* A line is drawn in the notebook and dragged in, which is how a chapter is
divided at all. */
const sepRow = (await notebookRows()).find((id) => id && id.startsWith("sep-"));
if (sepRow) {
await dragOnto(sepRow, shownChapters[0]);
await p.waitForTimeout(2500);
const inChapterNow = JSON.parse(
(await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Haus")?.separators || "[]"
);
check(
inChapterNow.some((sep) => sep.chapterId),
"a separator dragged onto a chapter belongs to it",
JSON.stringify(inChapterNow.map((sep) => [sep.label, sep.chapterId]))
);
} else {
check(false, "a separator was there to drag", "none found");
}
/* ── a chapter is a neighbour too ──
* Taking the whole row as "into" quietly took reordering away: a chapter could
* 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 notebookRows()).find(
(id) =>
id &&
!id.startsWith("sep-") &&
id !== "__end__" &&
id !== "__start__" &&
!shownChapters.includes(id)
);
if (looseLeft) {
const held = await holdOver(looseLeft, shownChapters[0], "edge");
await p.waitForTimeout(400);
const litAtEdge = await p.locator("[data-chapter-drop]").count();
const lineAtEdge = await p.locator("[data-drop-line]").count();
await letGo();
await p.waitForTimeout(2000);
check(
held === "held" && litAtEdge === 0 && lineAtEdge === 1,
"held over the bottom edge of a chapter, it offers a line instead of taking the row in",
JSON.stringify({ held, litAtEdge, lineAtEdge })
);
} else {
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
* dialog used to take that for "clicked beside me" and vanish mid-edit. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
const field = p.getByLabel("Chapter name");
const box = await field.boundingBox();
await p.mouse.move(box.x + box.width - 12, box.y + box.height / 2);
await p.mouse.down();
await p.mouse.move(box.x - 220, box.y + box.height / 2, { steps: 12 });
await p.mouse.up();
await p.waitForTimeout(800);
check(
(await p.locator("[data-chapter-dialog]").count()) === 1,
"a selection dragged past the edge does not close the dialog"
);
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* ── its own colour, afterwards ──
* The same dialog that named it. There used to be a "Colour" submenu beside
* this entry as well: two things in the menu, one of them offering half of
* what the other did. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
check(
(await p.locator("[data-submenu-toggle]").count()) === 0 &&
(await p.getByText("Edit", { exact: true }).count()) === 1,
"its menu offers one Edit rather than one entry per property"
);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
check((await p.locator("[data-chapter-preview]").count()) === 1, "and the dialog draws the row as it will be");
await p.locator('[data-colour-option="#2da44e"]').click();
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2000);
const coloured = (await api(p, "GET", "/chapters")).body;
check(
coloured.some((c) => c.color === "#2da44e"),
"a chapter can be recoloured after the fact",
JSON.stringify(coloured.map((c) => [c.name, c.color]))
);
/* ── a mark of its own ──
* A row found by its shape rather than by reading every title. The same glyph
* shows in the list and in the editor, so a note is recognised by one sign in
* both places. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
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);
const marked = (await api(p, "GET", "/chapters")).body.find((c) => c.icon);
check(!!marked?.icon, "a chapter keeps the mark it was given", JSON.stringify([marked?.name, marked?.icon]));
check(
(await p.locator("[data-chapter-mark]").first().innerText()).trim() === marked.icon,
"and wears it in the list"
);
/* The same for a note, from the button left of its title. */
await p.locator("[data-note-row]").first().click();
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);
// 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);
const iconed = (await api(p, "GET", "/notes")).body.find((n) => n.icon);
check(!!iconed?.icon && iconed?.icon_color === "#2da44e", "a note keeps its mark and its colour", JSON.stringify([iconed?.title, iconed?.icon, iconed?.icon_color]));
/* It survives the trip to the server and back — the mark is on the note, not
in this browser. */
await p.reload();
await p.waitForTimeout(4000);
await openNotebook();
check(
(await p.locator("[data-note-icon]").getAttribute("data-note-icon")) === iconed.icon ||
(await p.locator("[data-note-mark]").first().innerText()).trim() === iconed.icon,
"and still wears it after a reload"
);
/* ── 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);
@@ -100,16 +724,31 @@ try {
await p.getByRole("button", { name: "Remove", exact: true }).click();
await p.waitForTimeout(2500);
check((await p.locator("[data-chapter-row]").count()) === 0, "the chapter is gone");
const after = (await titles()).sort().join(",");
/* The question has to be gone afterwards.
*
* It was not: removing a chapter did its work and left the prompt standing
* over the app, with nothing but a reload to get out of. Every check below
* still passed — they read the list straight through the overlay, which is
* there in the DOM the whole time. */
check(
after === "Stromrechnung,Wasserrechnung,Zählerstand",
(await p.locator("[data-confirm-dialog]").count()) === 0,
"confirming closes the question rather than leaving it over the app"
);
check(
(await p.locator("[data-chapter-row]").count()) === 1,
"the chapter is gone, and only that one",
String(await p.locator("[data-chapter-row]").count())
);
const after = (await titles()).sort();
check(
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]))
);
+13
View File
@@ -149,6 +149,19 @@ try {
(await p.locator('canvas[data-draw-page="2"]').count()) === 1,
"there is a blank page past the end to draw on, before anything is written"
);
/* It has to read as an offer, not as a page that appeared by itself. Drawn
solid, a second sheet turned up the moment Draw was pressed while the count
still said "of 1", which looks like the app inventing a page. */
check(
(await p.locator("[data-spare-page]").count()) === 1,
"and it is marked as the spare rather than drawn as paper",
String(await p.locator("[data-spare-page]").count())
);
check(
(await p.locator("[data-note-position]").innerText()).includes("of 1"),
"so the count still says one page until something is on it",
await p.locator("[data-note-position]").innerText()
);
await draw(p, 1);
const first = await inkGrid(p, 1);
+236 -7
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.`);
await p.keyboard.press("Enter");
}
/* Enough for several pages. With only two, the middle of the window never
leaves the first one — a window is shorter than a page — so the check would
be testing arithmetic rather than behaviour.
Pasted rather than typed: at the speed a robot types, a long note loses
keystrokes between the editor and the pagination measuring after each one,
and the page count then measures typing throughput instead of pagination.
(At any human speed nothing is lost — checked at 50 characters a second.) */
await p.keyboard.type("Absatz 1: Dichtungen altern durch Wärme.");
await p.keyboard.press("Enter");
await p.evaluate((text) => {
const dt = new DataTransfer();
dt.setData("text/plain", text);
document
.querySelector(".ProseMirror")
.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
}, Array.from({ length: 200 }, (_, i) => `Absatz ${i + 2}: Dichtungen altern durch Wärme.`).join("\n"));
await p.waitForTimeout(3500);
const position = () => p.locator("[data-note-position]").innerText();
@@ -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 {
+27 -1
View File
@@ -46,7 +46,7 @@ function readZip(bytes) {
}
const openExportDialog = async (page) => {
await page.getByTitle("Export").click();
await page.getByTitle("Export", { exact: true }).click();
await page.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await page.waitForTimeout(800);
};
@@ -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));
},
};
}
+20 -4
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 r = await fetch("/api/notes", {
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
});
return (await r.json())[0].id;
const mine = async () => {
const r = await fetch("/api/notes", {
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
});
return (await r.json()).find((n) => n.title === "Mit Verlauf");
};
for (let tries = 0; tries < 20; tries++) {
const note = await mine();
if (note) return note.id;
await new Promise((r) => setTimeout(r, 500));
}
return null;
});
check(!!id, "the note reached the server", String(id));
/* Replace it with a stub. Shrinking a note by more than half is treated as a
* large deletion and bypasses the 5-minute coalescing window, so a version is
+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());
}
+236 -16
View File
@@ -35,6 +35,18 @@ try {
await p.waitForTimeout(300);
await p.locator(`[data-sort-option="${mode}"]`).click();
await p.waitForTimeout(900);
/* Rows are only draggable while rearranging is switched on — the handles
are asked for rather than standing in every row. The suite drags a lot,
so it turns the mode on with the order it belongs to. */
if (mode === "manual") await startMoving();
};
const startMoving = async () => {
const toggle = p.locator("[data-reorder-toggle]");
if ((await toggle.count()) && (await toggle.getAttribute("data-reorder-toggle")) === "off") {
await toggle.click();
await p.waitForTimeout(500);
}
};
/* ── three notes, made in a known order ── */
@@ -52,6 +64,23 @@ try {
check((await titles()).length === 3, "three notes to arrange", (await titles()).join(", "));
/* ── what a notebook is in before anyone chooses ──
* Its own order. That is where a notebook's arrangement lives: lines show,
* rows can be put where they belong, nothing slides about by itself. It used
* to open in "last changed", so both of those looked broken until the sort
* menu was found — a notebook nobody had arranged still reads newest-first,
* because untouched rows all share position 0. */
check(
(await p.locator("[data-sort-button]").getAttribute("data-sort-button")) === "manual",
"a notebook is in its own order to begin with",
await p.locator("[data-sort-button]").getAttribute("data-sort-button")
);
check(
(await p.locator("[data-add-separator]").count()) === 1,
"so a line can be drawn in it straight away"
);
check((await titles())[0] === "Gamma", "and it reads newest first until something is moved", (await titles()).join(", "));
/* ── the built-in orders ── */
await sortBy("updated");
check((await titles())[0] === "Gamma", "newest first by default", (await titles()).join(", "));
@@ -98,13 +127,23 @@ try {
};
const rows = p.locator("[data-note-row]");
/* A drop lands *under* the row it was released on, and only there — both
directions meant every gap could be aimed at from two sides. */
await drag(rows.nth(2), rows.nth(0));
const under = await titles();
check(
under[1] === beforeDrag[2] && under[0] === beforeDrag[0],
"dropping a note on a row puts it under that row",
`${beforeDrag.join(",")} → ${under.join(",")}`
);
// Which leaves the very top out of reach, so it has a strip of its own.
await drag(rows.nth(2), p.locator("[data-drop-start]"));
const arranged = await titles();
check(
arranged[0] === beforeDrag[2],
"dragging a note to the top puts it there",
`${beforeDrag.join(",")} → ${arranged.join(",")}`
arranged[0] === under[2],
"and the strip above the first row puts it at the very top",
`${under.join(",")} → ${arranged.join(",")}`
);
/* The search box is simply there, with nothing to open first. A magnifying
@@ -186,21 +225,49 @@ try {
await p.waitForTimeout(1800);
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
/* Renaming is right-click, like removing. A left-click on the label used to
start an edit, so the row could not be touched without falling into one. */
const label = p.getByLabel("Separator label");
check((await label.count()) === 0, "clicking a separator does not drop straight into renaming");
/* Everything about a line is set in one dialog reached by right-click, like
every other row here is acted on. A left-click on the label used to start an
edit in place, so the row could not be touched without falling into one. */
check(
(await p.getByLabel("Separator label").count()) === 0,
"clicking a separator does not drop straight into renaming"
);
await p.locator("[data-separator]").click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Rename", { exact: true }).click();
await p.waitForTimeout(500);
await label.fill("Wichtig");
await label.press("Enter");
await p.waitForTimeout(1800);
check(
!/Colour|Line/.test(await p.locator("[data-context-menu], body").first().innerText()) ||
(await p.getByText("Edit", { exact: true }).count()) === 1,
"its menu offers one Edit rather than one entry per property"
);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
/* The dialog draws the line itself, so "dotted" is something you see rather
than a word you have to imagine. */
check((await p.locator("[data-separator-preview]").count()) === 1, "the dialog shows the line as it will be");
check(
(await p.locator("[data-line-option]").count()) === 5,
"with every shape drawn beside its name",
String(await p.locator("[data-line-option]").count())
);
const samples = await p
.locator("[data-line-option] [data-separator-rule]")
.evaluateAll((els) => els.map((el) => getComputedStyle(el).borderTopStyle || "solid"));
check(
new Set(samples).size > 1,
"and the samples really differ from one another",
samples.join(", ")
);
await p.getByLabel("Separator name").fill("Wichtig");
await p.locator('[data-line-option="dashed"]').click();
await p.locator('[data-colour-option="#1f9c96"]').click();
await p.locator("[data-separator-save]").click();
await p.waitForTimeout(2000);
check(
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
"and it can be named from the same menu",
"and it carries the name it was given",
await p.locator("[data-separator]").innerText()
);
@@ -225,10 +292,138 @@ try {
// notes would slide past the line on every edit.
await sortBy("updated");
check((await p.locator("[data-separator]").count()) === 0, "it is hidden in the other orders");
/* And the button that draws one goes with it. Standing in an order that
cannot show a line, pressing it produced nothing at all — which reads
exactly like a button that is broken. */
check(
(await p.locator("[data-add-separator]").count()) === 0,
"and so is the button that draws one, which could only do nothing there"
);
await sortBy("manual");
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
* it near the top and moved everything else: a line's position counts the rows
* above it, so a row appearing without a renumber left every line one row too
* high, and the arrangement came apart a little more with each new note. */
const layout = () =>
p
.locator("[data-note-row], [data-separator]")
.evaluateAll((els) =>
els.map((el) =>
el.hasAttribute("data-separator") ? `── ${el.innerText.trim()}` : el.innerText.split("\n")[0].trim()
)
);
const beforeNew = await layout();
await createNote(p);
await p.waitForTimeout(2500);
const afterNew = await layout();
check(
afterNew[0] === "Untitled Note",
"a new note is made at the very top, above the lines as well",
afterNew.join(" | ")
);
check(
afterNew.slice(1).join(" | ") === beforeNew.join(" | "),
"and every other row stayed exactly where it was",
`${beforeNew.join(" | ")} → ${afterNew.join(" | ")}`
);
// And it survives the trip to the server: the positions were 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 layout()).join(" | ") === afterNew.join(" | "),
"and the arrangement comes back the same after a reload",
(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. */
const stored = (await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Ordnung");
const seps = JSON.parse(stored?.separators || "[]");
check(
seps[0]?.color === "#1f9c96" && seps[0]?.style === "dashed",
"a separator keeps its own colour and line style, on the server",
JSON.stringify(seps)
);
// Drawn, not just stored: the rule is a dashed border rather than a filled bar.
const drawn = await p.locator("[data-separator-rule]").first().evaluate((el) => {
const cs = getComputedStyle(el);
return { style: cs.borderTopStyle, colour: cs.borderTopColor };
});
check(
drawn.style === "dashed" && /31,\s*156,\s*150/.test(drawn.colour),
"and it is drawn that way, not only recorded",
JSON.stringify(drawn)
);
/* Removing is a right-click behind a confirmation. The × used to sit a few
pixels from the label you click to rename, so tidying the list and
destroying part of it were neighbouring gestures. */
@@ -249,7 +444,15 @@ try {
await p.getByRole("button", { name: "Remove" }).click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 0, "and then it is gone");
check((await titles()).length === 3, "with every note still there", (await titles()).join(", "));
check(
(await p.locator("[data-confirm-dialog]").count()) === 0,
"and the question is gone with it, not left standing over the app"
);
check(
(await titles()).join(",") === arrangedRows.filter((row) => !row.startsWith("──")).join(","),
"with every note still there",
(await titles()).join(", ")
);
/* ── exporting a note and importing it back ── */
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click();
@@ -280,7 +483,7 @@ try {
});
check(exported, "downloads can be captured");
await p.getByTitle("Export").click();
await p.getByTitle("Export", { exact: true }).click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(600);
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
@@ -486,7 +689,10 @@ try {
const beforeTouch = await titles();
// The last row onto the first, so the order cannot come out the same by luck.
const rowCount = (await titles()).length;
check((await p.locator("[data-reorder-grip]").count()) >= rowCount, "every row has a grip to take hold of");
check(
(await p.locator("[data-reorder-grip]").count()) >= rowCount,
"with rearranging switched on, every row has a grip to take hold of"
);
await touchDrag(rowCount - 1, 0);
await p.waitForTimeout(2000);
check(
@@ -548,6 +754,20 @@ try {
"and the notebooks it offers are on the screen, not past its edge",
JSON.stringify(await sub.boundingBox())
);
/* It holds two different kinds of place, so it says which is which. The
headings are deliberately not shaped like entries: one built like a menu row
is a button that does nothing when pressed. */
const headings = await sub.locator("[data-menu-heading]").allInnerTexts();
check(
headings.some((h) => /other notebooks/i.test(h)),
"the list is divided under headings rather than run together",
headings.join(" / ")
);
check(
(await sub.locator("[data-menu-heading]").evaluateAll((els) => els.every((el) => el.tagName !== "BUTTON"))),
"and a heading is not a button"
);
await p.keyboard.press("Escape");
await p.waitForTimeout(400);
await p.setViewportSize({ width: 1400, height: 950 });
+123 -2
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);
@@ -336,7 +457,7 @@ try {
const originalBytes = readFileSync(PDF_PATH);
const openExport = async () => {
await p.getByTitle("Export").click();
await p.getByTitle("Export", { exact: true }).click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(1200);
};
@@ -611,7 +732,7 @@ try {
await captureDownloads(p);
await clearDownloads(p);
await p.getByTitle("Export").click();
await p.getByTitle("Export", { exact: true }).click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(800);
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
+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 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());
}
+162
View File
@@ -0,0 +1,162 @@
/* Tables, and what happens to one at the edge of a sheet.
*
* The writing surface is drawn as separate sheets with a gap between them. Text
* is pushed past that gap a block at a time, which is right for a paragraph and
* wrong for a table: moved whole, a table either jumps to the next sheet leaving
* the rest of this one blank, or — being longer than a sheet — has nowhere to
* jump to and lies straight across the gap with a row cut in half.
*
* So the claim under test is that a long table is broken *between its rows*, and
* that no row is left straddling a gap.
*/
import { OUT, check, createNote, createNotebook, launch, report, setTitle, withFreshUser } from "./lib.mjs";
/** Where each sheet sits, and therefore where the gaps are. */
const sheetBands = (page) =>
page.evaluate(() =>
[...document.querySelectorAll("[data-page-sheet]")].map((el) => {
const r = el.getBoundingClientRect();
return { top: r.top, bottom: r.bottom };
})
);
/** Every table row on screen, with the band it occupies. */
const rowBands = (page) =>
page.evaluate(() =>
[...document.querySelectorAll(".ProseMirror table tr:not(.ne-page-spacer-row)")].map((el) => {
const r = el.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, text: el.textContent.trim().slice(0, 12) };
})
);
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "tables");
const p = session.page;
await createNotebook(p, "Tabellen");
await p.locator("button").filter({ hasText: "Tabellen" }).first().click();
await p.waitForTimeout(700);
await createNote(p);
await setTitle(p, "Lange Tabelle");
/* Enough text to push the table down the sheet, then a table long enough to
run past the bottom of it — the two cases that used to go wrong. */
await p.locator(".ProseMirror").click();
for (let i = 0; i < 12; i++) {
await p.keyboard.type(`Zeile ${i} vor der Tabelle. `);
await p.keyboard.press("Enter");
}
await p.waitForTimeout(500);
await p.locator("[data-table-menu] button").first().click();
await p.waitForTimeout(400);
await p.getByText("Insert 3 × 3 table", { exact: true }).click();
await p.waitForTimeout(1200);
check((await p.locator(".ProseMirror table").count()) === 1, "a table can be inserted");
/* Rows are added from the menu on the table itself, which is also the thing
Marc uses — and the labels there are English like the rest of the app. */
await p.locator(".ProseMirror table td").first().click();
await p.waitForTimeout(300);
for (let i = 0; i < 26; i++) {
await p.keyboard.type(`R${i}`);
await p.locator(".ProseMirror table").click({ button: "right" });
await p.waitForTimeout(220);
await p.getByText("Insert row below", { exact: true }).click();
await p.waitForTimeout(220);
}
await p.waitForTimeout(2500);
check(
(await p.locator("[data-submenu-toggle], [role=menu]").count()) >= 0 &&
!/Kopfzeile|Zellen verbinden|Zelle teilen/.test(await p.locator("body").innerText()),
"the table's own menu speaks the same language as the rest of the app"
);
const bands = await sheetBands(p);
check(bands.length >= 2, "the table runs past the first sheet", `${bands.length} sheets`);
const spacers = await p.locator("tr.ne-page-spacer-row").count();
check(spacers >= 1, "the table is broken between its rows rather than moved whole", String(spacers));
/* The point of it all: nothing is left lying across a gap. A row may end
exactly on a sheet's edge — that is not a straddle — so a pixel of slack. */
const rows = await rowBands(p);
const gaps = bands.slice(0, -1).map((b, i) => ({ from: b.bottom, to: bands[i + 1].top }));
const straddling = rows.filter((r) => gaps.some((g) => r.bottom > g.from + 1 && r.top < g.to - 1));
check(
straddling.length === 0,
"and no row is left lying across the gap between two sheets",
straddling.map((r) => r.text).join(", ")
);
await p.screenshot({ path: `${OUT}/tables-split.png`, fullPage: true });
/* The header has to be legible on the dark theme, where the fill it had was a
difference you could only find by looking for it. */
// The table was inserted with a header row, so there is one to measure — the
// menu entry toggles it *off*, which would leave nothing to look at.
const header = await p.locator(".ProseMirror table th").first().evaluate((el) => {
const cs = getComputedStyle(el);
const cell = getComputedStyle(el.closest("table").querySelector("td") || el);
return { head: cs.backgroundColor, body: cell.backgroundColor, rule: cs.borderBottomWidth };
});
check(
header.head !== header.body && parseFloat(header.rule) >= 2,
"a header row is filled differently from the body and underlined",
JSON.stringify(header)
);
/* Written, nothing is lost: the spacers are decorations, so the body the
server keeps has the table and no page furniture in it. */
await p.waitForTimeout(2000);
const body = await p.evaluate(async () => {
const auth = { Authorization: "Bearer " + localStorage.getItem("inknote_token") };
const notes = await (await fetch("/api/notes", { headers: auth })).json();
const mine = notes.find((n) => n.title === "Lange Tabelle");
return (await (await fetch(`/api/notes/${mine.id}`, { headers: auth })).json()).content;
});
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 {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}
+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; }
+534 -95
View File
@@ -7,12 +7,15 @@ 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 { DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
import { CHAPTER_COLORS, DEFAULT_FOLDERS, SEPARATOR_STYLES, coverBackground } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
import { NameDialog } from "./ui/NameDialog.jsx";
import { ChapterDialog } from "./ui/ChapterDialog.jsx";
import { SeparatorDialog } from "./ui/SeparatorDialog.jsx";
import { IconPicker } from "./ui/IconPicker.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
import { AccountMenu } from "./ui/AccountMenu.jsx";
@@ -23,10 +26,10 @@ import { NotebookPicker, rememberNotebook } from "./ui/NotebookPicker.jsx";
import { ImportDialog } from "./ui/ImportDialog.jsx";
import { Sidebar } from "./ui/Sidebar.jsx";
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
import { NoteList } from "./features/notes/NoteList.jsx";
import { ChapterNotes, NoteList } from "./features/notes/NoteList.jsx";
import {
DEFAULT_NOTEBOOK_SORT,
DEFAULT_SORT,
canReorder,
sortModesFor,
sortNotes,
separatorsFor,
@@ -48,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.
*
@@ -56,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");
@@ -67,6 +120,20 @@ export default function App() {
notebook itself. A chapter is a folder inside a notebook, so this is a
second step of "where the user is", not a second notebook. */
const [activeChapter, setActiveChapter] = useState(null);
/* Whether that chapter is the place being worked in.
*
* Open beside or below, a chapter does not replace the notebook's list — both
* are on screen at once, and "which of the two am I in" is then a real
* 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);
@@ -83,43 +150,73 @@ export default function App() {
store a choice — "All Notes" is not a thing that can carry a setting — so it
lasts for the session and no longer. */
const [virtualSort, setVirtualSort] = useState({});
// Which separator is being renamed. Held here because the context menu that
// starts it lives here too.
const [renamingSeparator, setRenamingSeparator] = useState(null);
// The separator being edited, if any. Held here because the menu that opens
// the dialog lives here too.
const [editingSeparator, setEditingSeparator] = useState(null);
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
// { title, value, onSave } while a chapter is being named or renamed.
const [namingChapter, setNamingChapter] = useState(null);
/* { title, value, colour, onSave } while a mark is being chosen for a note. */
const [pickingIcon, setPickingIcon] = useState(null);
/* The chapter being made or changed: `{}` for a new one, the chapter itself
for an existing one. */
const [editingChapter, setEditingChapter] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(null);
// Bumped on a version restore to force a fresh editor on the restored body.
const [restoreEpoch, setRestoreEpoch] = useState(0);
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.
const noteFont = user?.preferences?.noteFont || "serif";
/* How a chapter opens: a further column beside the list, or unfolded under
its own row. A phone has room for neither, so there it fills the screen —
which is what "column" already did before there was a choice. */
const chapterView = user?.preferences?.chapterView || "column";
const inlineChapters = chapterView === "inline" && !isMobile;
const columnChapters = chapterView === "column" && !isMobile;
const data = useNoteData(user, activeNote);
const { notes, notebooks, noteContents, backlinks } = data;
const search = useSearchIndex(user, notes, searchQuery);
const dnd = useNotebookDnd(data.reorderNotebooks);
/* Notes reorder through their own list, which is the only place that knows
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
hook: they are dragged with a mouse, in the sidebar and on the dashboard. */
const rearrangeNotes = useCallback(
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
[data, activeFolder, activeChapter]
);
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();
@@ -160,6 +257,7 @@ export default function App() {
(id) => {
setActiveFolder(id);
setActiveChapter(null);
setInChapter(false);
setActiveNote(null);
if (isMobile) {
setSidebarOpen(false);
@@ -205,6 +303,40 @@ export default function App() {
}));
}, [data.chapters, activeFolder, notes]);
const openChapter = folderChapters.find((c) => c.id === activeChapter) || null;
/* Which list the middle column is *showing*.
*
* Only the fallback replaces it: beside or below, the notebook's own list
* carries on and the chapter is a second thing on screen. Told apart from
* `activeChapter` after a separator drawn in a notebook vanished the moment a
* chapter was opened — the list still showed the notebook, but everything
* around it had switched to the chapter, so the notebook's own separators
* were filtered out and a drag in that list wrote nothing at all. */
const listChapter = inlineChapters || columnChapters ? null : activeChapter;
const listIsChapter = !!listChapter;
/* Where a new note is made. In the fallback the list is the chapter, so there
is nothing to choose; beside or below it follows the click. */
const targetChapter = listChapter ?? (inChapter ? activeChapter : null);
/* Both places are on screen at once, and only then is it worth saying which
one is meant. With no chapter open there is nothing to tell apart. */
const showsWhere = !!openChapter && !listIsChapter;
/* Notes reorder through their own list, which is the only place that knows
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
hook: they are dragged with a mouse, in the sidebar and on the dashboard.
Scoped to the list on screen, not to the chapter that happens to be open
beside it — otherwise a drag among the notebook's own rows is measured
against the chapter's notes, finds nothing, and does nothing. */
const rearrangeNotes = useCallback(
(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";
@@ -212,7 +344,7 @@ export default function App() {
/* A real notebook remembers its order on the server; a view keeps one for the
session. Either way the note list is told a single mode. */
const rawSortMode = activeNotebook
? activeNotebook.sortMode || DEFAULT_SORT
? activeNotebook.sortMode || DEFAULT_NOTEBOOK_SORT
: virtualSort[activeFolder] || DEFAULT_SORT;
// A view has nowhere to keep a hand-made arrangement, so it cannot be in one —
// even if an older version of the app left "manual" behind on a notebook that
@@ -234,20 +366,53 @@ export default function App() {
/* Only the ones drawn in the list being looked at — the notebook itself, or
the chapter that is open. */
const separators = useMemo(
() => separatorsFor(allSeparators, activeChapter),
[allSeparators, activeChapter]
() => separatorsFor(allSeparators, listChapter),
[allSeparators, listChapter]
);
const writeSeparators = (next) =>
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
/* Written back whole: the notebook stores one list, and the ones belonging to
the other chapters have to survive a change made in this one. */
const writeVisible = (visible) =>
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== activeChapter), ...visible]);
const writeIn = (chapterId, visible) =>
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== chapterId), ...visible]);
const writeVisible = (visible) => writeIn(listChapter, visible);
/* One press draws the line; it is named afterwards from its own menu, like it
is renamed. A dialog in front of it asked a question about something that
reads perfectly well unnamed. */
const addSeparator = () =>
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter));
const renameSeparator = (id, label) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", listChapter));
const editSeparator = (id, changes) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, ...changes } : s)));
/**
* Dropping a row onto a chapter puts it *in* the chapter.
*
* A note is filed there; a line drawn in the notebook is moved into it, which
* is also how a chapter gets a line of its own — there is no second button for
* that, because a second way to make the same thing in a second place is a
* second thing to keep straight.
*/
const fileIntoChapter = useCallback(
(dragId, chapterId) => {
const note = notes.find((n) => n.id === dragId);
if (note) {
void data.moveNote(dragId, activeFolder, chapterId);
return;
}
const separator = allSeparators.find((sep) => sep.id === dragId);
if (separator) {
writeSeparators(
allSeparators.map((sep) => (sep.id === dragId ? { ...sep, chapterId, order: 0 } : sep))
);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[notes, allSeparators, activeFolder, data]
);
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
const separatorContextMenu = (e, separator) => {
@@ -257,7 +422,10 @@ export default function App() {
x: e.clientX,
y: e.clientY,
items: [
{ label: "Rename", icon: I.edit, action: () => setRenamingSeparator(separator.id) },
/* One entry, like every other row here has. It used to be three —
rename, colour, line — three shapes of interaction for one small
thing, none of them showing what the result would look like. */
{ label: "Edit", icon: I.edit, action: () => setEditingSeparator(separator) },
{ divider: true },
{
label: "Remove separator",
@@ -279,17 +447,40 @@ 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;
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
else if (activeChapter) list = activeNotes.filter((n) => n.chapterId === activeChapter);
else if (listChapter) list = activeNotes.filter((n) => n.chapterId === listChapter);
/* In the notebook itself, a note filed into a chapter is *in* that chapter
and not loose here — otherwise it would appear twice and moving it would
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.
@@ -301,8 +492,45 @@ export default function App() {
const q = searchQuery.toLowerCase();
return list.filter((n) => n.title.toLowerCase().includes(q));
}
return sortNotes(list, sortMode);
}, [activeFolder, activeChapter, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
return sortNotes(list, mode);
}, [
activeFolder,
listChapter,
searchQuery,
search.results,
activeNotes,
trashedNotes,
sortMode,
chapterSort,
]);
/* 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), 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. */
const chapterSeparators = useMemo(
() => separatorsFor(allSeparators, activeChapter),
[allSeparators, activeChapter]
);
/* Opening a notebook opens its first note with it.
*
* On a wide screen the editor is already there, and leaving it on "Select a
* note to edit" wastes two thirds of the window and a click on the thing you
* almost always want. On a phone the list *is* the screen, so opening a note
* would replace it and hide the notebook you just asked to see. */
useEffect(() => {
if (isMobile || activeNote || isTrashView) return;
if (activeFolder === "dashboard" || isSearching) return;
const first = filteredNotes[0];
if (first) setActiveNote(first.id);
}, [isMobile, activeNote, isTrashView, activeFolder, activeChapter, isSearching, filteredNotes]);
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
// The editor opens as soon as the metadata is known; the body fills in when
@@ -326,7 +554,7 @@ export default function App() {
...notes
.filter((n) => !n.trashed && n.id !== activeNote && match(n.title || "Untitled"))
.slice(0, 8)
.map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note" })),
.map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note", icon: n.icon || null })),
...notebooks
.filter((nb) => match(nb.name))
.slice(0, 4)
@@ -358,9 +586,16 @@ export default function App() {
[notes, openFolder, openNote]
);
/* The mark a link should wear: the target's own, so a reference is recognised
by the same sign as the note it points at. */
const resolveLinkIcon = useCallback(
(id) => notes.find((n) => n.id === id && !n.trashed)?.icon ?? null,
[notes]
);
const linkProps = useMemo(
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }),
[openLinkTarget, searchLinkTargets, resolveLinkLabel]
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel, resolveLinkIcon }),
[openLinkTarget, searchLinkTargets, resolveLinkLabel, resolveLinkIcon]
);
/* ── Actions ── */
@@ -441,21 +676,35 @@ export default function App() {
/* A note made while a chapter is open belongs to that chapter — but only
if it is a chapter of the notebook being written into, which it is not
when the notebook had to be asked for. */
const chapterId = folderId === activeFolder ? activeChapter : null;
const chapterId = folderId === activeFolder ? targetChapter : null;
const note = await data.createNote(folderId, chapterId);
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
openNote(note.id);
});
/** Runs a destructive action behind a confirmation prompt. */
const confirm = (dialog) =>
setConfirmDialog({
...dialog,
onConfirm: async () => {
await dialog.onConfirm();
setConfirmDialog(null);
},
/* A note's own mark. Written through its own call rather than with the title:
the title write is debounced behind typing, and a mark chosen with one press
would then arrive late or not at all. */
const pickNoteIcon = () => {
if (!noteMeta) return;
setPickingIcon({
title: "Icon for this note",
value: noteMeta.icon ?? null,
colour: noteMeta.iconColor ?? null,
onSave: (icon, colour) => data.setNoteIcon(noteMeta.id, icon, colour),
});
};
/**
* Runs a destructive action behind a confirmation prompt.
*
* Only sets the question. Closing it again is done where the dialog is
* rendered, so it happens whoever asked and however they asked: this used to
* be wrapped here instead, and the one caller that set the dialog directly —
* removing a chapter — did its work and then left the prompt standing over
* the app with nothing but a reload to get out of.
*/
const confirm = (dialog) => setConfirmDialog(dialog);
const trashNote = (id) =>
confirm({
@@ -551,26 +800,49 @@ export default function App() {
*/
const moveSubmenu = (note) => {
const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId);
const targets = [
...(note.chapterId
? [{ label: "The notebook itself", action: () => data.moveNote(note.id, note.folderId, null) }]
: []),
...here.map((c) => ({
label: c.name,
icon: I.books,
action: () => data.moveNote(note.id, note.folderId, c.id),
})),
...(here.length || note.chapterId ? [{ divider: true }] : []),
...notebooks
.filter((nb) => nb.id !== note.folderId)
.map((nb) => ({
const home = notebooks.find((nb) => nb.id === note.folderId);
const elsewhere = notebooks.filter((nb) => nb.id !== note.folderId);
const targets = [];
/* The notebook the note is already in, when it is sitting in one of that
notebook's chapters — coming back out is a move like any other, and it
wears the notebook's own cover so it is read as a place. */
if (note.chapterId && home) {
targets.push({
label: home.name,
colorDot: coverBackground(home.cover, home.coverPattern),
action: () => data.moveNote(note.id, home.id, null),
});
}
/* Grouped and named, because the list holds two different kinds of place and
used to run them together with a rule between: a chapter of this notebook
is one step, another notebook is a different one. Each row carries the
mark its chapter or notebook was given, so it is found the way it is found
everywhere else. */
if (here.length) {
targets.push({ heading: "Chapters here" });
targets.push(
...here.map((c) => ({
label: c.name,
icon: c.icon || I.books,
iconColor: c.color || undefined,
action: () => data.moveNote(note.id, note.folderId, c.id),
}))
);
}
if (elsewhere.length) {
targets.push({ heading: "Other notebooks" });
targets.push(
...elsewhere.map((nb) => ({
label: nb.name,
colorDot: coverBackground(nb.cover, nb.coverPattern),
action: () => data.moveNote(note.id, nb.id, null),
})),
];
// A lone divider is not a menu.
const usable = targets.filter((t) => !t.divider);
}))
);
}
// Headings alone are not a menu.
const usable = targets.filter((t) => !t.divider && !t.heading);
return usable.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
};
@@ -578,11 +850,25 @@ export default function App() {
* Folders inside a notebook. Named on creation, because a chapter with no name
* is a row nobody can tell from the next one — unlike a separator, which is a
* line and reads perfectly well unnamed. */
const addChapter = () => {
if (!activeNotebook) return;
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
/**
* Opens a chapter, or closes the one already open.
*
* Opening it also makes it the place being worked in, which is what pressing
* a chapter means; clicking back into the notebook's list undoes that without
* closing the chapter, so both stay readable side by side.
*/
const openChapterView = (id) => {
const next = activeChapter === id ? null : id;
setActiveChapter(next);
setInChapter(!!next);
};
/* Named, marked and coloured in one go, and the same dialog does both making
and changing. A chapter's whole identity is those three things; asked one at
a time from three menu entries, getting a new chapter to look right took
three visits. */
const addChapter = () => activeNotebook && setEditingChapter({});
const chapterContextMenu = (e, chapter) => {
e.preventDefault();
e.stopPropagation();
@@ -590,32 +876,26 @@ export default function App() {
x: e.clientX,
y: e.clientY,
items: [
{
label: "Rename",
icon: I.edit,
action: () =>
setNamingChapter({
title: "Rename chapter",
value: chapter.name,
onSave: (name) => data.renameChapter(chapter.id, name),
}),
},
{ label: "Edit", icon: I.edit, action: () => setEditingChapter(chapter) },
{ divider: true },
{
label: "Remove chapter",
icon: I.trash,
danger: true,
action: () =>
setConfirmDialog({
confirm({
title: "Remove chapter?",
message: `"${chapter.name}" is removed. Its ${chapter.noteCount} note${
chapter.noteCount === 1 ? "" : "s"
} stay — they go back to the notebook.`,
confirmLabel: "Remove",
danger: true,
onConfirm: () => {
if (activeChapter === chapter.id) setActiveChapter(null);
void data.deleteChapter(chapter.id);
onConfirm: async () => {
if (activeChapter === chapter.id) {
setActiveChapter(null);
setInChapter(false);
}
await data.deleteChapter(chapter.id);
},
}),
},
@@ -663,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,
@@ -674,9 +954,55 @@ 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,
so it reads as "inside this" rather than as more of the same list. */
/* What a chapter holds, wherever it is shown. The column beside the list and
this unfolded block are the same list in two frames, so they draw it with
the same component — a chapter divisible in one and not the other would be
two different things under one name. */
const chapterContents = (compact) => (
<ChapterNotes
T={T}
chapterId={activeChapter}
notes={chapterNotes}
separators={chapterSeparators}
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}
onNoteContextMenu={noteContextMenu}
compact={compact}
/>
);
const renderChapterNotes = (chapter) => (
<div
data-chapter-notes={chapter.id}
data-chapter-current={inChapter ? "on" : "off"}
/* Pressing inside the unfolded chapter says "I am in here" — and must not
then travel on to the list around it, which says the opposite. */
onPointerDown={(e) => {
e.stopPropagation();
setInChapter(true);
}}
style={{
margin: "2px 0 8px 22px",
borderLeft: `2px solid ${inChapter ? chapter.color || T.accent : `${chapter.color || T.accent}55`}`,
paddingLeft: 8,
}}
>
{chapterContents(true)}
</div>
);
const showList = !(isMobile && mobileView === "editor" && activeNote);
const showEditor = !isMobile || mobileView === "editor";
@@ -684,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,
@@ -709,8 +1035,8 @@ export default function App() {
<Sidebar
T={T}
user={user}
dark={dark}
onToggleDark={() => setDark(!dark)}
isMobile={isMobile}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
@@ -764,19 +1090,30 @@ 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}
renamingSeparator={renamingSeparator}
onStartRenameSeparator={setRenamingSeparator}
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
chapters={folderChapters}
openChapter={openChapter}
onOpenChapter={setActiveChapter}
/* Only in the fallback: there the list becomes the chapter, and
its header is the way back. Beside or below, the notebook's own
list carries on and keeps its chapter rows. */
openChapter={listIsChapter ? openChapter : null}
onOpenChapter={openChapterView}
onAddChapter={activeNotebook ? addChapter : null}
onChapterContextMenu={chapterContextMenu}
expandedChapter={inlineChapters ? activeChapter : null}
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
onReorder={rearrangeNotes}
onFileInto={fileIntoChapter}
listKey={listKey}
isCurrent={showsWhere ? !inChapter : null}
onFocusList={showsWhere ? () => setInChapter(false) : undefined}
newNotePlace={
targetChapter ? folderChapters.find((c) => c.id === targetChapter)?.name : folderName
}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
@@ -786,6 +1123,76 @@ export default function App() {
onEmptyTrash={emptyTrash}
/>
)}
{/* The chapter's own column, beside the list — the way OneNote lays
out its pages. Only where there is room: on a phone the chapter
fills the screen instead, with a way back. */}
{columnChapters && openChapter && (
<div
data-chapter-column
data-chapter-current={inChapter ? "on" : "off"}
onPointerDown={() => setInChapter(true)}
style={{
width: 268,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderRight: `1px solid ${T.borderLight}`,
background: T.panel || "transparent",
// The same line the note list wears when it is the one in hand.
boxShadow: inChapter ? `inset 0 2px 0 ${T.accent}` : undefined,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "14px 14px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
<span style={{ display: "flex", color: openChapter.color || T.accent, flexShrink: 0 }}>
{I.books}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 14,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{openChapter.name}
</span>
<button
onClick={() => {
setActiveChapter(null);
setInChapter(false);
}}
title="Close chapter"
aria-label="Close chapter"
data-chapter-close
style={{
border: "none",
background: "transparent",
color: T.muted,
cursor: "pointer",
display: "flex",
padding: 0,
}}
>
{I.x}
</button>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
{chapterContents(false)}
</div>
</div>
)}
{showEditor && (
<EditorPane
T={T}
@@ -799,6 +1206,7 @@ export default function App() {
linkProps={linkProps}
onBack={closeNote}
onUpdate={(u) => data.updateNote(activeNote, u)}
onPickIcon={pickNoteIcon}
onToggleStar={() => data.toggleStar(activeNote)}
onTrash={() => trashNote(activeNote)}
onRestore={() => restoreNote(activeNote)}
@@ -900,14 +1308,39 @@ export default function App() {
)}
/>
)}
{namingChapter && (
<NameDialog
title={namingChapter.title}
value={namingChapter.value}
label="Chapter name"
confirmLabel={namingChapter.value ? "Rename" : "Create"}
onSave={namingChapter.onSave}
onClose={() => setNamingChapter(null)}
{editingChapter && (
<ChapterDialog
chapter={editingChapter}
onSave={(fields) =>
editingChapter.id
? data.editChapter(editingChapter.id, fields)
: data.createChapter(
activeNotebook.id,
fields.name,
fields.color,
fields.icon,
fields.sortMode
)
}
onClose={() => setEditingChapter(null)}
dark={dark}
/>
)}
{editingSeparator && (
<SeparatorDialog
separator={editingSeparator}
onSave={(changes) => editSeparator(editingSeparator.id, changes)}
onClose={() => setEditingSeparator(null)}
dark={dark}
/>
)}
{pickingIcon && (
<IconPicker
title={pickingIcon.title}
value={pickingIcon.value}
colour={pickingIcon.colour}
onSave={pickingIcon.onSave}
onClose={() => setPickingIcon(null)}
dark={dark}
/>
)}
@@ -916,7 +1349,13 @@ export default function App() {
title={confirmDialog.title}
message={confirmDialog.message}
confirmLabel={confirmDialog.confirmLabel}
onConfirm={confirmDialog.onConfirm}
/* The one place the question is taken down, whatever it asked and
whoever set it. Leaving that to each caller left one of them
holding the screen hostage after doing its work. */
onConfirm={async () => {
await confirmDialog.onConfirm?.();
setConfirmDialog(null);
}}
onCancel={() => setConfirmDialog(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"))}
+92 -15
View File
@@ -21,6 +21,15 @@
--ne-accent: #0969da;
--ne-surface: #ffffff;
--ne-subtle: #f6f8fa;
/* A table's header row, which needs to read as one at a glance. Its own
tokens because "slightly off the page colour" is enough for a code block
sitting in the middle of text and not enough for a row that has to be told
from the rows under it. */
--ne-th-bg: #eaeef2;
--ne-th-rule: #afb8c1;
/* One line of body text. Named because the task list needs to know it: a
checkbox is centred against a line, and a line is this tall. */
--ne-line: 1.7;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
@@ -30,6 +39,10 @@
--ne-accent: #388bfd;
--ne-surface: #0d1117;
--ne-subtle: #161b22;
/* Dark is where this mattered: #161b22 on a #0d1117 sheet is a difference you
have to look for, so a header row read as just another row. */
--ne-th-bg: #262c36;
--ne-th-rule: #6a7480;
}
.note-editor .ProseMirror {
@@ -53,8 +66,8 @@
/* 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;
line-height: 1.7;
font-size: var(--ne-size, 15px);
line-height: var(--ne-line);
padding: 20px 24px;
min-height: 100%;
word-break: break-word;
@@ -113,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);
@@ -167,6 +199,20 @@
pointer-events: none;
}
/* The spacer that carries a table across the gap between two sheets. It is a
row, so it inherits every cell rule below — and has to shed all of them, or
the gap shows a tall bordered box. */
.note-editor .ProseMirror tr.ne-page-spacer-row,
.note-editor .ProseMirror tr.ne-page-spacer-row td {
border: none !important;
padding: 0 !important;
background: transparent !important;
}
.note-editor .ProseMirror tr.ne-page-spacer-row td::before,
.note-editor .ProseMirror tr.ne-page-spacer-row td::after {
content: none !important;
}
/* ── Tables ── */
.note-editor .ProseMirror table {
border-collapse: collapse;
@@ -184,9 +230,12 @@
min-width: 1em;
}
.note-editor .ProseMirror th {
background: var(--ne-subtle);
font-weight: 600;
background: var(--ne-th-bg);
font-weight: 700;
text-align: left;
/* The rule under the header does most of the work — the fill alone cannot be
made much stronger without the header turning into a banner. */
border-bottom: 2px solid var(--ne-th-rule);
}
.note-editor .ProseMirror .selectedCell::after {
content: "";
@@ -195,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;
@@ -219,18 +280,34 @@
align-items: flex-start;
gap: 8px;
}
/* The box is centred on the *letters* of the first line beside it — not on that
line's box, and not at a fixed nudge from the top.
`margin-top: 0.35em` was measured once against one font at one size. Centring
in the full line was the next attempt and is still not it: a line box is
taller than the letters in it by the depth of a descender, so its middle sits
below the middle of a capital. Measured, that left the box's top level with
the capitals and its bottom 4px past the baseline — a square hanging off the
line. Two tenths of an em off the line's height is where the capitals'
middle actually is, for every face on offer here. */
.note-editor .ProseMirror ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-top: 0.35em;
display: flex;
align-items: center;
height: calc(1em * var(--ne-line) - 0.3em);
user-select: none;
}
.note-editor .ProseMirror ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
/* Sized in em, so the box stays the size of a capital whatever the note's type
size is. At 15px it was a 15px square against a 10px letter, which read as a
box the text was standing next to rather than part of the same line. */
.note-editor .ProseMirror ul[data-type="taskList"] input[type="checkbox"] {
accent-color: var(--ne-accent);
width: 15px;
height: 15px;
width: 0.9em;
height: 0.9em;
margin: 0;
cursor: pointer;
}
.note-editor .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div {
+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 }),
];
}
+21 -4
View File
@@ -50,6 +50,15 @@ export const NoteLink = Node.create({
},
// Set when the target no longer exists, so a dead link reads as broken
// instead of silently going nowhere.
/* The target's own mark, kept on the link and refreshed like the label is.
* A reference that shows the same sheet-of-paper glyph whatever it points
* at tells you nothing; the mark you gave the note is how you recognise
* it everywhere else. */
icon: {
default: null,
parseHTML: (el) => el.getAttribute("data-icon") || null,
renderHTML: (a) => (a.icon ? { "data-icon": a.icon } : {}),
},
missing: {
default: false,
parseHTML: (el) => el.getAttribute("data-missing") === "true",
@@ -71,7 +80,7 @@ export const NoteLink = Node.create({
href: "#",
title: node.attrs.label,
}),
`${{ notebook: "📓", pin: "📌" }[node.attrs.kind] || "📝"} ${node.attrs.label}`,
`${node.attrs.icon || { notebook: "📓", pin: "📌" }[node.attrs.kind] || "📝"} ${node.attrs.label}`,
];
},
@@ -95,7 +104,12 @@ export const NoteLink = Node.create({
.insertContentAt(range, [
{
type: "noteLink",
attrs: { noteId: props.id, label: props.label, kind: props.kind || "note" },
attrs: {
noteId: props.id,
label: props.label,
kind: props.kind || "note",
icon: props.icon || null,
},
},
{ type: "text", text: " " },
])
@@ -131,7 +145,7 @@ function renderSuggestionList() {
it,
i
) => `<button type="button" class="note-link-option${i === selected ? " is-selected" : ""}" data-index="${i}">
<span class="note-link-kind">${it.kind === "notebook" ? "📓" : "📝"}</span>
<span class="note-link-kind">${it.icon || (it.kind === "notebook" ? "📓" : "📝")}</span>
<span class="note-link-label"></span>
</button>`
)
@@ -148,7 +162,10 @@ function renderSuggestionList() {
const pick = (i) => {
const item = items[i];
if (item && command) command({ id: item.id, label: item.label, kind: item.kind });
// The mark travels with the choice. Dropped here, every new link was born
// wearing the default sheet of paper — the menu showed the note's own mark
// and the link that came out of it did not.
if (item && command) command({ id: item.id, label: item.label, kind: item.kind, icon: item.icon || null });
};
const place = (rect) => {
+141 -16
View File
@@ -17,14 +17,24 @@ import { Decoration, DecorationSet } from "@tiptap/pm/view";
* note is still one body.
*
* The unit is a whole block. A paragraph that would be split across the gap
* moves down entire, which is what a page break means on paper. A single block
* taller than a page — a long table, a big image — is left where it is and runs
* over the gap: splitting one would mean laying out its insides ourselves.
* moves down entire, which is what a page break means on paper.
*
* A **table** is the exception, and has to be: moved whole it either jumps to
* the next page leaving half a sheet blank, or — being taller than a page — has
* nowhere to jump to and lies across the gap regardless. So a table is broken
* between its rows, which is where a table breaks on paper too. The spacer there
* is a row of its own rather than a block: a `div` inside a `tbody` is not a
* thing, and a `tr` with one borderless cell pushes the rows after it down
* exactly as needed.
*
* Everything else too tall to fit anywhere — a full-page image — is left where
* it is: moving it down only puts the same problem one page further on.
*/
export const paginationKey = new PluginKey("pagination");
const SPACER_CLASS = "ne-page-spacer";
const SPACER_ROW_CLASS = "ne-page-spacer-row";
const px = (value) => {
const n = parseFloat(value);
@@ -55,9 +65,69 @@ function makeSpacer(height, delta) {
* Returns `null` when there is nothing to measure against — no geometry yet, or
* a page too short to hold anything.
*/
/**
* The spacer inside a table.
*
* A row, because that is what a table's children are: anything else is either
* dropped by the browser or wrapped in an anonymous cell of its own. The cell
* carries the height and is stripped of the borders every other cell has, so the
* gap between two sheets stays empty.
*/
function makeSpacerRow(height, delta, colspan) {
const tr = document.createElement("tr");
tr.className = SPACER_ROW_CLASS;
tr.setAttribute("aria-hidden", "true");
tr.contentEditable = "false";
tr.dataset.delta = String(delta);
const td = document.createElement("td");
td.colSpan = Math.max(1, colspan || 1);
td.style.height = `${Math.max(0, Math.round(height))}px`;
tr.appendChild(td);
return tr;
}
/** The rows of a table, with the document position of each. */
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;
const rows = [];
let inner = 0;
let index = 0;
// Positions are walked rather than looked up: a row's place in the document is
// the table's own position plus the size of every row before it.
let pos = 0;
for (const el of body.children) {
if (el.classList?.contains(SPACER_ROW_CLASS)) {
inner += Number(el.dataset.delta) || 0;
continue;
}
if (index >= node.childCount) break;
const rect = el.getBoundingClientRect();
rows.push({
index,
pos,
top: (rect.top - domTop) / scale - seenBefore - inner,
height: rect.height / scale,
});
pos += node.child(index).nodeSize;
index++;
}
const columns = table.querySelector(":scope > colgroup")?.children.length || 1;
return { rows, inner, columns };
}
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;
@@ -71,9 +141,9 @@ function computeBreaks(view, geometry) {
const domTop = dom.getBoundingClientRect().top;
// Top-level document positions, in the order they are rendered.
const positions = [];
view.state.doc.forEach((_node, offset) => positions.push(offset));
// Top-level nodes and their positions, in the order they are rendered.
const nodes = [];
view.state.doc.forEach((node, offset) => nodes.push({ node, offset }));
/* Each block's position **as if no spacer existed**. A spacer shifts
* everything after it by exactly its recorded delta, so subtracting the
@@ -87,16 +157,33 @@ function computeBreaks(view, geometry) {
seen += Number(child.dataset.delta) || 0;
continue;
}
if (index >= positions.length) break;
if (index >= nodes.length) break;
const { node, offset } = nodes[index];
const rect = child.getBoundingClientRect();
const cs = getComputedStyle(child);
blocks.push({
pos: positions[index],
top: rect.top - domTop - seen,
height: rect.height,
const block = {
pos: offset,
top: (rect.top - domTop) / scale - seen,
height: rect.height / scale,
marginTop: px(cs.marginTop),
marginBottom: px(cs.marginBottom),
});
};
/* A table is measured row by row, since that is where it can be broken.
* Its own spacer rows are subtracted as we go, and then added to `seen`:
* 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, scale);
if (measured) {
block.rows = measured.rows.map((r) => ({ ...r, pos: offset + 1 + r.pos }));
block.columns = measured.columns;
block.height = rect.height / scale - measured.inner;
seen += measured.inner;
}
}
blocks.push(block);
index++;
}
@@ -105,6 +192,40 @@ function computeBreaks(view, geometry) {
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const top = block.top + shift;
/* ── a table breaks between its rows ──
* The row that would straddle the gap starts the next page instead, and the
* rows above it stay where they are. Moving the table whole either wastes
* the rest of the sheet or, for a table longer than a page, cannot help at
* all — which is why a long one used to lie straight across the gap. */
if (block.rows?.length) {
let inner = 0;
for (const row of block.rows) {
// `shift` is what the blocks above pushed down; `inner` is what the
// breaks already placed inside this table added on top of that.
const rowTop = row.top + shift + inner;
const rowPage = Math.floor(rowTop / pitch);
const rowEnd = rowPage * pitch + pageHeight - padBottom;
if (rowTop + row.height <= rowEnd + 0.5) continue;
// A row taller than a whole page cannot be helped by moving it.
if (row.height > usable) continue;
const delta = (rowPage + 1) * pitch + padTop - rowTop;
if (delta <= 0) continue;
/* The first row is the table's own top edge: pushing *inside* the table
* there would leave its border hanging above the break, so the whole
* table moves, exactly as any other block would. */
if (row.index === 0) {
if (i === 0) continue;
breaks.push({ pos: block.pos, delta, height: Math.max(0, delta - Math.min(blocks[i - 1].marginBottom, block.marginTop)) });
} else {
breaks.push({ pos: row.pos, delta, height: delta, row: true, columns: block.columns });
}
inner += delta;
}
shift += inner;
continue;
}
// Taller than a page however it is placed — moving it down would only put
// the same problem on the next page, one page further from the top.
if (block.height > usable) continue;
@@ -133,20 +254,24 @@ const sameBreaks = (a, b) =>
!!a &&
!!b &&
a.length === b.length &&
a.every((x, i) => x.pos === b[i].pos && Math.abs(x.height - b[i].height) < 1);
a.every((x, i) => x.pos === b[i].pos && !!x.row === !!b[i].row && Math.abs(x.height - b[i].height) < 1);
const decorationsFor = (doc, breaks) =>
DecorationSet.create(
doc,
breaks.map((b) =>
Decoration.widget(b.pos, () => makeSpacer(b.height, b.delta), {
Decoration.widget(
b.pos,
() => (b.row ? makeSpacerRow(b.height, b.delta, b.columns) : makeSpacer(b.height, b.delta)),
{
// Height in the key, so a spacer that has to change size is rebuilt
// rather than reused at its old height.
key: `page-break-${b.pos}-${Math.round(b.height)}`,
key: `page-break-${b.row ? "row" : "block"}-${b.pos}-${Math.round(b.height)}`,
side: -1,
ignoreSelection: true,
marks: [],
})
}
)
)
);
+3 -3
View File
@@ -38,13 +38,13 @@ export function tableMenuItems(editor) {
{ label: "Insert column left", action: run((c) => c.addColumnBefore()) },
{ label: "Insert column right", action: run((c) => c.addColumnAfter()) },
{ divider: true },
{ label: "Kopfzeile an/aus", action: run((c) => c.toggleHeaderRow()) },
{ label: "Header row on/off", action: run((c) => c.toggleHeaderRow()) },
];
// mergeCells and splitCell are mutually exclusive in practice: one needs a
// multi-cell selection, the other a cell that was merged earlier.
if (can("mergeCells")) items.push({ label: "Zellen verbinden", action: run((c) => c.mergeCells()) });
if (can("splitCell")) items.push({ label: "Zelle teilen", action: run((c) => c.splitCell()) });
if (can("mergeCells")) items.push({ label: "Merge cells", action: run((c) => c.mergeCells()) });
if (can("splitCell")) items.push({ label: "Split cell", action: run((c) => c.splitCell()) });
items.push(
{ divider: true },
+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) }),
};
},
});
+81 -20
View File
@@ -123,7 +123,7 @@ export function useNoteEditor({
}
/** The editable page itself. */
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel }) {
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.
*
@@ -133,23 +133,61 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
* node — the link survived on screen and vanished on the next reload. */
useEffect(() => {
if (!editor || editor.isDestroyed || !resolveLinkLabel) return;
const edits = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== "noteLink") return;
const label = resolveLinkLabel(node.attrs.noteId);
const missing = label === null;
const nextLabel = missing ? node.attrs.label : label;
if (nextLabel !== node.attrs.label || missing !== !!node.attrs.missing) {
edits.push([pos, { ...node.attrs, label: nextLabel, missing }]);
}
});
if (edits.length === 0) return;
const tr = editor.state.tr;
for (const [pos, attrs] of edits) tr.setNodeMarkup(pos, undefined, attrs);
tr.setMeta(LABEL_REFRESH, true);
tr.setMeta("addToHistory", false); // undo should not step through renames
editor.view.dispatch(tr);
}, [editor, resolveLinkLabel]);
const refresh = () => {
if (editor.isDestroyed) return;
const edits = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== "noteLink") return;
const label = resolveLinkLabel(node.attrs.noteId);
const missing = label === null;
const nextLabel = missing ? node.attrs.label : label;
// The mark travels the same way, and for the same reason: it belongs to
// the note, so it has to follow when the note's changes.
const icon = (resolveLinkIcon ? resolveLinkIcon(node.attrs.noteId) : node.attrs.icon) ?? null;
if (
nextLabel !== node.attrs.label ||
missing !== !!node.attrs.missing ||
icon !== (node.attrs.icon ?? null)
) {
edits.push([pos, { ...node.attrs, label: nextLabel, missing, icon }]);
}
});
if (edits.length === 0) return;
const tr = editor.state.tr;
for (const [pos, attrs] of edits) tr.setNodeMarkup(pos, undefined, attrs);
tr.setMeta(LABEL_REFRESH, true);
tr.setMeta("addToHistory", false); // undo should not step through renames
editor.view.dispatch(tr);
};
refresh();
/* And again whenever the document changes.
*
* Running once was not enough, and the gap was the ordinary case: this
* effect runs while the editor is still empty, and the note's content is
* poured in afterwards — deliberately without an update event, so the
* editor is not marked dirty by its own loading. Every link therefore kept
* whatever it was saved with, which is why a reference always showed the
* sheet of paper however its target was marked.
*
* Deferred by a frame rather than dispatched from inside the handler:
* ProseMirror is still finishing the transaction that brought us here. */
let frame = 0;
const schedule = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
refresh();
});
};
editor.on("transaction", schedule);
return () => {
if (frame) cancelAnimationFrame(frame);
editor.off("transaction", schedule);
};
}, [editor, resolveLinkLabel, resolveLinkIcon]);
const handleClick = useCallback(
(e) => {
@@ -169,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 (
@@ -180,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>
);
}
+75 -18
View File
@@ -146,6 +146,7 @@ export function EditorPane({
linkProps,
onBack,
onUpdate,
onPickIcon,
onToggleStar,
onTrash,
onRestore,
@@ -187,28 +188,84 @@ 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}`,
}}
>
{isMobile && <BackButton T={T} onClick={onBack} />}
<input
type="text"
aria-label="Note title"
value={note.title}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{
flex: 1,
border: "none",
background: "transparent",
outline: "none",
fontSize: isMobile ? 15 : 17,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
minWidth: 0,
}}
/>
{/* The mark and the name are one thing, so they sit in one group with a
small gap of their own. Left as separate children of the header they
were spaced like the buttons at the far end — the mark read as a
control that happened to be parked beside the title. */}
{/* Aligned on the baseline, not on the box.
Centring the two boxes looked right on paper and wrong on screen: a
title's box is taller than its letters by the depth of a descender,
so its capitals sit above the middle — while an emoji's picture fills
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: 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 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: 20,
height: 26,
lineHeight: "26px",
flexShrink: 0,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
padding: 0,
fontSize: 16,
color: note.iconColor || T.faint,
}}
>
{note.icon || (
/* A drawn glyph has no baseline of its own — the browser sets its
bottom edge on the line, which leaves it floating above the
letters. A single pixel below puts its middle on theirs. */
<span style={{ display: "inline-block", verticalAlign: "-1px" }}>{I.note}</span>
)}
</button>
<input
type="text"
aria-label="Note title"
value={note.title}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{
flex: 1,
border: "none",
background: "transparent",
outline: "none",
fontSize: isMobile ? 15 : 17,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
minWidth: 0,
// Matches the box beside it, so the two sit on one line rather
// than each finding its own centre.
lineHeight: "26px",
padding: 0,
}}
/>
</div>
<button onClick={onExport} title="Export" style={toolbarButton(T, T.faint)}>
{I.export}
</button>
+3 -1
View File
@@ -2,12 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import * as sync from "../../syncEngine";
import { VersionPreview } from "../../editor/VersionPreview.jsx";
import { I } from "../../ui/icons.jsx";
import { useBackdropClose } from "../../ui/backdrop";
/* ═══════ VERSION HISTORY ═══════ */
/* Past states of a note, newest first. The server keeps a snapshot of what a
* note looked like before each editing session, so an overwrite that is only
* noticed later can still be undone. */
export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, renderDrawing }) {
const backdrop = useBackdropClose(onClose);
const [versions, setVersions] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
@@ -110,7 +112,7 @@ export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, ren
zIndex: 2000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
onClick={(e) => e.stopPropagation()}
+134 -40
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";
@@ -37,6 +62,7 @@ export function NoteEditor({
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
resolveLinkIcon,
backlinks,
}) {
const [mode, setMode] = useState("text");
@@ -84,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) => {
@@ -180,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;
@@ -236,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
@@ -243,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 }));
};
@@ -265,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;
@@ -789,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",
}}
>
@@ -799,25 +841,59 @@ export function NoteEditor({
something instead of floating in the middle of an empty pane.
Behind the text and deaf to the pointer: it is what is written
on, not part of what is written. */}
{Array.from({ length: canvasPages }, (_, i) => (
<div
key={i}
aria-hidden
data-page-sheet={i + 1}
style={{
position: "absolute",
left: 0,
right: 0,
top: i * pitch,
height: stageSize.h,
pointerEvents: "none",
background: T.bg,
border: isMobileViewport ? "none" : `1px solid ${T.border}`,
borderRadius: isMobileViewport ? 0 : 4,
boxShadow: isMobileViewport ? "none" : "0 2px 10px rgba(0,0,0,0.22)",
}}
/>
))}
{Array.from({ length: canvasPages }, (_, i) => {
/* The last sheet in Draw mode is the spare one, not a page.
Drawn as an outline rather than as paper: it appeared as a full
second page the moment Draw was pressed, while the count still
said "of 1" — which reads as the app inventing a page. It is an
offer, and should look like one until something is on it. */
const spare = mode === "draw" && i + 1 > pages.count;
return (
<div
key={i}
aria-hidden
data-page-sheet={i + 1}
data-spare-page={spare ? "yes" : undefined}
style={{
position: "absolute",
left: 0,
right: 0,
top: i * pitch,
height: stageSize.h,
pointerEvents: "none",
background: spare ? "transparent" : T.bg,
/* 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: 1,
borderColor: T.border,
borderRadius: isMobileViewport && !spare ? 0 : 4,
boxShadow: isMobileViewport || spare ? "none" : "0 2px 10px rgba(0,0,0,0.22)",
opacity: spare ? 0.5 : 1,
}}
>
{spare && (
<div
style={{
position: "absolute",
top: 12,
left: 0,
right: 0,
textAlign: "center",
fontSize: 11,
letterSpacing: 0.5,
color: T.faint,
}}
>
Draw here to add a page
</div>
)}
</div>
);
})}
<div
style={{
position: "relative",
@@ -827,14 +903,32 @@ export function NoteEditor({
pointerEvents: mode === "text" ? "auto" : "none",
}}
>
<EditorSurface
editor={textEditor}
dark={dark}
font={noteFont}
minHeight={stageSize.h}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
/>
{/* 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={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 && (
+406 -143
View File
@@ -3,8 +3,9 @@ import { useEffect, useRef, useState } from "react";
import { I } from "../../ui/icons.jsx";
import { fmtDate } from "../../ui/format";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
import { END_ID, SORT_LABELS, mergeEntries } from "./noteSort";
import { END_ID, SORT_LABELS, START_ID, mergeEntries } from "./noteSort";
import { useReorderDrag } from "../../hooks/useReorderDrag";
import { Rule } from "../../ui/separatorStyle.jsx";
const iconButton = (T) => ({
display: "flex",
@@ -19,8 +20,9 @@ const iconButton = (T) => ({
color: T.muted,
});
/** A note rendered outside the manual order, wrapped to look like an entry. */
/** A row rendered outside the manual order, wrapped to look like an entry. */
const asNoteEntry = (note) => ({ kind: "note", id: note.id, note });
const asChapterEntry = (chapter) => ({ kind: "chapter", id: chapter.id, chapter });
/**
* Where the dragged row will land.
@@ -41,9 +43,11 @@ const DropLine = ({ T }) => (
*
* A mouse can drag a row from anywhere, and did. A finger cannot: pressing the
* row has to keep scrolling the list, and holding it has to keep opening the
* menu. So touch gets its own target, wide enough to hit and only there while a
* hand-made arrangement is in effect — which is the only time moving a row means
* anything.
* menu. So touch gets its own target, wide enough to hit.
*
* Only there while rearranging is switched on. Standing in every row all the
* time it was six dots of clutter beside every title, and a row could be pulled
* out of place by a press that was meant to open it.
*/
const Grip = ({ T, ...rest }) => (
<div
@@ -67,31 +71,28 @@ const Grip = ({ T, ...rest }) => (
</div>
);
/** Width the separator's label keeps in both states, so renaming moves nothing. */
const MIDDLE_W = 132;
/** Narrowest the label's box gets, so an unnamed line still has something to hit. */
const MIDDLE_MIN = 54;
/** Widest, so a long name cannot squeeze the rules away to nothing. */
const MIDDLE_MAX = 170;
/**
* A line drawn between notes, with an optional name.
*
* Draggable like a note, because a divider that cannot move with what it
* divides is worse than none.
* Draggable like a note, because a divider that cannot move with what it divides
* is worse than none.
*
* Both renaming and removing are right-click, which is how everything else in
* this list is acted on. A left-click on the label started renaming, so the row
* could not be clicked — to select it, to drag from it — without falling into an
* edit; and the × for removal sat a few pixels from that same target.
* Everything about it — its name, its colour, the shape of its rule — is changed
* in one dialog reached by right-click, like every other row here is acted on.
* It used to be three menu entries and an input that appeared in the row itself:
* three shapes of interaction for one small thing, and none of them showing what
* the result would look like.
*/
function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu, grip }) {
const [draft, setDraft] = useState(separator.label);
useEffect(() => {
if (editing) setDraft(separator.label);
}, [editing, separator.label]);
const commit = () => {
onStartRename(null);
if (draft !== separator.label) onRename(separator.id, draft);
};
function SeparatorRow({ T, separator, onContextMenu, grip }) {
// Its own colour when it has been given one; the list's faint grey otherwise,
// which is what every separator looked like before there was a choice.
const colour = separator.color || T.faint;
return (
<div
@@ -100,76 +101,41 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
style={{
display: "flex",
alignItems: "center",
gap: 8,
/* Room above it, so it reads as the end of what came before rather than
as decoration attached to the next note. */
padding: "14px 8px 8px",
cursor: "grab",
gap: 7,
/* A little room above, so it reads as the end of what came before rather
than as decoration attached to the next note — but only a little: it
is a line between two rows, not a heading of its own. */
padding: "9px 8px 5px",
cursor: grip ? "grab" : "default",
}}
>
{grip}
{/* The two rules always share what is left over equally, and the middle
keeps one width whether it is showing a label or an input. Sizing it to
its contents made the whole row jump sideways the moment renaming
started, which is exactly when the eye is on it. */}
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
<div style={{ flex: "0 0 auto", width: MIDDLE_W, display: "flex" }}>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setDraft(separator.label);
onStartRename(null);
}
}}
aria-label="Separator label"
placeholder="Section"
style={{
width: "100%",
minWidth: 0,
border: `1px solid ${T.border}`,
borderRadius: 6,
background: "transparent",
color: T.text,
fontSize: 11.5,
fontWeight: 700,
letterSpacing: 0.8,
textAlign: "center",
// Matches the button's box exactly, so nothing shifts vertically
// either when the input replaces it.
padding: "1px 4px",
outline: "none",
}}
/>
) : (
<div
title="Right-click for options"
style={{
width: "100%",
border: "1px solid transparent",
background: "transparent",
color: separator.label ? T.text : T.muted,
fontSize: 11.5,
fontWeight: 700,
letterSpacing: 0.8,
textTransform: "uppercase",
textAlign: "center",
padding: "1px 4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{separator.label || "Unnamed"}
</div>
)}
{/* The two rules always share what is left over equally. */}
<Rule colour={colour} style={separator.style} />
{/* Sized to the name it holds, between a floor and a ceiling. A fixed slot
kept the row from jumping when renaming began — which it no longer can,
since renaming happens in a dialog — at the cost of a short name
floating in the middle of a gap it did not fill. */}
<div
title="Right-click to edit"
style={{
flex: "0 1 auto",
minWidth: MIDDLE_MIN,
maxWidth: MIDDLE_MAX,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
color: separator.label ? separator.color || T.text : T.muted,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{separator.label || "Unnamed"}
</div>
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
<Rule colour={colour} style={separator.style} />
</div>
);
}
@@ -181,7 +147,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
* place, and the notes are what is in this one. The count is what makes it worth
* opening — a row saying only "Invoices" tells you nothing about whether to.
*/
function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
return (
<button
data-chapter-row={chapter.id}
@@ -191,17 +157,32 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
display: "flex",
alignItems: "center",
gap: 9,
width: "100%",
flex: 1,
minWidth: 0,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${T.borderLight}`,
background: "transparent",
border: `1px solid ${open ? T.accent + "55" : T.borderLight}`,
background: open ? T.accentSoft : "transparent",
cursor: "pointer",
marginBottom: 4,
textAlign: "left",
}}
>
<span style={{ display: "flex", color: T.accent, flexShrink: 0 }}>{I.books}</span>
{/* Its own colour, so a shelf of chapters can be told apart at a glance
— the point of having them is that they are different places. */}
<span
data-chapter-mark
style={{
display: "flex",
alignItems: "center",
color: chapter.color || T.accent,
flexShrink: 0,
fontSize: chapter.icon ? 15 : undefined,
lineHeight: 1,
}}
>
{chapter.icon || I.books}
</span>
<span
style={{
flex: 1,
@@ -217,11 +198,154 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
{chapter.name}
</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{chapter.noteCount}</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>›</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{open ? "▾" : "›"}</span>
</button>
);
}
/**
* What is inside a chapter: its notes, with its own lines drawn between them.
*
* One component for both places a chapter can be shown — the column beside the
* list and the unfolded block under its row — because they are the same list in
* two frames, and a chapter that could be divided in one and not the other would
* be two different things wearing one name.
*
* A chapter's separators are its own. Each carries the list it was drawn in, so
* the notebook's lines stay in the notebook and these stay here.
*/
export function ChapterNotes({
T,
chapterId,
notes,
separators,
reorderable,
moving = false,
onReorder,
activeNote,
onSeparatorContextMenu,
onSelectNote,
onNoteContextMenu,
compact = false,
}) {
// 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" ? (
<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={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
/>
{moving && dnd.overId === entry.id && <DropLine T={T} />}
</div>
) : (
<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)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: compact ? "8px 10px" : "9px 10px",
borderRadius: compact ? 8 : 9,
border: `1px solid ${activeNote === entry.id ? T.accent + "55" : "transparent"}`,
background: activeNote === entry.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span
data-note-mark
style={{
display: "flex",
alignItems: "center",
color: entry.note.iconColor || T.faint,
flexShrink: 0,
fontSize: entry.note.icon ? 14 : undefined,
lineHeight: 1,
}}
>
{entry.note.icon || (entry.note.type === "pdf" ? I.pdf : I.note)}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entry.note.title}
</span>
</button>
</div>
{moving && dnd.overId === entry.id && <DropLine T={T} />}
</div>
)
)}
{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. */}
</>
);
}
/**
* How this notebook's notes are ordered.
*
@@ -309,7 +433,7 @@ function SortMenu({ T, mode, modes, onChange, disabled }) {
* putting something new in here — and they had grown into three buttons side by
* side, two of which were icons you had to hover to identify.
*/
function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
function AddMenu({ T, place, onCreateNote, onImportPdf, onImportMarkdown }) {
const [open, setOpen] = useState(false);
const box = useRef(null);
@@ -354,7 +478,8 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
<div style={{ position: "relative" }} ref={box}>
<button
onClick={() => setOpen((v) => !v)}
title="New Note"
// Names the place, so it is clear before pressing where the note lands.
title={place ? `New note in ${place}` : "New Note"}
data-add-menu={open ? "on" : "off"}
style={{
display: "flex",
@@ -396,6 +521,32 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
);
}
/**
* Switches rearranging on.
*
* Rows are dragged rarely and read constantly, so the handles are not there by
* default: the list stays a list, and taking hold of a row is something asked
* for. While it is on, and only then, every row shows its grip and can be
* carried; pressing a row does not open it by accident.
*/
const ReorderToggle = ({ T, on, onToggle }) => (
<button
onClick={onToggle}
data-reorder-toggle={on ? "on" : "off"}
title={on ? "Done rearranging" : "Rearrange the rows"}
aria-label="Rearrange the rows"
aria-pressed={on}
style={{
...iconButton(T),
color: on ? "#fff" : T.muted,
background: on ? T.accent : "transparent",
borderColor: on ? T.accent : T.border,
}}
>
{on ? I.check : I.grip}
</button>
);
/** The middle column: every note in the selected folder, in the chosen order. */
export function NoteList({
T,
@@ -412,18 +563,30 @@ 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,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
chapters,
openChapter,
onOpenChapter,
onAddChapter,
onChapterContextMenu,
expandedChapter,
renderChapterNotes,
onReorder,
/** Drops a carried row *into* a chapter rather than beside it. */
onFileInto,
/** Identifies the list on screen, so rearranging does not stay on across a move to another. */
listKey,
/** Whether this pane is the one being worked in; null when there is nothing to tell apart. */
isCurrent,
onFocusList,
/** The place a new note would land in, named on the + button. */
newNotePlace,
onToggleSidebar,
onSelectNote,
onNoteContextMenu,
@@ -432,18 +595,34 @@ export function NoteList({
onImportMarkdown,
onEmptyTrash,
}) {
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
changed at all, with nothing on screen to say why. */
const dnd = useReorderDrag(!!reorderable, onReorder);
const dnd = useReorderDrag(moving, onReorder, onFileInto);
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
or the trash, which are views over notebooks rather than a place to file
anything, and not inside a chapter, because a chapter holds notes. */
const canAddChapter = !!onAddChapter && !openChapter;
/* `openChapter` means this list *is* the chapter — the fallback where there is
no room beside or below, so the chapter fills the list and the header offers
a way back. In the other two views the chapter rows stay where they are. */
const shownChapters = openChapter ? [] : chapters || [];
/* Every row this list shows, in the order it shows them.
*
* Separators are part of the manual arrangement and only mean something
* there: in "newest first" the notes would slide past a line on every edit.
* They are not shown in the other orders, and are still in place on the way
* back. */
const entries = reorderable
? mergeEntries(notes, separators, shownChapters)
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)];
return (
<div
data-list-current={isCurrent === null || isCurrent === undefined ? undefined : isCurrent ? "on" : "off"}
style={{
width: isMobile ? "100%" : 300,
minWidth: isMobile ? 0 : 300,
@@ -453,6 +632,8 @@ export function NoteList({
flexDirection: "column",
background: T.bg,
fontFamily: FONT_UI,
// A line along the top of whichever pane is being worked in.
boxShadow: isCurrent ? `inset 0 2px 0 ${T.accent}` : undefined,
}}
>
<div
@@ -503,10 +684,17 @@ export function NoteList({
</h2>
</div>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
{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 && (
<AddMenu
T={T}
place={newNotePlace}
onCreateNote={onCreateNote}
onImportPdf={onImportPdf}
onImportMarkdown={onImportMarkdown}
@@ -575,12 +763,20 @@ export function NoteList({
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 6,
padding: "5px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
{/* Only while it is on, and then it says what to do rather than
explaining the order the sort menu already names. */}
{moving && (
<span style={{ flex: 1, minWidth: 0, fontSize: 11, color: T.accent, fontWeight: 600 }}>
Drag the rows into place
</span>
)}
{/* A chapter divides the notebook; a separator divides a list. They
sit together because both are "add something that is not a note",
and apart from the + button, which makes notes. */}
@@ -592,7 +788,7 @@ export function NoteList({
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 10px",
padding: "5px 10px",
borderRadius: 8,
border: `1px solid ${T.border}`,
background: "transparent",
@@ -605,6 +801,11 @@ export function NoteList({
+ Chapter
</button>
)}
{/* Only where a line can be seen. A separator belongs to the hand-made
order and is hidden in every other one, so the button stood in
orders where pressing it produced nothing at all — which reads
exactly like a button that is broken. */}
{reorderable && (
<button
onClick={onAddSeparator}
data-add-separator
@@ -614,19 +815,34 @@ export function NoteList({
background: "transparent",
color: T.muted,
borderRadius: 8,
padding: "2px 8px",
fontSize: 10.5,
padding: "5px 10px",
fontSize: 11.5,
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
+ Separator
</button>
)}
</div>
)}
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
{notes.length === 0 ? (
{/* Pressing in the list itself — on a note or on the empty space below
them — says "I am in the notebook", and a new note goes there.
Deliberately not the whole pane: the + button sits in the header, and
taking the focus back on the way to pressing it would mean a note
could never be made in the open chapter at all. */}
<div
data-note-list-body
onPointerDown={onFocusList}
style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}
>
{/* "Nothing here" means nothing at all — not "no loose notes".
Asked of the notes alone, a notebook whose only contents were
chapters showed an empty list with the chapters hidden behind it,
and a chapter made in an empty notebook appeared to do nothing. */}
{entries.length === 0 ? (
<div
style={{
display: "flex",
@@ -669,46 +885,73 @@ export function NoteList({
</div>
) : (
<>
{/* Separators are part of the manual arrangement and only mean
something there: in "newest first" the notes would slide past a
line on every edit. They are not shown in the other orders, and
are still in place on the way back. */}
{shownChapters.map((chapter) => (
<ChapterRow
key={chapter.id}
T={T}
chapter={chapter}
onOpen={onOpenChapter}
onContextMenu={onChapterContextMenu}
/>
))}
{shownChapters.length > 0 && notes.length > 0 && (
<div style={{ height: 1, background: T.borderLight, margin: "8px 4px 10px" }} />
{/* The one place a drop under a row cannot reach. Only while the
list is being rearranged, so it costs nothing the rest of the
time. */}
{moving && (
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 22 }}>
{dnd.overId === START_ID && <DropLine T={T} />}
</div>
)}
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
const showDropLine = reorderable && dnd.overId === entry.id;
/* The whole row, indicator included, is the drop target.
*
* Putting these on the inner element left the indicator itself as
* a dead strip: it pushes the row down as it appears, so the
* pointer ended up over the line rather than over the row, and
* releasing there dropped on nothing. */
const rowProps = reorderable ? dnd.rowProps(entry.id) : {};
{entries.map((entry) => {
/* The line is drawn *under* the row, because that is where the
row being carried is about to land. One direction only: a gap
that can be aimed at from both sides is a gap nobody can aim
at, and the two ends of the list have strips of their own.
The whole wrapper, line included, is the drop target. On the
inner element the line itself was a dead strip — it grows as it
appears, so the pointer could end up over the line rather than
over any row, and releasing there dropped on nothing. */
const showDropLine = moving && dnd.overId === entry.id;
const rowProps = moving ? dnd.rowProps(entry.id) : {};
const lifted = dnd.draggingId === entry.id;
if (entry.kind === "chapter") {
const taking = dnd.intoId === entry.id;
return (
<div
key={entry.id}
{...(moving ? dnd.rowProps(entry.id, true) : {})}
data-chapter-drop={taking ? "on" : undefined}
style={{
opacity: lifted ? 0.45 : 1,
/* Held over, the whole row lights up rather than growing a
line above it: a chapter is about to take what is
carried, not stand next to it. */
outline: taking ? `2px solid ${T.accent}` : "none",
outlineOffset: -1,
borderRadius: 10,
}}
>
{/* The header alone is the chapter for the purpose of a
drop — an open one carries its notes inside this row. */}
<div data-reorder-head style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{moving && <Grip T={T} {...dnd.gripProps} />}
<ChapterRow
T={T}
chapter={entry.chapter}
open={expandedChapter === entry.id}
onOpen={onOpenChapter}
onContextMenu={onChapterContextMenu}
/>
</div>
{expandedChapter === entry.id && renderChapterNotes?.(entry.chapter)}
{showDropLine && <DropLine T={T} />}
</div>
);
}
if (entry.kind === "separator") {
return (
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<SeparatorRow
grip={<Grip T={T} {...dnd.gripProps} />}
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
/>
{showDropLine && <DropLine T={T} />}
</div>
);
}
@@ -717,9 +960,8 @@ export function NoteList({
const snippet = snippetFor(note.id);
return (
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{reorderable && <Grip T={T} {...dnd.gripProps} />}
{moving && <Grip T={T} {...dnd.gripProps} />}
<button
data-note-row={note.id}
onClick={() => onSelectNote(note.id)}
@@ -733,7 +975,7 @@ export function NoteList({
borderRadius: 10,
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
background: activeNote === note.id ? T.accentSoft : "transparent",
cursor: reorderable ? "grab" : "pointer",
cursor: moving ? "grab" : "pointer",
marginBottom: 2,
textAlign: "left",
}}
@@ -747,8 +989,21 @@ export function NoteList({
width: "100%",
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{note.trashed ? I.trash : note.type === "pdf" ? I.pdf : I.note}
{/* Its own mark when it has one, otherwise the kind of
thing it is. The same glyph the editor shows, so a note
is recognised by the same sign in both places. */}
<span
data-note-mark
style={{
display: "flex",
alignItems: "center",
color: note.iconColor || T.faint,
flexShrink: 0,
fontSize: note.icon ? 14 : undefined,
lineHeight: 1,
}}
>
{note.trashed ? I.trash : note.icon || (note.type === "pdf" ? I.pdf : I.note)}
</span>
<span
data-note-title
@@ -789,12 +1044,20 @@ export function NoteList({
</div>
</button>
</div>
{showDropLine && <DropLine T={T} />}
</div>
);
})}
{/* Dropping below the last row moves something to the end, which
otherwise needs an awkward drop onto the very last note. */}
{reorderable && (
{/* Said out loud, so a notebook holding only chapters does not read
as a list that failed to load. */}
{notes.length === 0 && !isTrashView && (
<div data-no-loose-notes style={{ padding: "12px 12px 4px", fontSize: 12, color: T.muted }}>
No notes outside the chapters yet.
</div>
)}
{moving && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 56 }}>
{dnd.overId === END_ID && <DropLine T={T} />}
</div>
+127 -5
View File
@@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest";
import {
END_ID,
START_ID,
canReorder,
mergeEntries,
placeOnTop,
reindex,
reindexEntries,
sortModesFor,
sortNotes,
withNewSeparator,
withoutRow,
} from "./noteSort";
import type { NoteMeta } from "../../types";
@@ -89,8 +92,10 @@ describe("reindex", () => {
note("d", { sortOrder: 3 }),
];
it("moves a note onto the position of the one it was dropped on", () => {
expect(reindex(all, "d", "b")).toEqual(["a", "d", "b", "c"]);
/* Under, always under. Both directions meant every gap could be aimed at from
two sides, which is what made a drag hard to predict. */
it("moves a note under the one it was dropped on", () => {
expect(reindex(all, "d", "b")).toEqual(["a", "b", "d", "c"]);
});
it("works upwards as well as downwards", () => {
@@ -198,18 +203,25 @@ describe("reindexEntries", () => {
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })];
const separators = [{ id: "s1", label: "Teil 2", order: 1 }];
it("moves a note across a separator and keeps the separator where it was", () => {
it("moves a note across a separator and leaves the line above the same note", () => {
// a │ ─── │ b │ c, and c is dropped under a. The line still sits above b;
// its number counts the rows before it, and there is one more of those now.
const next = reindexEntries(notes, separators, "c", "a")!;
expect(next.noteIds).toEqual(["c", "a", "b"]);
expect(next.noteIds).toEqual(["a", "c", "b"]);
expect(next.separators[0]!.order).toBe(2);
});
it("moves a separator itself", () => {
const next = reindexEntries(notes, separators, "s1", "a")!;
const next = reindexEntries(notes, separators, "s1", START_ID)!;
expect(next.separators[0]!.order).toBe(0);
expect(next.noteIds).toEqual(["a", "b", "c"]);
});
it("sends a row to the top when dropped on the strip above the first one", () => {
// The one place a drop under a row cannot reach.
expect(reindexEntries(notes, [], "c", START_ID)!.noteIds).toEqual(["c", "a", "b"]);
});
it("sends a row to the end when dropped below the last one", () => {
expect(reindexEntries(notes, [], "a", END_ID)!.noteIds).toEqual(["b", "c", "a"]);
});
@@ -224,3 +236,113 @@ describe("reindexEntries", () => {
expect(next.separators).toEqual([{ id: "s1", label: "x", order: 0 }]);
});
});
describe("placeOnTop", () => {
/* A notebook someone has arranged: two notes with a line between them, and a
chapter above both. The numbers are what a drag left behind. */
const notes = [note("a", { sortOrder: 1 }), note("b", { sortOrder: 2 })];
const chapters = [{ id: "ch", notebookId: "nb", name: "Kapitel", sortOrder: 0 }] as never;
const separators = [{ id: "s1", label: "Teil 2", order: 2 }];
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
const fresh = [note("neu", { sortOrder: 0 }), ...notes];
const arranged = placeOnTop(fresh, separators, chapters, "neu")!;
it("puts the new note above everything, lines and chapters included", () => {
expect(arranged.notes[0]).toEqual({ id: "neu", order: 0 });
expect(arranged.chapters).toEqual([{ id: "ch", order: 1 }]);
});
it("leaves every other row in the order it was in", () => {
expect(arranged.noteIds).toEqual(["neu", "a", "b"]);
});
/* The whole point. A separator's number counts the rows above it, so a row
appearing at the top without a renumber leaves every line one row too high
— which is how a notebook came apart a little more with each new note. */
it("moves the lines down with the rows they sat above", () => {
expect(arranged.separators[0]!.order).toBe(3);
// Drawn again from exactly what was written back.
const after = [note("neu", { sortOrder: 0 }), note("a", { sortOrder: 2 }), note("b", { sortOrder: 3 })];
const movedChapters = [{ id: "ch", notebookId: "nb", name: "Kapitel", sortOrder: 1 }] as never;
expect(kinds(mergeEntries(after, arranged.separators, movedChapters))).toEqual([
"n:neu",
"c:ch",
"n:a",
"s:s1",
"n:b",
]);
});
it("reports nothing to do for a note that is not in the list", () => {
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);
});
});
+181 -35
View File
@@ -12,8 +12,25 @@
* scramble everything that was not being looked at.
*/
import type { NoteMeta, Separator, SortMode } from "../../types";
import type { Chapter, NoteMeta, Separator, SortMode } from "../../types";
/**
* What a notebook is in until it is told otherwise.
*
* The hand-made order, because that is where a notebook's own arrangement lives:
* separators show, rows can be dragged into place, and nothing slides about on
* its own. A notebook that has never been arranged loses nothing by it —
* untouched rows all carry position 0 and fall back to newest first, which is
* what the old default showed.
*/
export const DEFAULT_NOTEBOOK_SORT: SortMode = "manual";
/**
* What a *view* is in — "All Notes", "Starred", the bin.
*
* Not the hand-made one: those are questions asked of every notebook at once, so
* there is nothing for an arrangement to belong to and nowhere to keep it.
*/
export const DEFAULT_SORT: SortMode = "updated";
export const SORT_LABELS: Record<SortMode, string> = {
@@ -86,6 +103,7 @@ export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): Not
*/
export type Entry =
| { kind: "note"; id: string; order: number; note: NoteMeta }
| { kind: "chapter"; id: string; order: number; chapter: Chapter }
| { kind: "separator"; id: string; order: number; separator: Separator };
/**
@@ -94,51 +112,99 @@ export type Entry =
* A separator sitting at the same position as a note comes first, so "insert a
* line above this note" lands where it was asked for rather than one row late.
*/
export function mergeEntries(notes: NoteMeta[], separators: Separator[] = []): Entry[] {
/* Notes are placed by their rank, not by their stored number. Notes that have
* never been dragged all carry 0, and sorting has already broken that tie —
* using the stored numbers here would pile them all at position 0 and push
* every separator behind the lot. */
const noteEntries: Entry[] = sortNotes(notes, "manual").map((note, rank) => ({
kind: "note" as const,
id: note.id,
order: rank,
note,
}));
export function mergeEntries(
notes: NoteMeta[],
separators: Separator[] = [],
chapters: Chapter[] = []
): Entry[] {
/* Notes and chapters share one sequence.
*
* A chapter is a row in the list like a note is, and can be dragged among
* them, so they cannot each be numbered from zero — they are ranked together
* by the position each carries.
*
* Ranked, not placed by the stored number: rows that have never been dragged
* all carry 0, and using the raw numbers would pile them at position 0 and
* push every separator behind the lot. */
const ranked = sortNotes(notes, "manual");
const rows = [
...chapters.map((chapter, i) => ({
kind: "chapter" as const,
id: chapter.id,
chapter,
sort: chapter.sortOrder ?? 0,
// Keeps the order within one kind settled when several share a position,
// which every row does until it has been dragged once.
tie: i,
})),
...ranked.map((note, i) => ({ kind: "note" as const, id: note.id, note, sort: note.sortOrder ?? 0, tie: i })),
].sort(
(a, b) =>
a.sort - b.sort ||
// A chapter shares a position with a note only before either has been
// moved; putting chapters first there matches where they are created.
(a.kind === b.kind ? a.tie - b.tie : a.kind === "chapter" ? -1 : 1)
);
const rowEntries: Entry[] = rows.map((row, rank) =>
row.kind === "chapter"
? { kind: "chapter", id: row.id, order: rank, chapter: row.chapter }
: { kind: "note", id: row.id, order: rank, note: row.note }
);
const separatorEntries: Entry[] = separators.map((separator) => ({
kind: "separator" as const,
id: separator.id,
// Clamped, so a separator left over from a notebook that has since shrunk
// still appears — at the end — rather than vanishing with its label.
order: Math.max(0, Math.min(separator.order, noteEntries.length)),
order: Math.max(0, Math.min(separator.order, rowEntries.length)),
separator,
}));
return [...noteEntries, ...separatorEntries].sort(
// A separator sitting at the same position as a note comes first, so
// "a line above this note" lands where it was asked for.
(a, b) => a.order - b.order || (a.kind === "separator" ? -1 : 1)
);
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.
return a.kind === "separator" ? -1 : 1;
});
}
/** The result of a drag: what to write back for notes and for separators. */
/** The result of a drag: what to write back for each kind of row. */
export interface Arrangement {
noteIds: string[];
/** Notes in their new order, with the position each should carry. */
notes: Array<{ id: string; order: number }>;
/** Chapters likewise — numbered from the *same* counter as the notes. */
chapters: Array<{ id: string; order: number }>;
separators: Separator[];
/** The note ids alone, for callers that only reorder notes. */
noteIds: string[];
}
/** Turns an ordered entry list back into storable positions. */
export function toArrangement(entries: Entry[]): Arrangement {
const noteIds: string[] = [];
const notes: Array<{ id: string; order: number }> = [];
const chapters: Array<{ id: string; order: number }> = [];
const separators: Separator[] = [];
/* One counter for notes and chapters together. Numbering them separately
* would leave two sequences that cannot be interleaved again, and a chapter
* dragged between two notes would spring back. */
let position = 0;
for (const entry of entries) {
if (entry.kind === "note") noteIds.push(entry.id);
// A separator's position is "how many notes come before me". Expressed that
// way it stays put as the notes around it are renamed, edited or opened.
else separators.push({ ...entry.separator, order: noteIds.length });
if (entry.kind === "note") notes.push({ id: entry.id, order: position++ });
else if (entry.kind === "chapter") chapters.push({ id: entry.id, order: position++ });
// A separator's position is "how many rows come before me". Expressed that
// way it stays put as the rows around it are renamed, edited or opened.
else separators.push({ ...entry.separator, order: position });
}
return { noteIds, separators };
return { notes, chapters, separators, noteIds: notes.map((n) => n.id) };
}
/**
@@ -146,33 +212,113 @@ export function toArrangement(entries: Entry[]): Arrangement {
*
* `notes` must be every note in the notebook, not the filtered view. Returns
* null when the move changes nothing, which spares the caller a pointless write.
*
* 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, and having both meant every gap could be aimed at from
* two sides — which is a hard thing to aim at and a harder thing to read. The
* two strips, one above the first row and one below the last, are how the ends
* are reached.
*/
export function reindexEntries(
notes: NoteMeta[],
separators: Separator[],
dragId: string,
targetId: string
targetId: string,
chapters: Chapter[] = []
): Arrangement | null {
if (dragId === targetId) return null;
const entries = mergeEntries(notes, separators);
const entries = mergeEntries(notes, separators, chapters);
const from = entries.findIndex((e) => e.id === dragId);
if (from < 0) return null;
// The strip below the last row means "put it at the end" — otherwise moving
// something past the final note needs a drop onto the note itself, which puts
// it above.
const to = targetId === END_ID ? entries.length - 1 : entries.findIndex((e) => e.id === targetId);
if (to < 0) return null;
const [moved] = entries.splice(from, 1);
if (!moved) return null;
entries.splice(to, 0, moved);
if (targetId === START_ID) entries.unshift(moved);
else if (targetId === END_ID) entries.push(moved);
else {
// Found after the row has been lifted out, so the index is the one in the
// list the row is being dropped into.
const to = entries.findIndex((e) => e.id === targetId);
if (to < 0) return null;
entries.splice(to + 1, 0, moved);
}
return toArrangement(entries);
}
/**
* The whole list renumbered with one row put at the very top.
*
* For a note that has just been made. It used to be given no position at all and
* left to the tie-break, which put it near the top and moved everything else:
* a separator's position counts the rows above it, so a row appearing at the top
* without anybody being renumbered left every line one row too high — the
* notebook's arrangement came apart a little more with each new note.
*
* Renumbering the list settles that, and settles it for good: rows that were
* never dragged all carried position 0 and were being ordered by how recently
* they changed, so they slid about on their own. Afterwards each keeps the place
* it is in — which is what a hand-made order is for.
*/
export function placeOnTop(
notes: NoteMeta[],
separators: Separator[],
chapters: Chapter[],
ids: string | string[]
): Arrangement | null {
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 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) => !held.has(n.id)),
separators,
chapters
);
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);
}
/** Drop target standing for "after everything". */
export const END_ID = "__end__";
/** And its opposite: the one place a drop under a row cannot reach. */
export const START_ID = "__start__";
/** A new separator, added at the top where it can be seen and then dragged. */
export const withNewSeparator = (
separators: Separator[],
+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
+83 -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.
*
@@ -27,8 +39,10 @@ export function PdfWorkspace({
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
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);
@@ -44,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;
@@ -98,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"}
@@ -123,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
@@ -132,6 +212,7 @@ export function PdfWorkspace({
dark={dark}
onUpdate={onUpdate}
notesToggle={notesToggle}
zoomExtras={fullToggle}
focusPin={focusPin}
onFocusHandled={() => setFocusPin(null)}
/>
@@ -155,6 +236,7 @@ export function PdfWorkspace({
onOpenNote={openTarget}
searchLinkTargets={searchTargets}
resolveLinkLabel={resolveLabel}
resolveLinkIcon={resolveLinkIcon}
/* On a phone the viewer is not on screen at all, so the way back to
the document has to be here. */
onBackToDocument={narrow ? () => setShowNotes(false) : 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]);
}
+293 -94
View File
@@ -3,9 +3,20 @@ 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 { 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 { Backlink, Chapter, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
import type {
Backlink,
Chapter,
NoteContent,
NoteMeta,
Notebook,
Separator,
SortMode,
StrokeDoc,
User,
} from "../types";
/** How often the whole account is reconciled with the server. */
const FULL_SYNC_MS = 30_000;
@@ -41,6 +52,8 @@ export interface NoteData {
updateNote: (id: string, edit: NoteEdit) => void;
setNoteContent: (id: string, content: NoteContent) => void;
toggleStar: (id: string) => Promise<void>;
/** The note's own mark beside its title, and the colour it is drawn in. */
setNoteIcon: (id: string, icon: string | null, iconColor: string | null) => Promise<void>;
trashNote: (id: string) => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
@@ -52,8 +65,18 @@ export interface NoteData {
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
chapters: Chapter[];
createChapter: (notebookId: string, name: string) => Promise<Chapter>;
renameChapter: (id: string, name: string) => Promise<void>;
createChapter: (
notebookId: string,
name: string,
color?: string | null,
icon?: string | null,
sortMode?: SortMode | null
) => Promise<Chapter>;
/** 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;
@@ -79,6 +102,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
notesRef.current = notes;
const notebooksRef = useRef(notebooks);
notebooksRef.current = notebooks;
const chaptersRef = useRef(chapters);
chaptersRef.current = chapters;
const contentsRef = useRef(noteContents);
contentsRef.current = noteContents;
@@ -192,32 +217,18 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
/* ── Notes ── */
const createNote = useCallback(async (folderId: string, chapterId: string | null = null) => {
const note = await sync.createNote(folderId, chapterId);
setNotes((p) => [note, ...p]);
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
return note;
}, []);
/**
* Uploads a PDF and creates the note that holds it.
* Marks a note as changed *now* in the list everything on screen reads from.
*
* Not queued for offline: the file has to reach the server before a note can
* point at it, and a note referring to bytes nobody has would be worse than
* being told to try again with a connection.
* 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 importPdf = useCallback(async (folderId: string, file: File) => {
const attachment = await sync.uploadPdf(file, file.name);
const title = file.name.replace(/\.pdf$/i, "") || "Dokument";
const note = await sync.createPdfNote(folderId, title, attachment.id);
setNotes((p) => [note, ...p]);
/* Read the document here, now, on the machine that already has it open.
*
* Waiting until someone opens it means a filed invoice may never be read at
* all, and until it is, no device can find it. Not awaited: the note exists
* and should appear at once — the text catches up. */
void extractOnImport(file, attachment.id);
return note;
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) => {
@@ -231,36 +242,33 @@ 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) => {
setNoteContents((p) => ({ ...p, [id]: content }));
}, []);
const setNoteIcon = useCallback(async (id: string, icon: string | null, iconColor: string | null) => {
setNotes((p) => p.map((n) => (n.id === id ? { ...n, icon, iconColor } : n)));
await sync.updateNoteIcon(id, icon, iconColor);
}, []);
const toggleStar = useCallback(async (id: string) => {
const starred = await sync.toggleNoteStar(id);
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);
@@ -273,66 +281,215 @@ 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)));
}, []);
/**
* Puts one row where another was, in the user's own order.
* Everything one list is made of: its notes, its lines, its chapters.
*
* Notes and separators move the same way, so this takes both and writes both
* back. The new positions are computed over *every* note in that notebook,
* taken from the store rather than from whatever the list is showing: a search
* or a filter must never be able to renumber the notes it is hiding.
* `chapterId` scopes it to one list — the notebook itself, or one of its
* chapters. A row's place is only meaningful beside the rows it is shown with,
* and the separators of the other chapters have to come through a write
* untouched. The notes are taken from the store rather than from whatever the
* list is showing: a search must never be able to renumber what it is hiding.
*
* `extra` is a note that exists but has not reached the store yet — the one
* just created. Building the list without it would renumber the notebook
* around a row that is already on screen.
*/
/* `chapterId` scopes the arrangement to one list: the notebook itself, or one
of its chapters. A note's place is only meaningful beside the notes it is
shown with, and the separators of the other chapters must come through the
write untouched. */
const rearrange = useCallback(
(notebookId: string, dragId: string, targetId: string, chapterId: string | null = null) => {
const listOf = useCallback((notebookId: string, chapterId: string | null, extra?: NoteMeta) => {
const notebook = notebooksRef.current.find((nb) => nb.id === notebookId);
if (!notebook) return;
const inNotebook = notesRef.current.filter(
if (!notebook) return null;
const known = extra ? [extra, ...notesRef.current.filter((n) => n.id !== extra.id)] : notesRef.current;
const notes = known.filter(
(n) => !n.trashed && n.folderId === notebookId && (n.chapterId ?? null) === chapterId
);
const all = notebook.separators || [];
const mine = all.filter((sep) => (sep.chapterId ?? null) === chapterId);
const others = all.filter((sep) => (sep.chapterId ?? null) !== chapterId);
return {
notes,
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
chapter has no chapters of its own. */
chapters: chapterId ? [] : chaptersRef.current.filter((c) => c.notebookId === notebookId),
};
}, []);
const next = reindexEntries(inNotebook, mine, dragId, targetId);
if (!next) return;
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
/** Writes an arrangement back — to the screen at once, to the server behind it. */
const applyArrangement = useCallback(
(
notebookId: string,
chapterId: string | null,
/** This list's own lines as they were, for telling whether anything moved. */
mine: Separator[],
others: Separator[],
next: Arrangement
) => {
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
const position = new Map(next.noteIds.map((id, i) => [id, i]));
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
void sync.reorderNotes(next.noteIds);
const position = new Map(next.notes.map((n) => [n.id, n.order]));
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
void sync.reorderNotes(next.notes);
/* Only written when it actually changed.
*
* A plain note drag leaves the separators alone, and a write for nothing
* costs more than a request: it reloads the notes, which reshuffles any 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. */
const fingerprint = (list: typeof all) =>
list
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
.sort()
.join("\n");
const before = fingerprint(all);
if (fingerprint(merged) !== before) {
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
void sync.updateNotebook(notebookId, { separators: merged });
}
if (next.chapters.length) {
const chapterAt = new Map(next.chapters.map((c) => [c.id, c.order]));
setChapters((p) => p.map((c) => (chapterAt.has(c.id) ? { ...c, sortOrder: chapterAt.get(c.id)! } : c)));
void sync.reorderChapters(next.chapters);
}
/* Only written when it actually changed.
*
* A plain note drag leaves the separators alone, and a write for nothing
* costs more than a request: it reloads the notes, which reshuffles any
* that share an `updatedAt` second — so "last changed" quietly came out in
* a different order after every drag.
*
* 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.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 });
}
},
[]
);
/**
* Puts one row under the one it was dropped on, in the user's own order.
*
* Notes, lines and chapters move the same way, so this takes all three and
* writes all three back.
*/
const rearrange = useCallback(
(notebookId: string, dragId: string, targetId: string, chapterId: string | null = null) => {
const list = listOf(notebookId, chapterId);
if (!list) return;
const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters);
if (!next) return;
applyArrangement(notebookId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
/**
* Puts a freshly made note at the top of the list it was made in.
*
* A new note used to be given no position at all, and the sort put it near the
* top by age. That is not the same thing: a separator's position counts the
* rows above it, so a row that appears without anybody being renumbered leaves
* every line one row too high — and each new note pulled the notebook's
* arrangement further apart. Renumbering the list once, here, keeps every
* other row exactly where it was and puts the new one above all of them,
* lines and chapters included.
*/
const putOnTop = useCallback(
(note: NoteMeta) => {
if (!note.folderId) return;
const chapterId = note.chapterId ?? null;
const list = listOf(note.folderId, chapterId, note);
if (!list) return;
const next = placeOnTop(list.notes, list.mine, list.chapters, note.id);
if (!next) return;
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);
setNotes((p) => [note, ...p]);
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
putOnTop(note);
return note;
},
[putOnTop]
);
/**
* Uploads a PDF and creates the note that holds it.
*
* Not queued for offline: the file has to reach the server before a note can
* point at it, and a note referring to bytes nobody has would be worse than
* being told to try again with a connection.
*/
const importPdf = useCallback(
async (folderId: string, file: File) => {
const attachment = await sync.uploadPdf(file, file.name);
const title = file.name.replace(/\.pdf$/i, "") || "Dokument";
const note = await sync.createPdfNote(folderId, title, attachment.id);
setNotes((p) => [note, ...p]);
putOnTop(note);
/* Read the document here, now, on the machine that already has it open.
*
* Waiting until someone opens it means a filed invoice may never be read
* at all, and until it is, no device can find it. Not awaited: the note
* exists and should appear at once — the text catches up. */
void extractOnImport(file, attachment.id);
return note;
},
[putOnTop]
);
/* ── Notebooks ── */
/* ── Chapters ──
@@ -342,24 +499,65 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
* having the removal applied by hand here.
*/
const createChapter = useCallback(async (notebookId: string, name: string) => {
const chapter = await sync.createChapter(notebookId, name);
setChapters((p) => [...p, chapter]);
return chapter;
}, []);
const createChapter = useCallback(
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;
},
[]
);
const renameChapter = useCallback(async (id: string, name: string) => {
setChapters((p) => p.map((c) => (c.id === id ? { ...c, name } : c)));
await sync.updateChapter(id, { name });
/* Name and colour go the same way, in one write.
*
* 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: 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 }) => {
@@ -405,6 +603,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
updateNote,
setNoteContent,
toggleStar,
setNoteIcon,
trashNote,
restoreNote,
permanentDeleteNote,
@@ -415,7 +614,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
updateNotebook,
chapters,
createChapter,
renameChapter,
editChapter,
deleteChapter,
deleteNotebook,
reorderNotebooks,
+80 -11
View File
@@ -23,37 +23,72 @@ const MOUSE_THRESHOLD = 5;
export interface ReorderDrag {
/** The row the pointer is over, for the drop indicator. */
overId: string | null;
/** A row that would *take* what is being carried, rather than sit next to it. */
intoId: string | null;
/** The row being carried, so it can be shown as lifted. */
draggingId: string | null;
/** Spread onto each row; `id` is what gets reordered. */
rowProps: (id: string) => { "data-reorder-id": string; onPointerDown: (e: React.PointerEvent) => void };
/**
* Spread onto each row; `id` is what gets reordered.
*
* `into` marks a row that is a place rather than a neighbour — a chapter.
* Dropping on one files what is carried into it instead of putting it above.
*/
rowProps: (
id: string,
into?: boolean
) => {
"data-reorder-id": string;
"data-reorder-scope": string;
"data-reorder-into"?: string;
onPointerDown: (e: React.PointerEvent) => void;
};
/** Spread onto the grip inside a row. */
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
reorder: (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);
const [draggingId, setDraggingId] = useState<string | null>(null);
// Everything the pointer handlers need, kept out of state: they run far more
// often than anything needs to be redrawn.
const drag = useRef<{ id: string; x: number; y: number; active: boolean; fromGrip: boolean } | null>(null);
const overRef = useRef<string | null>(null);
const intoRef = useRef<string | null>(null);
const stop = useCallback(() => {
drag.current = null;
overRef.current = null;
intoRef.current = null;
setDraggingId(null);
setOverId(null);
setIntoId(null);
document.body.style.removeProperty("user-select");
}, []);
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;
@@ -65,7 +100,7 @@ export function useReorderDrag(
e.preventDefault();
}
},
[enabled]
[enabled, scope]
);
useEffect(() => {
@@ -84,12 +119,38 @@ 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;
if (next !== overRef.current) {
overRef.current = next;
setOverId(next);
/* A chapter is both a place and a neighbour, so which one it is depends on
* where in the row the pointer is.
*
* Anywhere on the row meant "into", and that quietly took reordering away:
* a chapter could no longer be moved among its neighbours, and nothing —
* note or line — could be put in the gap under one. The bottom edge is
* that gap; the body of the row is the chapter itself and takes what is
* carried in. The indicator says which: a line in the gap, the whole row
* lit up when it is about to take something. */
let into: string | null = null;
if (next && row && row.dataset.reorderInto !== undefined && fileInto) {
// The header, not the row: an open chapter's row includes the notes
// inside it, and an edge measured on *that* would be half a notebook.
const head = (row.querySelector("[data-reorder-head]") as HTMLElement | null) || row;
const rect = head.getBoundingClientRect();
const edge = Math.min(10, rect.height / 3);
// Only that band is the gap. Below it lie the chapter's own notes, and
// holding over them plainly means the chapter.
const inGap = e.clientY >= rect.bottom - edge && e.clientY <= rect.bottom;
if (!inGap) into = next;
}
if (into !== intoRef.current) {
intoRef.current = into;
setIntoId(into);
}
const beside = into ? null : next;
if (beside !== overRef.current) {
overRef.current = beside;
setOverId(beside);
}
if (e.cancelable) e.preventDefault();
};
@@ -97,7 +158,9 @@ export function useReorderDrag(
const up = () => {
const d = drag.current;
const target = overRef.current;
if (d?.active && target && target !== d.id) reorder(d.id, target);
const into = intoRef.current;
if (d?.active && into && into !== d.id) fileInto?.(d.id, into);
else if (d?.active && target && target !== d.id) reorder(d.id, target);
stop();
};
@@ -109,7 +172,7 @@ export function useReorderDrag(
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", stop);
};
}, [enabled, reorder, stop]);
}, [enabled, reorder, fileInto, scope, stop]);
// A drag that outlives its list would leave the page unselectable.
useEffect(
@@ -121,8 +184,14 @@ export function useReorderDrag(
return {
overId,
intoId,
draggingId,
rowProps: (id: string) => ({ "data-reorder-id": id, onPointerDown: onPointerDown(id) }),
rowProps: (id: string, into = false) => ({
"data-reorder-id": id,
"data-reorder-scope": scope,
...(into ? { "data-reorder-into": "" } : {}),
onPointerDown: onPointerDown(id),
}),
gripProps: { "data-reorder-grip": "" },
};
}
@@ -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 () => {
+210 -16
View File
@@ -16,6 +16,7 @@ import type {
NoteVersionSummary,
Notebook,
QueueEntry,
SortMode,
StrokeDoc,
SyncStatus,
User,
@@ -83,7 +84,10 @@ interface ServerChapter {
id: string;
notebook_id: string;
name: string;
color?: string | null;
icon?: string | null;
sort_order?: number;
sort_mode?: string | null;
}
interface ServerNote {
@@ -91,6 +95,8 @@ interface ServerNote {
title: string;
notebook_id: string | null;
chapter_id?: string | null;
icon?: string | null;
icon_color?: string | null;
starred?: number | boolean;
created_at: string;
updated_at?: string;
@@ -126,6 +132,9 @@ export interface NoteUpdate {
*/
chapterId?: string | null;
starred?: boolean;
/** The note's own mark, and its colour. Null on either takes it off. */
icon?: string | null;
iconColor?: string | null;
links?: string[];
/** Attachments the body embeds, so ones nothing uses can be released. */
attachments?: string[];
@@ -246,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();
},
};
@@ -345,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". */
@@ -383,6 +429,7 @@ export async function restoreSession(): Promise<User | Partial<User> | null> {
export async function logout(): Promise<void> {
api.forgetToken();
forgetAvatars();
await db.clearAll();
}
@@ -497,6 +544,15 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
id: n.id,
title: n.title,
folderId: n.notebook_id,
/* Where it is filed inside the notebook.
*
* Missing here, a note put into a chapter came back out of it seconds later:
* the server had it right, but every sync replaces the local mirror with
* what this function returns — and it returned a note belonging to no
* chapter, so the notebook's own list took it back. */
chapterId: n.chapter_id ?? null,
icon: n.icon ?? null,
iconColor: n.icon_color ?? null,
starred: !!n.starred,
trashed,
createdAt: n.created_at,
@@ -529,8 +585,28 @@ type QueuedAction =
};
}
| { action: "deleteNotebook"; data: { id: string } }
| { action: "createChapter"; data: { localId: string; notebookId: string; name: string } }
| { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number } }
| {
action: "createChapter";
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;
sortMode?: SortMode | null;
};
}
| { action: "deleteChapter"; data: { id: string } }
| {
action: "createNote";
@@ -640,7 +716,13 @@ async function processEntry(entry: QueueEntry): Promise<void> {
break;
case "createChapter": {
const d = a.data;
const r = await api.post<{ id: string }>("/chapters", { notebookId: d.notebookId, name: d.name });
const r = await api.post<{ id: string }>("/chapters", {
notebookId: d.notebookId,
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);
await db.deleteChapter(d.localId);
@@ -648,7 +730,10 @@ async function processEntry(entry: QueueEntry): Promise<void> {
id: r.id,
notebookId: d.notebookId,
name: d.name,
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.
@@ -660,7 +745,13 @@ async function processEntry(entry: QueueEntry): Promise<void> {
break;
}
case "updateChapter":
await api.put(`/chapters/${a.data.id}`, { name: a.data.name, sortOrder: a.data.sortOrder });
await api.put(`/chapters/${a.data.id}`, {
name: a.data.name,
sortOrder: a.data.sortOrder,
color: a.data.color,
icon: a.data.icon,
sortMode: a.data.sortMode,
});
break;
case "deleteChapter":
await api.del(`/chapters/${a.data.id}`);
@@ -792,7 +883,13 @@ export async function fetchChapters(): Promise<Chapter[]> {
id: c.id,
notebookId: c.notebook_id,
name: c.name,
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
@@ -803,12 +900,18 @@ export async function fetchChapters(): Promise<Chapter[]> {
return [...chapters, ...pending];
}
export async function createChapter(notebookId: string, name: string): Promise<Chapter> {
export async function createChapter(
notebookId: string,
name: string,
color: 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 });
const chapter: Chapter = { id: r.id, notebookId, name, 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;
@@ -817,15 +920,18 @@ export async function createChapter(notebookId: string, name: string): Promise<C
}
}
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
const chapter: Chapter = { id: lid, notebookId, name, 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 } });
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon, sortMode } });
notifyData("chapter", chapter);
notifyStatus("pending");
return chapter;
}
export async function updateChapter(id: string, updates: { name?: string; sortOrder?: number }): Promise<void> {
export async function updateChapter(
id: string,
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;
await db.putChapter({ ...chapter, ...updates });
@@ -998,15 +1104,40 @@ export async function getNotes(): Promise<NoteMeta[]> {
* note update would be wrong for the same reason — it is sent when there is a
* connection and simply re-derived from the local order otherwise.
*/
export async function reorderNotes(orderedIds: string[]): Promise<void> {
/**
* Writes new positions for notes.
*
* Positions are named rather than taken from the array index: chapters share
* the sequence, so a note's place among the notes is no longer its place in the
* list. A bare list of ids still works and is numbered in order.
*/
export async function reorderNotes(
order: string[] | Array<{ id: string; order: number }>
): Promise<void> {
const positions = order.map((entry, i) =>
typeof entry === "string" ? { id: entry, order: i } : entry
);
const notes = await db.getAllNotes();
for (let i = 0; i < orderedIds.length; i++) {
const note = notes.find((n) => n.id === orderedIds[i]);
if (note && note.sortOrder !== i) await db.putNote({ ...note, sortOrder: i });
for (const p of positions) {
const note = notes.find((n) => n.id === p.id);
if (note && note.sortOrder !== p.order) await db.putNote({ ...note, sortOrder: p.order });
}
notifyData("notes", null);
if (_online && api.token) {
api.post("/notes/reorder", { ids: orderedIds }).catch(() => {});
api.post("/notes/reorder", { positions }).catch(() => {});
}
}
/** The same, for chapters — they are rows in that one sequence too. */
export async function reorderChapters(positions: Array<{ id: string; order: number }>): Promise<void> {
const chapters = await db.getAllChapters();
for (const p of positions) {
const chapter = chapters.find((c) => c.id === p.id);
if (chapter && chapter.sortOrder !== p.order) await db.putChapter({ ...chapter, sortOrder: p.order });
}
notifyData("chapters", null);
if (_online && api.token) {
api.post("/chapters/reorder", { positions }).catch(() => {});
}
}
@@ -1099,6 +1230,37 @@ export async function createNote(
return note;
}
/**
* Sets or clears a note's own mark.
*
* Its own call rather than a field on the title write: the mark is chosen from a
* picker, one press at a time, and debouncing it behind the title's timer would
* make it arrive late or not at all.
*/
export async function updateNoteIcon(
id: string,
icon: string | null,
iconColor: string | null
): Promise<void> {
const note = await db.getNote(id);
if (!note) return;
const updated: NoteMeta = { ...note, icon, iconColor };
await db.putNote(updated);
notifyData("note", updated);
const updates = { icon, iconColor };
if (_online && api.token && !updated._pendingCreate) {
try {
await api.put(`/notes/${id}`, updates);
return;
} catch {
/* queue it below */
}
}
await enqueue({ action: "updateNote", data: { id, updates } });
notifyStatus("pending");
}
export async function updateNoteTitle(id: string, title: string): Promise<void> {
const note = await db.getNote(id);
if (!note) return;
@@ -1302,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
+46
View File
@@ -47,6 +47,9 @@ export interface NoteMeta {
folderId: string | null;
/** Chapter within the notebook; null means the notebook itself. */
chapterId?: string | null;
/** Its own mark beside the title, and the colour that mark is drawn in. */
icon?: string | null;
iconColor?: string | null;
starred: boolean;
trashed: boolean;
createdAt: string;
@@ -82,6 +85,12 @@ export type SortMode = "manual" | "updated" | "oldest" | "created" | "title";
* `order` is "how many notes come before me", so it stays meaningful as the
* notes around it are renamed, edited or opened.
*/
/** A mark a row can wear beside its name, with a colour of its own. */
export interface RowIcon {
icon?: string | null;
iconColor?: string | null;
}
export interface Separator {
id: string;
label: string;
@@ -92,8 +101,15 @@ export interface Separator {
* anything within one list.
*/
chapterId?: string | null;
/** Its own colour, `#rrggbb`, or null for the list's own faint grey. */
color?: string | null;
/** How the rule is drawn: solid, dashed, dotted, double or a thick band. */
style?: SeparatorStyle;
}
/** The ways a separator's rule can be drawn. */
export type SeparatorStyle = "solid" | "dashed" | "dotted" | "double" | "thick";
/**
* A folder inside a notebook.
*
@@ -105,7 +121,15 @@ export interface Chapter {
id: string;
notebookId: string;
name: string;
/** `#rrggbb` for its icon, so a shelf of chapters can be told apart. */
color?: string | null;
/** 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;
}
@@ -129,8 +153,20 @@ export interface Notebook {
/** How the note body is set. Presentation only — the export is unaffected. */
export type NoteFont = "serif" | "sans" | "mono";
/**
* How a chapter opens.
*
* "column" puts its notes in a further column beside the list, the way OneNote
* lays out its pages; "inline" unfolds them under the chapter's own row. A
* phone has room for neither and fills the screen either way, with a way back.
*/
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 {
@@ -141,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.
*
+187 -38
View File
@@ -1,17 +1,49 @@
import { useMemo, useState } from "react";
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 ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
const chooseFont = async (noteFont) => {
const backdrop = useBackdropClose(onClose);
/* Escape closes it, like every other panel over the app. Only clicking beside
it worked, which on a panel this wide means aiming at a sliver of page. */
useEffect(() => {
const key = (e) => e.key === "Escape" && onClose();
document.addEventListener("keydown", key);
return () => document.removeEventListener("keydown", key);
}, [onClose]);
const choosePreference = async (change) => {
try {
onUserUpdate(await sync.updatePreferences({ noteFont }));
onUserUpdate(await sync.updatePreferences(change));
} catch {
/* the choice is presentation; failing to store it must not block the panel */
}
};
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("");
@@ -76,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 {
@@ -121,7 +154,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
zIndex: 2000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
@@ -145,17 +178,20 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
borderBottom: `1px solid ${T.border}`,
}}
>
<h3
style={{
margin: 0,
fontSize: 17,
fontWeight: 600,
color: T.text,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
Account
</h3>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<Avatar user={user} size={34} />
<h3
style={{
margin: 0,
fontSize: 17,
fontWeight: 600,
color: T.text,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
Account
</h3>
</div>
<button
onClick={onClose}
style={{
@@ -195,15 +231,22 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
>
Username
</button>
<button
onClick={() => {
setTab("password");
clear();
}}
style={tbtn(tab === "password")}
>
Password
</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");
clear();
}}
style={tbtn(tab === "password")}
>
Password
</button>
)}
</div>
<div style={{ padding: "18px 22px 22px" }}>
{msg && (
@@ -268,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
@@ -305,6 +403,47 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
})}
</div>
</div>
{/* How a chapter opens. Kept beside the typeface because both are
the same kind of thing: how your own notes are laid out, stored
on the account so the answer follows you to the phone. */}
<div style={{ marginTop: 18 }}>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
Chapters open
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}>
{[
["column", "Beside the list", "Its notes in a further column, the way OneNote lays out pages"],
["inline", "Under the chapter", "Its notes unfold in place, below the chapter's own row"],
].map(([value, label, hint]) => {
const active = (user.preferences?.chapterView || "column") === value;
return (
<button
key={value}
data-chapter-view={value}
onClick={() => chooseChapterView(value)}
title={hint}
style={{
padding: "10px 8px",
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: 12.5,
textAlign: "left",
lineHeight: 1.35,
}}
>
{label}
</button>
);
})}
</div>
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
A phone has room for neither, and fills the screen either way — with a way back.
</div>
</div>
</>
)}
@@ -325,18 +464,28 @@ 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>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Confirm Password
</label>
<input
type="password"
value={usernamePass}
onChange={(e) => setUsernamePass(e.target.value)}
style={inp}
onKeyDown={(e) => {
if (e.key === "Enter") saveUsername();
}}
/>
{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>
<input
type="password"
value={usernamePass}
onChange={(e) => setUsernamePass(e.target.value)}
style={inp}
onKeyDown={(e) => {
if (e.key === "Enter") saveUsername();
}}
/>
</>
)}
<button
onClick={saveUsername}
disabled={saving}
@@ -358,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
+51 -2
View File
@@ -2,7 +2,9 @@ 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";
const MIN_PASSWORD_LENGTH = 6;
const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/;
@@ -23,6 +25,7 @@ const fmtDate = (d) =>
* makes the user type the name.
*/
export function AdminPanel({ currentUser, onClose, dark }) {
const backdrop = useBackdropClose(onClose);
const [users, setUsers] = useState(null);
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
@@ -176,7 +179,7 @@ export function AdminPanel({ currentUser, onClose, dark }) {
backdropFilter: "blur(6px)",
fontFamily: FONT_UI,
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
@@ -261,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>
@@ -279,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
@@ -294,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
@@ -325,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 }}
/>
);
}
+253
View File
@@ -0,0 +1,253 @@
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";
/**
* Everything about one chapter, in one place — and the same place whether it is
* being made or changed.
*
* It used to be a name asked on creation and then two or three entries in the
* chapter's own menu for everything else, so making a chapter that looked the
* way you wanted took three visits. A chapter's whole identity is a name, a mark
* and a colour; they are one decision and belong in one dialog.
*
* The row is drawn at the top exactly as the list will draw it, because the
* point of a mark and a colour is how the row looks, and a grid of swatches does
* not answer that.
*/
export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const existing = !!chapter?.id;
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);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const ready = !!name.trim();
const save = () => {
if (!ready) return;
onSave({ name: name.trim(), icon, color: colour, sortMode });
onClose();
};
return (
<div
data-chapter-dialog
{...backdrop}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
style={{
width: "100%",
maxWidth: 420,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 20,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>
{existing ? "Chapter" : "New chapter"}
</h3>
{/* The row as the list will draw it, changing as the choices change. */}
<div
data-chapter-preview
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "10px 12px",
borderRadius: 10,
background: T.field,
marginBottom: 16,
}}
>
<span
style={{
display: "flex",
alignItems: "center",
color: colour || T.accent,
flexShrink: 0,
fontSize: icon ? 15 : undefined,
lineHeight: 1,
}}
>
{icon || I.books}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 13,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name || "Unnamed chapter"}
</span>
</div>
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") onClose();
}}
aria-label="Chapter name"
placeholder="Chapter name"
style={{
width: "100%",
padding: "10px 13px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13.5,
outline: "none",
boxSizing: "border-box",
}}
/>
{/* 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) => {
const on = (c.value ?? null) === (colour ?? null);
return (
<button
key={c.label}
type="button"
data-colour-option={c.value ?? "none"}
title={c.label}
aria-label={c.label}
onClick={() => setColour(c.value ?? null)}
style={{
width: 26,
height: 26,
borderRadius: 8,
cursor: "pointer",
border: on ? `2px solid ${T.text}` : `1px solid ${T.border}`,
background: c.value ?? "transparent",
color: T.muted,
fontSize: 10,
lineHeight: 1,
}}
>
{c.value ? "" : "—"}
</button>
);
})}
</div>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Icon</div>
{/* 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
type="button"
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
<button
type="button"
data-chapter-save
onClick={save}
disabled={!ready}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: ready ? T.accent : T.border,
color: "#fff",
cursor: ready ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{existing ? "Save" : "Create"}
</button>
</div>
</div>
</div>
);
}
+7 -1
View File
@@ -1,7 +1,9 @@
import { useMemo } from "react";
import { useBackdropClose } from "./backdrop";
/* ═══════ Shared Components ═══════ */
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onCancel, dark }) {
const backdrop = useBackdropClose(onCancel);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
@@ -14,6 +16,10 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
);
return (
<div
// Named so a test can say "and the question is gone afterwards". Without
// that, a check reads the list straight through the overlay and passes
// while the screen is in fact stuck behind it.
data-confirm-dialog
style={{
position: "fixed",
inset: 0,
@@ -24,7 +30,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onCancel}
{...backdrop}
>
<div
style={{
+69 -13
View File
@@ -10,6 +10,7 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
hover: dark ? "#262c36" : "#f3f4f6",
faint: dark ? "#7d8590" : "#8c959f",
danger: dark ? "#f85149" : "#cf222e",
}),
[dark]
@@ -76,15 +77,56 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
}}
onClick={(e) => e.stopPropagation()}
>
{items.map((item, i) =>
item.divider ? (
<div key={i} style={{ height: 1, background: T.border, margin: "4px 6px" }} />
) : item.submenu ? (
<SubMenu key={i} item={item} T={T} onClose={onClose} />
) : (
<MenuButton key={i} T={T} item={item} onClose={onClose} />
)
)}
{renderItems(items, T, onClose)}
</div>
);
}
/**
* The items of one menu, in order.
*
* Shared by the menu and by anything that flies out of it, so a heading or a
* divider reads the same wherever it appears.
*/
function renderItems(items, T, onClose, small) {
return items.map((item, i) =>
item.divider ? (
<div key={i} style={{ height: 1, background: T.border, margin: "4px 6px" }} />
) : item.heading ? (
<Heading key={i} T={T}>
{item.heading}
</Heading>
) : item.submenu ? (
<SubMenu key={i} item={item} T={T} onClose={onClose} />
) : (
<MenuButton key={i} T={T} item={item} onClose={onClose} small={small} />
)
);
}
/**
* What the rows under it are.
*
* Deliberately unlike the rows: small, faint, spaced out and set left of them.
* Given the shape of a menu entry it would be a button that does nothing when
* pressed, which is worse than no heading at all.
*/
function Heading({ T, children }) {
return (
<div
data-menu-heading
style={{
padding: "8px 10px 3px",
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.7,
textTransform: "uppercase",
color: T.faint,
cursor: "default",
userSelect: "none",
}}
>
{children}
</div>
);
}
@@ -119,7 +161,23 @@ function MenuButton({ T, item, onClose, small }) {
{item.colorDot && (
<div style={{ width: 12, height: 8, borderRadius: 2, background: item.colorDot, flexShrink: 0 }} />
)}
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
{/* A chapter's own mark, in its own colour, at full strength — the point
of giving one a mark is to find it by that and not by reading. The
drawn glyphs stay a shade back, as they always were. */}
{item.icon && (
<span
style={{
display: "flex",
alignItems: "center",
flexShrink: 0,
lineHeight: 1,
opacity: item.iconColor || typeof item.icon === "string" ? 1 : 0.7, // a mark, not a control
color: item.iconColor || undefined,
}}
>
{item.icon}
</span>
)}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.label}</span>
</button>
);
@@ -180,9 +238,7 @@ function SubMenu({ item, T, onClose }) {
}
}
>
{item.submenu.map((sub, j) => (
<MenuButton key={j} T={T} item={sub} onClose={onClose} small />
))}
{renderItems(item.submenu, T, onClose, true)}
</div>
);
+5 -2
View File
@@ -11,6 +11,7 @@ import {
} from "../export/exportNotes";
import { I } from "./icons.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
import { useBackdropClose } from "./backdrop";
/**
* Export as Markdown, PDF or PNG.
@@ -32,6 +33,8 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
const [hasInk, setHasInk] = useState(null);
const [hasDrawing, setHasDrawing] = useState(null); // null = still looking
const [busy, setBusy] = useState(null);
// An export in flight is not interrupted by a stray click beside the dialog.
const backdrop = useBackdropClose(() => !busy && onClose());
const [progress, setProgress] = useState(null);
const [err, setErr] = useState("");
const [done, setDone] = useState("");
@@ -256,7 +259,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
backdropFilter: "blur(6px)",
fontFamily: FONT_UI,
}}
onClick={() => !busy && onClose()}
{...backdrop}
>
<div
data-export-dialog
@@ -298,7 +301,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
</div>
</div>
<button
onClick={() => !busy && onClose()}
{...backdrop}
style={{
display: "flex",
alignItems: "center",
+322
View File
@@ -0,0 +1,322 @@
import { Fragment, useMemo, useState } from "react";
/**
* The marks a note or a chapter can wear, and the grid for choosing one.
*
* 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.
*
* 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 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: "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: "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: "schedule planning week year" },
{ char: "📚", words: "book reading study library" },
{ char: "🎓", words: "school study course learning" },
{ 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: "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: "dentist teeth appointment" },
{ char: "🧘", words: "wellbeing calm mindfulness routine" },
],
},
];
/** 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 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… or type an emoji"
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(40px, 1fr))",
gap: 6,
marginTop: 10,
maxHeight: height,
overflowY: "auto",
}}
>
{/* "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. 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"
title="No icon"
aria-label="No icon"
onClick={() => onPick(null)}
style={{
height: 40,
borderRadius: 10,
cursor: "pointer",
fontSize: 13,
lineHeight: 1,
color: T.muted,
border: !value ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: !value ? `${T.accent}22` : "transparent",
}}
>
—
</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 && loose.length === 0 && (
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
Nothing by that name.
</div>
)}
</div>
</>
);
}
+147
View File
@@ -0,0 +1,147 @@
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 wears beside its name, and the colour that goes with
* it.
*
* 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 [picked, setPicked] = useState(value);
const [tint, setTint] = useState(colour);
const backdrop = useBackdropClose(onClose);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
const save = (char, colourValue) => {
onSave(char, colourValue);
onClose();
};
return (
<div
data-icon-picker
{...backdrop}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
style={{
width: "100%",
maxWidth: 420,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 20,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 12px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
<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. */}
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, marginBottom: 7 }}>
Colour (for the row, and for marks without one of their own)
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
const on = (c.value ?? null) === (tint ?? null);
return (
<button
key={c.label}
type="button"
data-icon-colour={c.value ?? "none"}
title={c.label}
aria-label={c.label}
onClick={() => setTint(c.value ?? null)}
style={{
width: 26,
height: 26,
borderRadius: 8,
cursor: "pointer",
border: on ? `2px solid ${T.text}` : `1px solid ${T.border}`,
background: c.value ?? "transparent",
color: T.muted,
fontSize: 10,
lineHeight: 1,
}}
>
{c.value ? "" : "—"}
</button>
);
})}
</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"
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
<button
type="button"
data-icon-save
onClick={() => save(picked, tint)}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
}}
>
Save
</button>
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useBackdropClose } from "./backdrop";
/**
* Attaching a web address to the selected text.
@@ -18,6 +19,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
const ALLOWED = /^(https?|mailto):/i;
export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark }) {
const backdrop = useBackdropClose(onClose);
const [url, setUrl] = useState(initialUrl);
const [error, setError] = useState("");
const inputRef = useRef(null);
@@ -88,7 +90,7 @@ export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onClose}
{...backdrop}
>
<form
onSubmit={submit}
-127
View File
@@ -1,127 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
/**
* Asking for one name.
*
* A real dialog rather than `window.prompt`, for the same reasons the link
* dialog is one: the browser's box cannot be styled, cannot be reached by the
* tests, and on a phone it arrives as a system alert with the app greyed out
* behind it.
*
* Deliberately small. Naming a chapter is one field and two buttons, and
* anything more would be a form.
*/
export function NameDialog({ title, value = "", label = "Name", confirmLabel = "Save", onSave, onClose, dark }) {
const [name, setName] = useState(value);
const inputRef = useRef(null);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const save = () => {
const trimmed = name.trim();
if (!trimmed) return;
onSave(trimmed);
onClose();
};
return (
<div
data-name-dialog
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 380,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 22,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
aria-label={label}
placeholder={label}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") onClose();
}}
style={{
width: "100%",
padding: "10px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 14,
outline: "none",
boxSizing: "border-box",
}}
/>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
<button
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
<button
onClick={save}
disabled={!name.trim()}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: name.trim() ? T.accent : T.border,
color: "#fff",
cursor: name.trim() ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+5 -2
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { COVERS, PATTERNS, coverBackground, coverParts } from "./constants.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Choosing a cover: its colour, then the pattern over it.
@@ -76,6 +77,7 @@ function CoverChooser({ T, cover, pattern, onCover, onPattern }) {
}
export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxVersions }) {
const backdrop = useBackdropClose(onClose);
const [name, setName] = useState(notebook.name);
/* Colour and pattern are separate choices now. A notebook saved when they
were one list holds an index into that list, so it is read back as the pair
@@ -113,7 +115,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
@@ -237,6 +239,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
}
export function CreateNotebookModal({ onClose, onCreate, dark }) {
const backdrop = useBackdropClose(onClose);
const [name, setName] = useState("");
const [sel, setSel] = useState(0);
const [pat, setPat] = useState(0);
@@ -262,7 +265,7 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
+3 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { coverBackground } from "./constants.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Asks which notebook something should go into.
@@ -33,6 +34,7 @@ const recalledNotebook = () => {
};
export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", onPick, onClose, dark }) {
const backdrop = useBackdropClose(onClose);
const remembered = recalledNotebook();
const [selected, setSelected] = useState(
() => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null
@@ -77,7 +79,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", on
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onClose}
{...backdrop}
>
<div
onClick={(e) => e.stopPropagation()}
+239
View File
@@ -0,0 +1,239 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CHAPTER_COLORS, SEPARATOR_STYLES } from "./constants.jsx";
import { Rule } from "./separatorStyle.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Everything about one line, in one place.
*
* A separator used to be edited from three entries in its own menu — rename,
* colour, line — each a different shape of interaction, and none of them showing
* what the result would look like. Choosing "dotted" from a list of words is a
* guess; the difference between dotted and dashed at this size is not something
* a word settles.
*
* So the dialog draws the thing itself, twice over: once at the top as it will
* appear in the list, and once beside each choice of line.
*/
export function SeparatorDialog({ separator, onSave, onClose, dark }) {
const [label, setLabel] = useState(separator.label || "");
const [colour, setColour] = useState(separator.color ?? null);
const [style, setStyle] = useState(separator.style || "solid");
const inputRef = useRef(null);
const backdrop = useBackdropClose(onClose);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
faint: dark ? "#484f58" : "#afb8c1",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
// What the list will actually draw: no colour means the list's own faint grey.
const drawn = colour || T.faint;
const save = () => {
onSave({ label: label.trim(), color: colour, style });
onClose();
};
return (
<div
data-separator-dialog
{...backdrop}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
style={{
width: "100%",
maxWidth: 400,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 20,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>Separator</h3>
{/* The line as the list will draw it, changing as the choices change. */}
<div
data-separator-preview
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "10px 8px",
borderRadius: 10,
background: T.field,
marginBottom: 16,
}}
>
<Rule colour={drawn} style={style} />
<div
style={{
flex: "0 1 auto",
maxWidth: 170,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
color: label ? colour || T.text : T.muted,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
minWidth: 54,
}}
>
{label || "Unnamed"}
</div>
<Rule colour={drawn} style={style} />
</div>
<input
ref={inputRef}
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") onClose();
}}
aria-label="Separator name"
placeholder="Name — may be left blank"
style={{
width: "100%",
padding: "10px 13px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13.5,
outline: "none",
boxSizing: "border-box",
}}
/>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Line</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{SEPARATOR_STYLES.map((s) => (
<button
key={s.value}
type="button"
data-line-option={s.value}
aria-pressed={style === s.value}
onClick={() => setStyle(s.value)}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "8px 10px",
borderRadius: 9,
cursor: "pointer",
border: style === s.value ? `1px solid ${T.accent}` : `1px solid transparent`,
background: style === s.value ? `${T.accent}18` : "transparent",
color: style === s.value ? T.text : T.muted,
fontSize: 12,
fontWeight: 600,
textAlign: "left",
}}
>
<span style={{ width: 62, flexShrink: 0 }}>{s.label}</span>
{/* Drawn with the very rule the list uses, in the colour chosen —
a sample that could disagree with the result is worse than none. */}
<span style={{ display: "flex", alignItems: "center", flex: 1 }}>
<Rule colour={drawn} style={s.value} />
</span>
</button>
))}
</div>
<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) => {
const on = (c.value ?? null) === (colour ?? null);
return (
<button
key={c.label}
type="button"
data-colour-option={c.value ?? "none"}
title={c.label}
aria-label={c.label}
onClick={() => setColour(c.value ?? null)}
style={{
width: 26,
height: 26,
borderRadius: 8,
cursor: "pointer",
border: on ? `2px solid ${T.text}` : `1px solid ${T.border}`,
background: c.value ?? "transparent",
color: T.muted,
fontSize: 10,
lineHeight: 1,
}}
>
{c.value ? "" : "—"}
</button>
);
})}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 20 }}>
<button
type="button"
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
<button
type="button"
data-separator-save
onClick={save}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
}}
>
Save
</button>
</div>
</div>
</div>
);
}
+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>
+32
View File
@@ -0,0 +1,32 @@
import { useCallback, useRef } from "react";
/**
* Clicking beside a dialog closes it — but only a click that *began* there.
*
* A bare `onClick={onClose}` on the backdrop looks right and is wrong in one
* ordinary case: select a word inside the dialog, drag a little past its edge,
* let go. The browser fires the click on the nearest common ancestor of press
* and release, which is the backdrop — so the dialog closed in the middle of
* editing, and whatever had been typed went with it.
*
* Remembering where the press landed is enough to tell the two apart: a real
* dismissal starts and ends on the backdrop, a selection starts inside it.
*/
export function useBackdropClose(onClose) {
const startedOutside = useRef(false);
const onPointerDown = useCallback((e) => {
startedOutside.current = e.target === e.currentTarget;
}, []);
const onClick = useCallback(
(e) => {
if (e.target !== e.currentTarget || !startedOutside.current) return;
startedOutside.current = false;
onClose?.();
},
[onClose]
);
return { onPointerDown, onClick };
}
+32
View File
@@ -41,6 +41,38 @@ export const COVERS = [
{ bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" },
];
/**
* Colours a chapter's icon can be given.
*
* A short, named list rather than the full palette: the point is telling one
* chapter from another at a glance, and twenty near-identical blues do not help
* with that. Each is legible on both themes.
*/
export const CHAPTER_COLORS = [
{ label: "Red", value: "#e5534b" },
{ label: "Orange", value: "#e07c24" },
{ label: "Yellow", value: "#d4a017" },
{ label: "Green", value: "#2da44e" },
{ label: "Teal", value: "#1f9c96" },
{ label: "Blue", value: "#4a8ff5" },
{ label: "Purple", value: "#a371f7" },
{ label: "Pink", value: "#db61a2" },
];
/**
* The ways a separator's rule can be drawn.
*
* Named rather than shown as samples: at this size a dotted rule and a dashed
* one are two grey smudges, and the word says which is which.
*/
export const SEPARATOR_STYLES = [
{ label: "Solid", value: "solid" },
{ label: "Dashed", value: "dashed" },
{ label: "Dotted", value: "dotted" },
{ label: "Double", value: "double" },
{ label: "Thick", value: "thick" },
];
/* The icon is a key rather than an emoji: the sidebar draws it from the app's
* own set, so it matches the stroke weight of everything around it and looks
* the same on every machine. */
+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);
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* How a separator's rule is drawn.
*
* Its own module because two places draw it and they must not drift: the list,
* where the line actually lives, and the dialog that offers the choice. A
* preview built from a second copy of these rules would eventually show
* something the list does not draw, which is worse than no preview at all.
*
* Built as a border rather than a background, because that is the only way to
* get dashes and dots without an image — and `double` needs the height to carry
* two lines and the gap between them, or the browser draws one.
*/
export const RULE_STYLES = {
solid: { height: 2, borderRadius: 2, solid: true },
dashed: { height: 0, borderTop: "2px dashed" },
dotted: { height: 0, borderTop: "3px dotted" },
double: { height: 0, borderTop: "4px double" },
thick: { height: 4, borderRadius: 2, solid: true },
};
/** One half of the line, left or right of the label. */
export function Rule({ colour, style, width }) {
const shape = RULE_STYLES[style] || RULE_STYLES.solid;
return (
<div
data-separator-rule={style || "solid"}
style={{
flex: width ? undefined : 1,
width,
minWidth: width ? undefined : 8,
height: shape.height,
borderRadius: shape.borderRadius,
...(shape.solid ? { background: colour } : { borderTop: `${shape.borderTop} ${colour}` }),
}}
/>
);
}
+539 -28
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,34 @@ 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
drag hard to aim and harder to read. One direction, one line, drawn under the
row it will land under. The two ends of the list have strips of their own: the
one below the last row was always there, and there is now one above the first,
because that is the single place a drop *under* something cannot reach.
**Rearranging is a mode**, switched on by the handle button beside the sort menu
and off again by itself when the list is left or the order stops being a
hand-made one. Only while it is on does a row wear its grip or move at all. Rows
are read constantly and dragged rarely, so six dots beside every title was
clutter that also made an ordinary press able to carry a row off. Filing a note
somewhere else is not part of it: that is one of the things done to a row from
the row's own menu, like starring, exporting and discarding.
**The context menu** was placed with `innerWidth - 220`, a stand-in for a width
nobody had measured, and its submenu was pinned to `left: 100%` and opened only
on hover. On a phone the parent already sits against the right edge, so the list
@@ -340,6 +523,13 @@ hover, there was no way to open it in the first place. It is now placed by
measuring, opens on a tap, unfolds in place on a narrow screen, and flips to the
other side on a wide one when the right would not fit.
*Move to…* holds two different kinds of place, so it **says which is which**:
the notebook itself, then *Chapters here*, then *Other notebooks*. The headings
are deliberately not shaped like entries — small, faint and set apart — because
a heading built like a menu row is a button that does nothing when pressed. Each
row carries the mark its chapter was given or the cover its notebook wears, so a
place is recognised in the menu the same way it is recognised in the list.
### Installing it
The install entry used to appear only when the browser had offered a prompt
@@ -423,17 +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` | 52 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 13 | a note filed into a chapter leaves the notebook's list, and removing the chapter gives every note back |
| `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` | 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
@@ -482,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. 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
@@ -497,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
@@ -518,9 +776,35 @@ appear in an export. Two tests hold that down — one asks whether any block sit
in a gap, the other reads the stored body back and looks for them.
The unit is a whole block. A paragraph that would be split moves down entire,
which is what a page break means on paper. A single block taller than a page — a
long table, a tall image — is left where it is and runs over the gap: splitting
one would mean laying out its insides ourselves.
which is what a page break means on paper.
**A table is the exception, and has to be.** Moved whole it either jumps to the
next sheet leaving the rest of this one blank, or — being longer than a sheet —
has nowhere to jump to and lies straight across the gap with a row cut in half.
So a table is broken **between its rows**, which is where a table breaks on paper
too. The spacer there is a row of its own: a `div` inside a `tbody` is not a
thing the browser will keep, while a `tr` with one borderless cell pushes the
rows after it down exactly as far as needed. Its own spacer rows are subtracted
again on the next measurement, and counted into the shift every block below it
inherits — miss that and everything under a broken table is measured a page too
high.
Anything else too tall to fit anywhere — a full-page image — is left where it is
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:
@@ -583,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`
@@ -639,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
@@ -881,9 +1208,28 @@ on every keystroke. They are now separate blobs; the body only keeps an id.
| POST | `/api/attachments` | Upload a base64 data URL |
| GET | `/api/attachments/:id` | Fetch the bytes (owner only) |
> Removing an image from a note does not yet delete its blob: the body is
> encrypted, so the server cannot tell what is still referenced. Unused blobs
> cost disk space, nothing else.
### Where it all actually sits
Two directories, both bind-mounted from the host so that whatever backs up the
host backs up InkNote:
```
/srv/inknote/db/inknote.db every note, encrypted body and all
/srv/inknote/uploads/attachments/<sha> one file per distinct upload, encrypted
/srv/inknote/uploads/drawing_<id>.png legacy raster drawings
```
**There is no folder per user, and there is deliberately nothing on disk to read.**
Who owns what is a column in the database (`attachments.user_id`), not a path.
The file name is the SHA-256 of the *plaintext* — encryption uses a fresh IV each
time, so hashing the ciphertext would store the same photo twice — and the bytes
beside it are AES-256-GCM. So a file name tells you that two people uploaded the
same document, and nothing else: not who, not what, not which note.
Sorting uploads into per-user folders would name the owner in the clear on a disk
where nothing else is readable, and it would break the sharing that keeps one copy
of a file two accounts happen to have. Deleting stays safe without it: a blob goes
only when **no** row in `attachments` points at it any more, whoever owned them.
---
@@ -969,9 +1315,68 @@ Three rules that keep the two levels honest:
the others back untouched.
A note made while a chapter is open is filed there without being moved. A chapter
is named when it is created, unlike a separator: a separator is a line and reads
perfectly well unnamed, while an unnamed chapter is a row nobody can tell from
the next one.
is **named, marked and coloured in one dialog** — the same dialog whether it is
being made or changed, with the row drawn at the top as the list will draw it.
Asked one at a time from three menu entries, getting a new chapter to look right
took three visits. Its menu therefore holds one *Edit* and one *Remove*.
**A row is put into a chapter by dragging it onto it.** While rearranging is on,
a chapter row takes what is dropped on it and lights up to say so, rather than
growing a drop line: a chapter is a place, not a neighbour. Notes and separators
both go in this way.
But it is a neighbour as well, and taking the *whole* row as "into" quietly took
that away — a chapter could no longer be moved among its neighbours, and nothing
could be put in the gap under one. So the **bottom edge of a chapter's header is
the gap** and the rest of it is the chapter. The header, not the row: an open
chapter carries its notes inside that row, and holding over those plainly means
the chapter.
A chapter shows even when the notebook holds nothing else. "Nothing here" is
asked of every row, not of the notes alone — asked of the notes, a notebook whose
only contents were chapters showed the empty-list panel with the chapters hidden
behind it, and making a chapter in a fresh notebook appeared to do nothing.
### How a chapter opens
Two ways, chosen per account under Account → Profile, beside the typeface and for
the same reason: it is a choice about how your own notes are laid out, and it
should follow you to the phone.
- **Beside the list** — its notes in a further column, the way OneNote lays out
its pages. The notebook's own list carries on to the left, chapter rows and
all.
- **Under the chapter** — its notes unfold in place, indented against a rule in
the chapter's colour.
A phone has room for neither. There the chapter fills the list and the header
button becomes a way back out, which is what it did before there was a choice.
**Which list is on screen is not the same as which chapter is open.** Beside and
below, the middle column still shows the notebook; only the phone's fallback
replaces it. The two were one value for a while, and a separator drawn in a
notebook vanished the moment a chapter was opened — the list still showed the
notebook, but everything around it had switched to the chapter, so the notebook's
own separators were filtered out and a drag in that list wrote nothing at all.
**Which of the two you are in is a separate question again**, because both are on
screen at once. Opening a chapter puts you in it; pressing in the notebook's own
list puts you back in the notebook without closing the chapter. Whichever it is
wears a line along its top, and the + button names the place it would make the
note in. Before, a new note went into the chapter however far away you had since
clicked, and nothing on screen said so.
### A chapter is a row like any other
Chapters sit at the same level as notes and separators: same grip, same drag,
same place in a hand-made arrangement. That takes **one counter for both** in
`toArrangement` — numbering notes and chapters separately would leave two
sequences that cannot be interleaved again, and a chapter dragged between two
notes would spring back to the top.
Positions therefore have to be *named* rather than taken from an array index,
which is why `POST /notes/reorder` accepts `positions: [{id, order}]` beside the
older `ids`, and why chapters have a reorder route of their own.
---
@@ -983,10 +1388,60 @@ finding one sorted differently on the phone than on the desktop is its own small
annoyance. A reference book can stay alphabetical while a journal stays
newest-first.
**A notebook starts in the hand-made order.** That is where a notebook's own
arrangement lives: separators show, rows wear their handles once rearranging is
switched on, and nothing slides about by itself. A notebook nobody has arranged
still reads newest-first — untouched rows all carry position 0 and fall back to
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
renumber left every line one row too high, and the arrangement came apart a
little more with each new note. `placeOnTop` builds the order from the list
*without* the new note — measuring the room after the furniture has moved would
write the misreading back as the truth — and puts the note in front of it. Every
other row keeps its place, and the rows that had never been dragged stop sliding
about, because now they carry a position of their own.
The views are different: "All Notes", "Starred" and the bin are questions asked
of every notebook at once, so there is nothing for an arrangement to belong to
and nowhere to keep it. They start in "last changed" and are not offered the
hand-made order at all.
| Mode | Order |
| --- | --- |
| `manual` | the user's own arrangement, dragged into place — notebooks only |
| `updated` | most recently changed first *(the default)* |
| `manual` | the user's own arrangement, dragged into place — notebooks only, *and what a notebook starts in* |
| `updated` | most recently changed first *(the default for the views: All Notes, Starred, the bin)* |
| `oldest` | least recently changed first |
| `created` | most recently created first |
| `title` | A–Z, case- and umlaut-aware, with numbers read as numbers |
@@ -1006,6 +1461,35 @@ never re-derived from what is on screen, so two rules hold it up:
questions asked of every notebook at once, so there is nothing for an
arrangement to belong to and nowhere to keep it.
**Icons.** A note and a chapter can each wear a mark of their own beside the
name — chosen from a short searchable list rather than the system emoji keyboard,
which is a different thing on every machine and offers three thousand pictures
for a decision that is "which of these is the invoice notebook". The list is
searched by what a thing is *for* ("invoice", "house", "travel"), because nobody
hunts a pictogram grid by shape. The same glyph shows in the list, above the
editor **and in every link pointing at the note**, so it is recognised by one sign
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
@@ -1014,9 +1498,24 @@ edit, and the × for removal sat a few pixels from that same target. Removal ask
first, and says the notes are not affected. They move by dragging like a note does — a
divider that cannot move with what it divides is worse than none — and their
position is recorded as "how many notes come before me", so they stay put as the
notes around them are renamed, edited or opened. They are shown only in the
manual order: in "newest first" the notes would slide past the line on every
edit.
notes around them are renamed, edited or opened.
One press draws the line. Everything about it — name, colour, and one of five
shapes — is then set in **one dialog** reached by right-click, which draws the
line as it will look and draws each shape beside its name. It used to be three
menu entries and an input inside the row: three shapes of interaction for one
small thing, none of them showing the result. The preview is built from the very
rules the list draws with, because a sample that can disagree with the result is
worse than none.
**A chapter can be divided the same way**, and a line is put into one by dragging
it there — there is no second "+ Separator" inside a chapter, because a second
way to make the same thing in a second place is a second thing to keep straight.
They are shown only in the manual order: in "newest first" the notes would slide
past the line on every edit — and **the button that draws one is only there in
that order too**. Standing in the others it could do nothing at all when pressed,
which reads exactly like a button that is broken.
Reordering also has its own route, `POST /api/notes/reorder`. Going through
`PUT /api/notes/:id` would bump `updated_at` and snapshot a version — so
@@ -1128,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.
---
@@ -1152,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,