Compare commits

...
62 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
marcandClaude Opus 5 1757ec9af3 Chapters: folders inside a notebook
A note is in exactly one chapter, or in none — which means the notebook
itself. Chapters sit above the loose notes; tapping one opens its own
list and the menu button becomes the way back out, which on a phone is
the only way, since the list fills the screen.

A chapter is not a wastebasket. Removing one never removes what it held:
the notes come back to the notebook, where they can be seen and filed
again. That is ON DELETE SET NULL, and it is also written out in the
delete route, because it is the promise the whole thing rests on and
should be visible where it happens rather than inferred from a schema.
The dialog says so too, with the number of notes affected, and the suite
removes a chapter holding three notes and asks the server for all three.

Three rules keep the two levels honest. A note filed into a chapter
leaves the notebook's own list, or filing it would appear to do nothing.
A chapter belongs to its notebook, so the server refuses to file a note
into a chapter of a book it is not in, and moving a note to another
notebook clears its chapter. And separators belong to the list they were
drawn in: a separator's order counts the notes before it, which means
nothing across lists.

Two bugs found while building it, both a day old and both affecting the
desktop rather than the phone they were written for:

- A submenu was clipped away. The context menu had been given
  `overflow-y: auto`, which cuts off anything flying out sideways — so
  the notebooks under "Move to..." were invisible and every click on them
  landed on the note list behind. The side was inverted too:
  `right: 100%` places a panel on the left.
- A separator was a one-pixel hairline in the border colour with faint
  grey text on near-black. Present, and on a phone effectively invisible.

And one test that never measured what it claimed. It asked whether a
dragged note had become "the most recently changed" by reading the order
on screen — but three notes made seconds apart share an `updated_at`
second, so that order is a tie broken arbitrarily. It passed on luck for
weeks and fell over on a run a fraction faster. It now compares the
timestamps themselves, before and after the drag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:04:18 +02:00
marcandClaude Opus 5 e037f01dc2 Let a notebook's design be its own
Two things were drawn on every cover regardless of what was chosen, and
between them they are most of why a shelf of notebooks looked alike.

A 45-degree stripe texture was baked into the cover at 1.5% white. It was
meant to be imperceptible; on the darker gradients it bands visibly, and
since covers gained patterns of their own it was a second pattern
fighting the first. Removed.

And the same pair of rings sat in the middle of every one. The mark
belongs to the design now — a ring, a square, a diamond, a dot, a cross,
an arc, three bars — and the designs whose pattern already fills the face
wear nothing at all. Drawn with borders rather than an image, so each
takes its cover's own accent and stays sharp at any size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:44:15 +02:00
marcandClaude Opus 5 83b76b95a0 Make a separator legible on a phone
It was a one-pixel hairline in the border colour with faint grey text on
near-black: present, and on a small screen in daylight effectively
invisible. Marc reported it as missing, which it may as well have been.

Two-pixel rules in a colour that carries, a brighter and slightly larger
label, and room above it so it reads as the end of what came before
rather than as decoration on the note below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:41:17 +02:00
marcandClaude Opus 5 7cb26898aa Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 02:20:55 +02:00
marcandClaude Opus 5 8ca1ed6e06 Choose a cover in two parts, and let the app be installed
A cover is a colour *and* a pattern now, chosen in turn. Offering every
combination as its own entry meant fifteen colours times a dozen patterns
in a grid nobody could take in — and in practice only the handful someone
had thought to write out. Each pattern is previewed over the colour in
hand, so what is on screen is the cover as it will be. `cover_pattern` is
its own column: they are two decisions, and folding them into one number
would mean renumbering every cover the day a pattern is added.

For one day the patterns *were* twelve appended entries. Those indices
are read back as the colour-and-pattern pair they were drawn as, so
nothing chosen then looks different now.

And three separate reasons the install button never appeared, none of
them in anyone's configuration:

- `beforeinstallprompt` fires once, early — usually before React has
  mounted — and is never fired again. A listener added from inside a
  component simply missed it. It is caught at the top of index.html now.
- A `<link rel="manifest">` is fetched with credentials omitted unless it
  says otherwise. Behind a reverse proxy that authenticates in front of
  the app, that one 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 service worker cached whatever came back, so a lapsed session could
  be stored as the application itself. The app would then "work offline"
  by showing a login screen it could never get past, curable only by
  clearing the site's data. Only successful, unredirected responses are
  cached now, and the cache name is bumped to drop anything already
  stored under the old rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 02:20:37 +02:00
marcandClaude Opus 5 0d8d1180de Record the digest of the image that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:16:59 +02:00
marcandClaude Opus 5 608da16ac2 Make the phone work: touch reordering, reachable menus, and nothing lost offline
Four things that were built for a mouse on a desk and quietly did nothing
useful on a phone. The last one is the serious one.

**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 ever
knew — 404, five retries, thrown away. The note arrived empty and
untitled, with nothing to say that anything had been lost. The database
was repointed at the new id all along; the queue was not. It is now, by
replacing the id across the whole queued payload rather than in a list of
named fields, because a list of fields is a list to forget to add to. The
same held for a notebook made offline and everything filed into it.

**The order of notes could not be changed on a phone**, because the list
went through the HTML5 drag API, which mobile browsers never fire from
touch. Pointer events instead, which mouse, pen and finger all produce. A
mouse still drags a row from anywhere; a finger drags from a grip,
because pressing the row has to go on scrolling and holding it has to go
on opening the menu. The row under the finger is found with
elementFromPoint — a finger raises no pointerover on anything it did not
press.

**"Move to..." opened off the side of the screen**, and could not be
opened at all without a hover. It is a tap now, unfolding in place where
there is no room beside the menu and flipping sides where there is. The
menu itself is placed by measuring rather than by assuming it is 220px
wide, its rows are tall enough to hit with a thumb, and Escape closes it.

**Installing said nothing when it could not be done.** The entry appeared
only when the browser had offered a prompt, so where installing was
impossible there was no button and no reason. It is always there now and
names the cause — most usefully that a browser will only install a site
served over HTTPS, which is also why the service worker would not have
registered, so one cause was showing up as two mysteries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:16:41 +02:00
marcandClaude Opus 5 6eecf57247 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:29:32 +02:00
marcandClaude Opus 5 9e4677c70b Patterns on the covers, a search box that is simply there, roomier margin notes
Four small things about the shape of the app.

Covers can be patterns as well as colours — ruled paper, graph paper,
pinstripe, bands, dots, cross-hatch, linen, washes, a ledger — drawn
entirely with CSS gradient layers. No image files, nothing to upload,
crisp at any size, and offline like everything else. A cover is stored as
an index into that list, so the new ones are appended and never inserted:
renumbering would silently repaint every notebook anyone already has.

Two things the patterns taught. One idea per cover: two crossed diagonals
meant as a chevron came out as noisy plaid at the size a cover is
actually read, and were replaced with plain bands. And the grid has to
scroll — with 27 covers the dialog ran past the bottom of a laptop screen
and took the Create button with it, which a check now holds at 1440x800.

The search box in a notebook is simply there now, and the button that
used to open it is gone. A magnifying glass cost a click and having to
remember that searching was possible at all; a search box is one line and
says for itself what it does.

Margin notes went from 208x74 to 288x112 — 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.

And the notebook edit dialog was still part German, and would not say
what the server's default number of versions actually is. It names it
now, read from the server rather than written into the interface: it is
set with MAX_VERSIONS_PER_NOTE, and a hardcoded "50" would quietly start
lying the day that changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:29:00 +02:00
marcandClaude Opus 5 635a9632d1 Record the digest of the image that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:27:11 +02:00
marcandClaude Opus 5 d82e72d358 Keep the gap blank, and give the writing more of the page
Two things wrong with the paper.

"Start writing..." was printed at the foot of every page that had 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. It inherited a placeholder meant for an empty note.

And the column was too narrow for the sheet it now sits on — about 73
characters, which left 117px of blank paper down each edge and made the
writing look lost. Widened to about 80, a little short of where the eye
stops reliably finding the start of the next line, which leaves roughly
8% of the sheet as margin: a document's margin rather than a picture
frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:26:51 +02:00
marcandClaude Opus 5 816cc8e68b Record the digest of the image that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:17:55 +02:00
marcandClaude Opus 5 97a3764d92 Write on paper: a sheet per page, with a gap between them
The writing surface was a column of text in an empty pane. A sheet was
drawn behind it, but only once you started drawing — so writing looked
like text floating in the middle of nothing, and the space either side of
the column read as a void rather than as a margin.

Now every page is a sheet of its own, centred, with a gap after it: the
same way the PDF reader draws its pages, because a page in a note and a
page in a document should read as the same kind of thing.

The gap is the part that takes work. The browser knows nothing about the
sheets and lays the text out as one flow, putting a line straight across
the gap and cutting it in half. So editor/pagination.js measures the flow
and pushes any block that would straddle a gap onto the next page, with a
spacer of exactly the right height in front of it. A paragraph moves down
entire, which is what a page break means on paper. A block taller than a
page is left to run over, since splitting one would mean laying out its
insides ourselves.

The spacers are decorations, not content: they never enter the document,
never reach the server and never appear in an export. Two checks hold
that down — one asks whether any block sits in a gap, the other reads the
stored body back and looks for them there.

Two things that are easy to get wrong. The spacer's height is not the
distance the text moves: margins that used to collapse into each other no
longer do with something between them, so the layout gains that much for
free and the spacer is made exactly that much shorter. And heights change
with no transaction to announce it — a webfont landing, an image
arriving, the window resizing — which a ResizeObserver on the editor
element catches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:17:28 +02:00
marcandClaude Opus 5 3ce867c6d0 Stop a drawing from making its note unwritable
The layer showing saved ink lies over the text. The canvas itself is deaf
to the pointer, but the box I wrapped it in yesterday was not — so on any
note carrying a drawing, every click meant for the text was swallowed
before it arrived, and the note simply could not be typed in.

Introduced with per-page drawing, where each page's ink got a wrapper to
be positioned by. The suite now clicks into the text of a note that has a
drawing and types, and asks what actually answers over the writing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:50:32 +02:00
marcandClaude Opus 5 fcdd29ba2b Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:27:18 +02:00
marcandClaude Opus 5 230e55903e Make build.sh executable
It has always been run as ./build.sh, which the file's own header says,
and the bit for it was never set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:26:51 +02:00
marcandClaude Opus 5 8e38404a69 Read a PDF where it is imported, and keep the text with the file
A PDF was searchable on a device once that device had opened it, because
the extracted text lived in that browser's IndexedDB and nowhere else. So
a document imported on the desktop was invisible to a search on the
phone — which is exactly the case that matters for filing invoices, since
those are imported and then never opened again.

The reading still never happens on the server. It has no PDF library and
is not getting one: the bytes arrive encrypted and are stored that way.
The device that imports the file reads it there and then, and the result
is stored beside the file in attachments.text_index, encrypted at rest
like everything else and opaque to the server. Every other device asks
for it after login and can then find a document it has never downloaded.

Not storing it was the earlier decision, on the reasoning that text
derivable from bytes the server already holds should not be kept twice.
True, and useless. A few hundred kilobytes against a file measured in
megabytes is a trade worth making.

A `null` answer is not an error — it means nobody has read this file yet.
Documents extracted before this existed are sent up the next time they
are opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:26:07 +02:00
marcandClaude Opus 5 228552d453 Let a drawing run over as many pages as the note
The ink was stuck on page one while the writing carried on without it,
which stopped making sense the moment a note could run to four pages.
Every page is exported anyway, so every page can be drawn on.

Pages are numbered the way the PDF reader numbers them, and only pages
that carry ink are stored, so the keys are the drawn pages. While drawing
there is always one blank page past the end: pages otherwise come from
how far the writing runs, and to draw on page three you would first have
had to write three pages.

A drawing that lives entirely on page one is still written in the old
single-page shape. Nothing requires that — it means an ordinary note is
stored exactly as it always was, and a device running an older build
still shows it.

The bin clears the page being looked at. Undo and redo work on the
drawing as a whole, because one Undo button that means four different
things depending on where you scrolled is not a button anyone can use.

And a real bug found on the way out: the exporter has written
drawings/<note>.png since the interface was put into English, while the
importer was still looking for zeichnungen/<note>.png. A drawing exported
by this very app came back from its own archive as nothing at all. Both
names are read now, and so are the further pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:25:56 +02:00
marcandClaude Opus 5 b8c5c42217 Record the digest of the image that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:41:43 +02:00
marcandClaude Opus 5 73cc28696f Write down the standing decisions, and start a changelog
The rules this project is worked under — Docker for the backend, straight
to main, no backup feature because the host is backed up, the encryption
stays, no public sign-up — lived only in a conversation. They belong in
the repository, where they survive it.

The changelog answers a question the commit messages cannot: what is
actually running on the server. It carries the image digests, read back
from the registry rather than taken from the push output.

The readme described the note column as left-aligned. It is centred
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:38:58 +02:00
marcandClaude Opus 5 dcc42bb8e0 Centre the writing column again
Pinning it to the left edge put the start of every line in the same
place, which was the point, but on a wide window it left the entire
right half of the pane as one dead block. Centred, the same leftover
space reads as two margins instead.

The page surface that tried to give that block a meaning is reverted in
the commit before this one: it made the whole sheet sit hard against the
left edge, which was worse than the problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:36:54 +02:00
marc 558c15f2c9 Revert "Give the writing a page to sit on, so the empty half means something"
This reverts commit 7910179f29.
2026-08-11 18:34:50 +02:00
86 changed files with 12548 additions and 1166 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
+508
View File
@@ -0,0 +1,508 @@
# Changelog
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`.
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.
---
## 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**
- **A marker pen and a colour for the letters**, both under one button in the
toolbar — two buttons would be two panels for one question. A highlight is a
`<mark>`, the element that *means* highlighted, and marked text is painted with
dark ink whatever the theme, the way a real marker leaves the writing alone.
Markdown has no colour, so both travel as HTML like underline does, and both
are read back — including a bare `<mark>`, which is what most other writers
emit.
- **The PDF fills the screen.** The button asks the browser for its own full
screen *and* lays the document out over the app; either alone is half the job,
and a browser that refuses the first still gets the second. Escape leaves, and
the button stays visible while full — a way out that is only known is not a way
out.
- **Dragging in from the left edge shows the sidebar** on a phone or tablet, and
dragging it back hides it. It must start at the edge, travel far enough to be
meant, and be more sideways than downwards; without the first rule every
sideways drag inside a note opens it, without the third every scroll does.
**Changed**
- **The dashboard leads with what you were last writing**, the shelf of notebooks
second — and "Recent" is sorted by time whatever order the notebooks are in.
It is a question about time, and a hand-made arrangement is the wrong answer.
- **Tapping the blank part of a page starts writing there.** The text is only as
tall as what has been written, so most of a fresh page is beside the editable
area rather than in it, and a tap down there landed on nothing at all: no
caret, and on a phone no keyboard. The caret goes to the position nearest the
tap rather than to the end.
- **The page has an edge on a phone.** The paper runs to the edges of the screen
there, but it was drawn with no border and no shadow, so nothing said where one
page ended and the next began.
---
## 2026-08-25 — The bar that ties the apps together, and Ctrl over a PDF
**Images** — web `sha256:783ccf92…`, server `sha256:2141f145…`
(earlier that day: web `d7c66235…`, server `308ed1b7…`)
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.
### The bar
**Added**
- **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 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**
- **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-22 — A bigger face, and the theme where the settings are
**Images** — web `sha256:fe35b0e7…`, server `sha256:a557fb4d…`
The server image changed: `theme` is now one of the settings an account keeps.
**Changed**
- **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**
- **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: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.
- **You write on paper.** Each page is a sheet of its own, centred, with a gap
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
- **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
- **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
- 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.
- Uploaded files are encrypted at rest, like note bodies already were, and stored
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.
- Vector clocks were never read — replaced with a conflict check that works.
- Public sign-up removed: an administrator creates the accounts.
---
## Earlier
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).
+95
View File
@@ -0,0 +1,95 @@
# InkNote – Projektkonventionen für Claude Code
Selbst gehostete Notiz-App, privat produktiv im Einsatz. Änderungen laufen auf
echten Daten – im Zweifel erst fragen, nicht erst ausprobieren.
Die ausführliche Beschreibung steht im [readme.md](readme.md); diese Datei hält
nur fest, was beim Arbeiten gilt und sich nicht aus dem Code ergibt.
## Feste Regeln
Diese Punkte sind Entscheidungen von Marc, keine offenen Fragen:
- **Backend immer mit Docker starten**, nie mit `go run` o. Ä.
`docker compose -f docker-compose.dev.yml up -d --build`
- **Direkt auf `main` pushen.** Keine Feature-Branches, kein PR-Umweg.
- **Kein Backup-Feature bauen.** Die Daten liegen über Bind-Mounts auf dem Host
(`INKNOTE_DATA_DIR`, Standard `/srv/inknote`), und was den Host sichert –
Proxmox Backup – sichert damit auch InkNote.
- **Die Verschlüsselung bleibt.** Notizinhalte, Zeichnungen und hochgeladene
Dateien liegen AES-256-GCM-verschlüsselt auf dem Server. Nichts davon
entfernen, um etwas anderes einfacher zu machen.
- **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.
## Tests
Beides muss vor einem Push grün sein.
```bash
# Unit (~4 s)
docker run --rm -v "$PWD/frontend:/w" -w /w node:20-alpine npx vitest run
# Browser, vierzehn Suiten – braucht den Dev-Stack
./e2e/run.sh # alle
./e2e/run.sh editor drawing # einzelne
```
Die Browser-Suiten melden sich mit Marcs Admin-Konto an (`INKNOTE_ADMIN` /
`INKNOTE_ADMIN_PASS`, Standard `marc`/`geheim123`) und legen sich damit
Wegwerf-Nutzer an. Sie laufen gegen `./data`, nicht gegen `/srv/inknote`.
Screenshots landen in `e2e/shots/` und sind gitignored.
`attachments-lifecycle` ruft `POST /admin/housekeeping` auf – das schreibt auf
echten Daten. Nicht gegen die Produktionsinstanz laufen lassen.
## Ausliefern
```bash
./build.sh # baut beide Images und schiebt sie ins Gitea-Registry
```
Nur das Image bauen, das sich geändert hat. Nach dem Push den Digest bei der
Registry gegenprüfen, nicht nur der Ausgabe von `docker push` glauben:
```bash
docker buildx imagetools inspect gitea.honeyy.duckdns.org/docker/inknote-web:latest
```
Auf dem Server: `docker compose pull && docker compose up -d`.
## Änderungen festhalten
Jede Runde, die als Image rausgeht, bekommt einen Eintrag in
[CHANGELOG.md](CHANGELOG.md) – mit den Image-Digests, damit später nachvollziehbar
ist, was auf dem Server läuft. Der `readme.md` beschreibt den *jetzigen* Stand
und wird mitgeändert, wenn sich Verhalten ändert; das Changelog beschreibt den
Weg dahin.
## Betrieb bei Marc
InkNote läuft hinter **Pangolin** (Reverse Proxy mit eigener Anmeldung), über
HTTPS, und wird am Handy als PWA auf **Android** genutzt. Daraus folgen drei
Dinge, die bei PWA- oder Sync-Fehlern zuerst zu prüfen sind:
- `/manifest.json` und `/sw.js` müssen die Datei liefern, nicht die Login-Seite
des Proxys. Eine **Weiterleitung** auf `sw.js` lässt die Registrierung
scheitern — dann gibt es weder Install noch Offline.
- `/api/*` unverändert durchreichen, `/ws` mit WebSocket-Upgrade.
- Jede UI-Änderung auch am Handy denken: Touch löst kein HTML5-Drag aus, und
`onMouseEnter` gibt es dort nicht.
## Stil
Der Code erklärt in Kommentaren das *Warum*, nicht das *Was* – oft mit dem
Fehler, den die Zeile verhindert. Neue Kommentare in dem Ton, englisch wie der
Rest. Die Oberfläche ist durchgehend englisch, das Gespräch mit Marc deutsch.
+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,
};
+694 -30
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,12 +278,49 @@ 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),
});
/**
* The account as it is handed to whoever just signed in as it.
*
* Carries one thing that is not about the account at all: how many versions a
* notebook keeps when it names no number of its own. It is sent rather than
* written into the interface because it is configurable —
* `MAX_VERSIONS_PER_NOTE` — and a dialog that said "50" would quietly start
* lying the day that is changed. It rides here because this is the one payload
* every client already fetches, on login and on restoring a session.
*
* `publicUser` stays as it is: the admin list shows *other* people's accounts,
* and a server-wide setting has no business being repeated on each row.
*/
const signedInUser = (row) => ({ ...publicUser(row), defaultMaxVersions: MAX_VERSIONS_PER_NOTE });
/* Interface settings the client may store. Anything else is dropped rather than
* 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;
@@ -291,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
@@ -391,6 +436,117 @@ if (!tableColumns("users").includes("preferences")) {
db.exec("ALTER TABLE users ADD COLUMN preferences TEXT");
}
/* The text of an imported PDF, extracted by whichever device imported it.
*
* The server never opens a PDF. It has no PDF library and no way to read one:
* the bytes arrive encrypted and are stored that way, and this column arrives
* the same — already extracted, encrypted at rest like everything else, and
* opaque here.
*
* It exists so a PDF is findable on a device that has never opened it. The text
* used to be extracted on first open and kept in that browser's IndexedDB, which
* meant a document imported on the desktop was invisible to a search on the
* phone until the phone had opened it. Doing the work once, where the file
* already is, and letting the result sync is what makes an archive of scanned
* invoices searchable from anywhere.
*
* On `attachments` rather than in a table of its own: it is a property of the
* file, it is deduplicated with the file, and it is freed with the file. */
/**
* Chapters — folders inside a notebook.
*
* A note sits in exactly one chapter, or in none, which is the notebook itself.
* `ON DELETE SET NULL` is the whole design in one clause: removing a chapter
* must never take notes with it. They come back to the notebook, where they can
* be seen and filed again.
*
* Separate from notebooks rather than a self-reference on them: a notebook has a
* cover, a sort order and separators, none of which a chapter has or wants.
*/
db.exec(`
CREATE TABLE IF NOT EXISTS chapters (
id TEXT PRIMARY KEY,
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')),
device_id TEXT
);
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)");
}
/* The pattern laid over a notebook's cover, chosen apart from its colour.
* Its own column rather than more values in `cover_index`: they are two
* decisions, and folding them into one number would mean renumbering every
* cover the day a pattern is added. */
if (!tableColumns("notebooks").includes("cover_pattern")) {
db.exec("ALTER TABLE notebooks ADD COLUMN cover_pattern INTEGER NOT NULL DEFAULT 0");
}
if (!tableColumns("attachments").includes("text_index")) {
db.exec("ALTER TABLE attachments ADD COLUMN text_index BLOB");
}
/**
* Makes sure exactly one way in exists.
*
@@ -573,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();
@@ -627,21 +784,227 @@ 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" });
res.json({ token, user: publicUser(user) });
res.json({ token, user: signedInUser(user) });
} 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);
if (!user) return res.status(404).json({ error: "User not found" });
res.json(publicUser(user));
res.json(signedInUser(user));
});
app.put("/api/auth/profile", authMiddleware, (req, res) => {
@@ -652,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)));
@@ -669,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);
@@ -683,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);
@@ -815,10 +1201,10 @@ app.get("/api/notebooks", authMiddleware, (req, res) => {
});
app.post("/api/notebooks", authMiddleware, (req, res) => {
const { name, coverIndex } = req.body;
const { name, coverIndex, coverPattern } = req.body;
const id = uuid();
db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, device_id) VALUES (?, ?, ?, ?, ?)")
.run(id, req.userId, name, coverIndex || 0, req.deviceId);
db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, cover_pattern, device_id) VALUES (?, ?, ?, ?, ?, ?)")
.run(id, req.userId, name, coverIndex || 0, coverPattern || 0, req.deviceId);
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'notebook', ?, 'create', ?)")
.run(req.userId, id, req.deviceId);
@@ -833,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. */
@@ -845,11 +1234,19 @@ function cleanSeparators(input) {
id: s.id.slice(0, 64),
label: typeof s.label === "string" ? s.label.slice(0, MAX_SEPARATOR_LABEL) : "",
order: Number.isFinite(Number(s.order)) ? Math.max(0, Math.round(Number(s.order))) : 0,
/* Which list it was drawn in — a chapter of the notebook, or the notebook
* 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,
}));
}
app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
const { name, coverIndex, sortOrder, maxVersions, sortMode, separators } = req.body;
const { name, coverIndex, coverPattern, sortOrder, maxVersions, sortMode, separators } = req.body;
const nb = db.prepare("SELECT * FROM notebooks WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!nb) return res.status(404).json({ error: "Not found" });
@@ -867,8 +1264,8 @@ app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
}
db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?")
.run(name, coverIndex, sortOrder, req.params.id);
db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), cover_pattern = COALESCE(?, cover_pattern), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?")
.run(name, coverIndex, coverPattern, sortOrder, req.params.id);
if (maxVersions !== undefined) {
db.prepare("UPDATE notebooks SET max_versions = ? WHERE id = ?").run(versions, req.params.id);
}
@@ -881,7 +1278,11 @@ app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
db.prepare("UPDATE notebooks SET separators = ? WHERE id = ?").run(JSON.stringify(cleaned), req.params.id);
}
broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId);
broadcastToUser(
req.userId,
{ type: "notebook_updated", data: { id: req.params.id, name, coverIndex, coverPattern } },
req.deviceId
);
res.json({ success: true, defaultMaxVersions: MAX_VERSIONS_PER_NOTE });
});
@@ -978,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, 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);
});
@@ -1043,29 +1446,52 @@ 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 });
});
app.post("/api/notes", authMiddleware, (req, res) => {
const { type, title, content, notebookId, starred, pdfAttachmentId } = req.body;
const { type, title, content, notebookId, chapterId, starred, pdfAttachmentId } = req.body;
const id = uuid();
const encContent = content ? encrypt(content) : null;
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId);
/* A note made while a chapter is open belongs to that chapter. Checked
* against the notebook, so it cannot be filed into a chapter of another. */
let chapter = null;
if (chapterId) {
const found = db.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?").get(chapterId, req.userId);
if (!found) return res.status(404).json({ error: "Chapter not found" });
if (found.notebook_id !== notebookId) {
return res.status(400).json({ error: "That chapter is in a different notebook" });
}
chapter = chapterId;
}
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, chapter_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, req.userId, notebookId, chapter, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId);
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)")
.run(req.userId, id, req.deviceId);
@@ -1107,10 +1533,33 @@ const replaceNoteAttachments = db.transaction((noteId, ids) => {
});
app.put("/api/notes/:id", authMiddleware, (req, res) => {
const { title, content, notebookId, 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" });
/* Which chapter the note is filed in. `null` means the notebook itself, so it
* is meaningful and has to travel — which is why this cannot ride on COALESCE
* like the fields above, where undefined and null mean the same thing.
*
* A chapter belongs to a notebook, so the two have to agree: filing a note
* into a chapter of a book it is not in would leave it in a place that cannot
* be reached from either. */
let chapterUpdate;
if (chapterId !== undefined) {
if (chapterId === null) chapterUpdate = null;
else {
const chapter = db
.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?")
.get(chapterId, req.userId);
if (!chapter) return res.status(404).json({ error: "Chapter not found" });
const target = notebookId !== undefined && notebookId !== null ? notebookId : note.notebook_id;
if (chapter.notebook_id !== target) {
return res.status(400).json({ error: "That chapter is in a different notebook" });
}
chapterUpdate = chapterId;
}
}
const conflict = isStaleWrite(note.updated_at, baseUpdatedAt);
const encContent = content ? encrypt(content) : note.content_encrypted;
@@ -1139,6 +1588,23 @@ 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) {
// Moved to another notebook without naming a chapter: the old chapter is in
// the book it left, so keeping it would file the note somewhere invisible.
db.prepare("UPDATE notes SET chapter_id = NULL WHERE id = ?").run(req.params.id);
}
if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links);
if (Array.isArray(attachments)) replaceNoteAttachments(req.params.id, attachments);
@@ -1284,6 +1750,17 @@ const MAX_STROKES = 20000;
function countStrokes(doc) {
if (!doc || typeof doc !== "object") return null;
if (Array.isArray(doc.strokes)) return doc.strokes.length;
/* A note drawn over several pages: page number → strokes. Flatter than the
* PDF's shape below, because every page of a note is the same size and there
* is nothing per page to record besides the ink. */
if (doc.sheets && typeof doc.sheets === "object") {
let n = 0;
for (const sheet of Object.values(doc.sheets)) {
if (!Array.isArray(sheet)) return null;
n += sheet.length;
}
return n;
}
if (doc.pages && typeof doc.pages === "object") {
let n = 0;
for (const page of Object.values(doc.pages)) {
@@ -1530,6 +2007,190 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.end(bytes);
});
/* ─── Chapters ───
*
* Folders inside a notebook. A note is in one or in none; deleting a chapter
* hands its notes back to the notebook rather than taking them with it.
*/
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, color, icon, sort_order, sort_mode FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.all(req.userId)
);
});
app.post("/api/chapters", authMiddleware, (req, res) => {
const { notebookId, name, color, icon, 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.
const nb = db.prepare("SELECT id FROM notebooks WHERE id = ? AND user_id = ?").get(notebookId, req.userId);
if (!nb) return res.status(404).json({ error: "Notebook not found" });
const order = db
.prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?")
.get(notebookId).n;
const id = uuid();
db.prepare(
"INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, 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, 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, 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) {
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" });
}
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 });
});
app.delete("/api/chapters/:id", authMiddleware, (req, res) => {
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" });
/* The notes are not deleted with it. Written out rather than left to the
* foreign key, because it is the promise the feature rests on and it should
* be visible here. */
const freed = db.prepare("UPDATE notes SET chapter_id = NULL WHERE chapter_id = ? AND user_id = ?")
.run(req.params.id, req.userId).changes;
db.prepare("DELETE FROM chapters WHERE id = ?").run(req.params.id);
broadcastToUser(req.userId, { type: "chapter_deleted", data: { id: req.params.id } }, req.deviceId);
res.json({ success: true, notesFreed: freed });
});
/* ─── The text inside a PDF ───
*
* Extracted by the device that has the file, never here. The server has no PDF
* library, and adding one would mean it could read documents it currently only
* stores — which is the opposite of the point. What arrives is a list of page
* texts; what is stored is that list, encrypted, and it is handed back to any
* other device of the same account so it can search a document it has never
* opened.
*/
/* A cap, so one enormous scan cannot fill the database. The client caps its own
* extraction at the same size; this is the second lock, on the side that cannot
* be edited by whoever is holding the browser. */
const MAX_PDF_TEXT_CHARS = 2_000_000;
app.put("/api/attachments/:id/text", authMiddleware, (req, res) => {
const a = db.prepare("SELECT id FROM attachments WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!a) return res.status(404).json({ error: "Not found" });
const { pages } = req.body || {};
if (!Array.isArray(pages) || pages.some((p) => typeof p !== "string")) {
return res.status(400).json({ error: "pages must be a list of strings" });
}
const total = pages.reduce((n, p) => n + p.length, 0);
if (total > MAX_PDF_TEXT_CHARS) return res.status(413).json({ error: "Extracted text is too large" });
db.prepare("UPDATE attachments SET text_index = ? WHERE id = ? AND user_id = ?")
.run(Buffer.from(encrypt(JSON.stringify({ pages }))), req.params.id, req.userId);
res.json({ ok: true, pages: pages.length });
});
app.get("/api/attachments/:id/text", authMiddleware, (req, res) => {
const a = db
.prepare("SELECT text_index FROM attachments WHERE id = ? AND user_id = ?")
.get(req.params.id, req.userId);
if (!a) return res.status(404).json({ error: "Not found" });
// No text yet is not an error: it means no device has read this file. The
// client answers that by reading it, if it has the bytes.
if (!a.text_index) return res.json({ pages: null });
try {
res.json(JSON.parse(decrypt(a.text_index.toString())));
} catch (err) {
console.error(` ❌ could not decrypt the text of attachment ${req.params.id}: ${err.message}`);
res.json({ pages: null }); // it can be extracted again; a 500 helps nobody
}
});
/* Two routes used to sit here and were removed on 2026-08-08:
*
* POST /api/notes/pdf accepted a PDF and stored it, but nothing could ever
@@ -1728,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 ─── */
Regular → Executable
View File
File mode changed.
+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
+762
View File
@@ -0,0 +1,762 @@
/* Chapters — folders inside a notebook.
*
* The claim the feature rests on is the one under test: a note filed into a
* chapter is *in* it and not also loose in the notebook, and removing a chapter
* never removes what it held. Both look fine on screen while being wrong on the
* server, so the server is asked directly.
*/
import {
api,
check,
createNote,
createNotebook,
launch,
report,
setTitle,
withFreshUser,
} from "./lib.mjs";
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "chapters");
const p = session.page;
const titles = () =>
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
/* 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);
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) => {
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]").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();
await p.waitForTimeout(700);
for (const name of ["Zählerstand", "Stromrechnung"]) {
await createNote(p);
await setTitle(p, name);
await p.waitForTimeout(600);
}
await p.waitForTimeout(1000);
/* ── making one ── */
check((await p.locator("[data-add-chapter]").count()) === 1, "a notebook can be given a chapter");
await addChapter("Rechnungen");
check((await p.locator("[data-chapter-row]").count()) === 1, "and it appears in the list");
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",
"a note filed into a chapter leaves the notebook's own list",
(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);
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 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 while it is open is filed there", String(made?.chapter_id));
/* ── 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()).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.
*
* 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);
check(/stay/i.test(await p.locator("body").innerText()), "removing one says out loud that the notes stay");
await p.getByRole("button", { name: "Remove", exact: true }).click();
await p.waitForTimeout(2500);
/* 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(
(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",
`${freedTitles.join(",")} → ${after.join(",")}`
);
const freed = (await api(p, "GET", "/notes")).body;
check(
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]))
);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}
+122 -10
View File
@@ -23,11 +23,13 @@ import {
const GRID_W = 48,
GRID_H = 64;
/** Rasterises the drawing surface into a GRID_W x GRID_H map of "has ink". */
const inkGrid = (page) =>
/** Rasterises one page's drawing surface into a GRID_W x GRID_H map of "has ink". */
const inkGrid = (page, sheet = 1) =>
page.evaluate(
([gw, gh]) => {
const c = document.querySelector("canvas");
([gw, gh, n]) => {
const c =
document.querySelector(`canvas[data-draw-page="${n}"]`) ||
document.querySelector(`[data-drawn-page="${n}"] canvas`);
if (!c) return null;
const off = document.createElement("canvas");
off.width = gw;
@@ -39,18 +41,23 @@ const inkGrid = (page) =>
for (let i = 0; i < gw * gh; i++) out.push(d[i * 4 + 3] > 40 ? 1 : 0);
return out;
},
[GRID_W, GRID_H]
[GRID_W, GRID_H, sheet]
);
const diff = (a, b) => a.reduce((n, v, i) => n + (v === b[i] ? 0 : 1), 0);
const ink = (g) => g.reduce((n, v) => n + v, 0);
async function draw(page) {
await page.locator("button").filter({ hasText: "Draw" }).first().click();
await page.waitForTimeout(1200);
const box = await page.locator("canvas").first().boundingBox();
async function draw(page, sheet = 1, strokes = null) {
if (sheet === 1) {
await page.locator("button").filter({ hasText: "Draw" }).first().click();
await page.waitForTimeout(1200);
}
const canvas = page.locator(`canvas[data-draw-page="${sheet}"]`);
await canvas.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
const box = await canvas.boundingBox();
// Three strokes in different parts of the page, so a shifted origin shows up.
const strokes = [
strokes ??= [
[0.2, 0.2, 0.7, 0.35],
[0.25, 0.55, 0.75, 0.5],
[0.5, 0.7, 0.55, 0.85],
@@ -130,6 +137,111 @@ try {
const d = diff(reference, afterReload);
check(d / (GRID_W * GRID_H) < 0.02, "the reloaded drawing matches the original", `${d} cells`);
}
/* ── 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. Every page is exported anyway, so every page can be drawn on. */
await createNote(p);
await setTitle(p, "Mehrere Seiten");
await p.locator("button").filter({ hasText: "Draw" }).first().click();
await p.waitForTimeout(1200);
check(
(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);
await draw(p, 2, [
[0.3, 0.25, 0.8, 0.4],
[0.2, 0.6, 0.6, 0.75],
]);
await p.waitForTimeout(2500);
const second = await inkGrid(p, 2);
check(ink(second) > 0, "a stroke lands on page two", `${ink(second)} ink cells`);
check(diff(first, await inkGrid(p, 1)) === 0, "and page one is untouched by it");
check(
(await p.locator("[data-note-position]").innerText()).includes("of 2"),
"drawing on page two makes the note two pages",
await p.locator("[data-note-position]").innerText()
);
check(
(await p.locator('canvas[data-draw-page="3"]').count()) === 1,
"and there is a blank page after it again"
);
await p.screenshot({ path: `${OUT}/drawing-pages.png` });
/* Stored per page, and it survives the trip to the server and back. */
const pageDoc = 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 === "Mehrere Seiten");
return (await (await fetch(`/api/notes/${mine.id}`, { headers: auth })).json()).drawingStrokes;
});
check(!!pageDoc?.sheets, "the server stored it as pages, not as one sheet", JSON.stringify(Object.keys(pageDoc || {})));
check(
(pageDoc?.sheets?.["1"]?.length ?? 0) > 0 && (pageDoc?.sheets?.["2"]?.length ?? 0) > 0,
"with strokes on both",
`page 1: ${pageDoc?.sheets?.["1"]?.length}, page 2: ${pageDoc?.sheets?.["2"]?.length}`
);
await reopenNote(p, "Mehrere Seiten");
await p.waitForTimeout(2000);
const reloadedTwo = await inkGrid(p, 2);
check(reloadedTwo !== null && ink(reloadedTwo) > 0, "page two's ink is still there after a reload");
check(
diff(second, reloadedTwo) / (GRID_W * GRID_H) < 0.02,
"and it is the same ink",
`${diff(second, reloadedTwo)} cells`
);
/* ── a drawing must not make the note unwritable ──
* The layers that show saved ink lie over the text. They are transparent and
* deaf to the pointer — except the wrapper around one of them was not, and a
* note with a drawing could not be typed in at all: every click meant for the
* text was swallowed before it arrived. */
await p.locator("button").filter({ hasText: "Write" }).first().click();
await p.waitForTimeout(1200);
check(
await p.evaluate(() => {
const r = document.querySelector(".ProseMirror").getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + 20);
return !!el?.closest(".ProseMirror");
}),
"over the writing it is the writing that answers, not the ink layer"
);
await p.locator(".ProseMirror").click();
await p.keyboard.type("Getippt, obwohl die Notiz eine Zeichnung hat.");
await p.waitForTimeout(1800);
check(
(await p.locator(".ProseMirror").innerText()).includes("obwohl die Notiz eine Zeichnung hat"),
"and a note with a drawing can still be written in",
await p.locator(".ProseMirror").innerText()
);
/* Clearing acts on the page you are looking at, not on the whole drawing. */
await p.locator("button").filter({ hasText: "Draw" }).first().click();
await p.waitForTimeout(1200);
await p.locator('canvas[data-draw-page="1"]').scrollIntoViewIfNeeded();
await p.waitForTimeout(900);
await p.locator("[data-clear-page]").click();
await p.waitForTimeout(2500);
check(ink(await inkGrid(p, 1)) === 0, "the bin empties the page being looked at");
check(ink(await inkGrid(p, 2)) > 0, "and leaves the other pages alone");
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
+280 -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();
@@ -180,10 +218,124 @@ try {
nearEnd
);
/* ── the paper ──
* Each page is a sheet of its own with a gap after it, the way the PDF reader
* draws its pages. The gap is the part that has to be got right: the browser
* knows nothing about our sheets and would lay a line of text straight across
* it, so the text flow holds it open — see editor/pagination.js. */
check(
(await p.locator("[data-page-sheet]").count()) === Number((await position()).match(/of (\d+)/)[1]),
"there is a sheet of paper for every page",
`${await p.locator("[data-page-sheet]").count()} sheets, ${await position()}`
);
const straddling = await p.evaluate(() => {
const pm = document.querySelector(".ProseMirror");
const top = pm.getBoundingClientRect().top;
const sheets = [...document.querySelectorAll("[data-page-sheet]")].map((el) => el.getBoundingClientRect());
const gaps = sheets.slice(0, -1).map((r, i) => ({ from: r.bottom - top, to: sheets[i + 1].top - top }));
const bad = [];
for (const el of pm.children) {
if (el.classList.contains("ne-page-spacer")) continue;
const r = el.getBoundingClientRect();
if (r.height === 0) continue;
for (const g of gaps) {
if (r.bottom - top > g.from + 0.5 && r.top - top < g.to - 0.5) bad.push((el.textContent || "").slice(0, 30));
}
}
return bad;
});
check(straddling.length === 0, "and no line of text is cut in half by a gap", straddling.join(" | "));
check(
(await p.locator(".ne-page-spacer").count()) > 0,
"which takes holding the gap open inside the text itself"
);
/* The placeholder extension styles `[contenteditable]:empty::before`, and a
* spacer is exactly that. "Start writing..." was printed at the foot of every
* page that had a break after it. */
check(
await p.evaluate(() =>
[...document.querySelectorAll(".ne-page-spacer")].every(
(el) => getComputedStyle(el, "::before").content === "none"
)
),
"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;
check(!/"type"\s*:\s*"page"/.test(body), "no page ever reaches the stored document");
check(!/page-spacer/.test(body), "and neither does anything holding a gap open");
/* ── alignment ── */
// Left is the default and is never written into the document, so an untouched
@@ -222,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);
@@ -251,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
+2 -7
View File
@@ -184,14 +184,9 @@ export async function createNote(page) {
await page.waitForTimeout(1200);
}
/** Opens the search box in the note list and types into it. */
/** Types into the note list's search box, which is always there. */
export async function search(page, query) {
const box = page.locator('input[aria-label="Search in this notebook"]');
if ((await box.count()) === 0) {
await page.locator("[data-search-toggle]").click();
await page.waitForTimeout(400);
}
await box.fill(query);
await page.locator('input[aria-label="Search in this notebook"]').fill(query);
await page.waitForTimeout(1300);
}
+48
View File
@@ -131,6 +131,38 @@ try {
const status = await p.locator("body").innerText();
check(/offline|Pending|Wartet|Änderungen/i.test(status), "the app says it is offline rather than pretending");
/* ── the rest of what a note is, with no connection ──
* Reading and editing were covered. Making a note and drawing on one were
* not, and those are half of what the app is for on a phone — which is
* exactly where the connection is missing. */
const beforeNew = (await p.locator("[data-note-row]").count());
await createNote(p);
await p.waitForTimeout(1200);
await setTitle(p, "Offline entstanden");
await typeInEditor(p, "Ohne Netz geschrieben.");
await p.waitForTimeout(1500);
check(
(await p.locator("[data-note-row]").count()) > beforeNew,
"a note can be made with no connection",
`${beforeNew} → ${await p.locator("[data-note-row]").count()}`
);
await p.locator("button").filter({ hasText: "Draw" }).first().click();
await p.waitForTimeout(1200);
const canvas = await p.locator('canvas[data-draw-page="1"]').boundingBox();
await p.mouse.move(canvas.x + canvas.width * 0.3, canvas.y + canvas.height * 0.3);
await p.mouse.down();
await p.mouse.move(canvas.x + canvas.width * 0.7, canvas.y + canvas.height * 0.45, { steps: 12 });
await p.mouse.up();
await p.waitForTimeout(2500);
const inkOffline = await p.locator('canvas[data-draw-page="1"]').evaluate((c) => {
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let n = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) n++;
return n;
});
check(inkOffline > 100, "and drawn on, with no connection", `${inkOffline} inked pixels`);
/* ── back on ── */
await context.setOffline(false);
await p.waitForTimeout(1000);
@@ -145,6 +177,22 @@ try {
"and the edit made offline reaches the server once there is a connection",
String(stored.body.content).slice(0, 80)
);
const made = server.find((n) => n.title === "Offline entstanden");
check(!!made, "the note made offline reaches the server too", made ? made.id : "not there");
if (made) {
const madeBody = await api(p, "GET", `/notes/${made.id}`);
check(
String(madeBody.body.content).includes("Ohne Netz"),
"with what was written in it",
String(madeBody.body.content).slice(0, 80)
);
check(
(madeBody.body.drawingStrokes?.strokes?.length ?? 0) > 0,
"and with the drawing that was made on it",
JSON.stringify(Object.keys(madeBody.body.drawingStrokes || {}))
);
}
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
+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());
}
+429 -27
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,35 +127,79 @@ 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 arranged = await titles();
const under = await titles();
check(
arranged[0] === beforeDrag[2],
"dragging a note to the top puts it there",
`${beforeDrag.join(",")} → ${arranged.join(",")}`
under[1] === beforeDrag[2] && under[0] === beforeDrag[0],
"dropping a note on a row puts it under that row",
`${beforeDrag.join(",")} → ${under.join(",")}`
);
/* The point of the whole feature. */
// 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] === 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
* glass costs a click and having to remember that searching is possible; the
* box is one line and says for itself what it does. */
check(
(await p.locator('input[aria-label="Search in this notebook"]').count()) === 1,
"the search box is there without opening anything"
);
check(
(await p.locator("[data-search-toggle]").count()) === 0,
"and the button that used to open it is gone"
);
/* The point of the whole feature. Measured against the arrangement as it
stands now: the rows have been moved about since it was first made. */
const arrangedNow = await titles();
await search(p, "Beta");
const searched = await titles();
check(searched.length < 3, "searching narrows the list", searched.join(", "));
await search(p, "");
check(
(await titles()).join(",") === arranged.join(","),
(await titles()).join(",") === arrangedNow.join(","),
"and clearing the search shows exactly the arrangement again",
`${arranged.join(",")} vs ${(await titles()).join(",")}`
`${arrangedNow.join(",")} vs ${(await titles()).join(",")}`
);
/* Reordering must not look like editing: a dragged note keeps its place in
"last changed", or the two orders would fight each other. */
await sortBy("updated");
"last changed", or the two orders would fight each other.
Asked of the timestamps rather than of the order on screen. Three notes made
seconds apart routinely share an `updated_at` second — the column has that
resolution — and then "which is newest" is a tie broken arbitrarily. The
check passed on luck for weeks and started failing on a run that was a
fraction faster. */
const stamps = async () =>
Object.fromEntries((await api(p, "GET", "/notes")).body.map((n) => [n.title, n.updated_at]));
await sortBy("manual");
await p.waitForTimeout(600);
const beforeStamps = await stamps();
const order = await titles();
await drag(rows.nth(2), rows.nth(0));
const afterStamps = await stamps();
check(
(await titles())[0] === "Gamma",
"a dragged note did not become the most recently changed one",
(await titles()).join(", ")
Object.keys(beforeStamps).every((t) => beforeStamps[t] === afterStamps[t]),
"dragging a note does not mark it as changed",
`${JSON.stringify(beforeStamps)} → ${JSON.stringify(afterStamps)}`
);
check(
(await titles()).join(",") !== order.join(","),
"though it did move",
`${order.join(",")} → ${(await titles()).join(",")}`
);
await sortBy("manual");
await p.waitForTimeout(800);
@@ -146,25 +219,55 @@ try {
);
/* ── separators ── */
// The arrangement as it stands now: rows have been dragged since `arranged`.
const beforeSeparator = await titles();
await p.locator("[data-add-separator]").click();
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()
);
@@ -180,19 +283,147 @@ try {
const withSeparator = await titles();
check(
withSeparator.join(",") === arranged.join(","),
withSeparator.join(",") === beforeSeparator.join(","),
"and adding one did not disturb the arrangement",
`${arranged.join(",")} vs ${withSeparator.join(",")}`
`${beforeSeparator.join(",")} vs ${withSeparator.join(",")}`
);
// Separators only mean something in a hand-made order; in "newest first" the
// 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. */
@@ -213,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();
@@ -244,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();
@@ -371,6 +610,169 @@ try {
"and it lands in the notebook that was chosen, not the first one",
`${fresh?.notebook_id} vs ${target.id}`
);
/* ── choosing a cover ──
* Covers are patterns as well as colours now, and there are enough of them
* that the grid has to scroll: without that the dialog runs past the bottom of
* a laptop screen and takes the Create button with it. */
await p.setViewportSize({ width: 1440, height: 800 });
await p.locator("button").filter({ hasText: "Dashboard" }).first().click();
await p.waitForTimeout(600);
await p.getByRole("button", { name: "Add Notebook" }).click();
await p.waitForTimeout(700);
/* Colour and pattern are two choices, not one long list of combinations. */
check((await p.locator("[data-cover-option]").count()) >= 15, "a colour to choose", `${await p.locator("[data-cover-option]").count()}`);
check((await p.locator("[data-pattern-option]").count()) > 5, "and a pattern to lay over it", `${await p.locator("[data-pattern-option]").count()}`);
check(
await p.getByRole("button", { name: "Create", exact: true }).isVisible(),
"and the dialog still fits a laptop screen, Create and all"
);
// The two together make the cover, and both reach the server.
await p.getByPlaceholder("Notebook name...").fill("Kariert");
await p.locator('[data-cover-option="6"]').click();
await p.locator('[data-pattern-option="2"]').click();
await p.waitForTimeout(300);
await p.getByRole("button", { name: "Create", exact: true }).click();
await p.waitForTimeout(2000);
const books = (await api(p, "GET", "/notebooks")).body;
const made = books.find((b) => b.name === "Kariert");
check(
made?.cover_index === 6 && made?.cover_pattern === 2,
"the colour and the pattern are both stored",
`cover ${made?.cover_index}, pattern ${made?.cover_pattern}`
);
/* ── moving a row with a finger ──
* Last in the suite, because moving rows about would pull the ground from
* under every check above that names an order.
*
* The list used the HTML5 drag API, which mobile browsers never fire from
* touch: on a phone the arrangement could not be changed at all, and nothing
* on screen said why. A finger drags from the grip — pressing the row itself
* has to go on scrolling, and holding it has to go on opening the menu.
*
* The pointer events below are synthesised, because Playwright drives a mouse.
* They still exercise the branch that was missing: pointerType "touch".
*/
const touchDrag = async (fromIndex, toIndex) =>
p.evaluate(
([from, toIdx]) => {
const grip = document.querySelectorAll("[data-reorder-grip]")[from];
const to = document.querySelectorAll("[data-note-row]")[toIdx];
if (!grip || !to) return "missing";
const box = to.getBoundingClientRect();
const fire = (target, type, x, y) =>
target.dispatchEvent(
new PointerEvent(type, {
pointerType: "touch",
pointerId: 1,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
})
);
const start = grip.getBoundingClientRect();
fire(grip, "pointerdown", start.x + start.width / 2, start.y + start.height / 2);
fire(window, "pointermove", box.x + box.width / 2, box.y + box.height / 2 - 20);
fire(window, "pointermove", box.x + box.width / 2, box.y + box.height / 2);
fire(window, "pointerup", box.x + box.width / 2, box.y + box.height / 2);
return "done";
},
[fromIndex, toIndex]
);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1200);
await sortBy("manual");
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,
"with rearranging switched on, every row has a grip to take hold of"
);
await touchDrag(rowCount - 1, 0);
await p.waitForTimeout(2000);
check(
(await titles()).join(",") !== beforeTouch.join(","),
"a row can be moved with a finger, from its grip",
`${beforeTouch.join(",")} → ${(await titles()).join(",")}`
);
// And pressing the row itself must still scroll rather than drag.
const beforeBody = await titles();
await p.evaluate(() => {
const row = document.querySelector("[data-note-row]");
const b = row.getBoundingClientRect();
const fire = (t, type, y) =>
t.dispatchEvent(
new PointerEvent(type, { pointerType: "touch", pointerId: 2, clientX: b.x + 40, clientY: y, bubbles: true, cancelable: true })
);
fire(row, "pointerdown", b.y + b.height / 2);
fire(window, "pointermove", b.y + 260);
fire(window, "pointerup", b.y + 260);
});
await p.waitForTimeout(1200);
check(
(await titles()).join(",") === beforeBody.join(","),
"and pressing the row itself does not drag it — that gesture still scrolls",
`${beforeBody.join(",")} → ${(await titles()).join(",")}`
);
/* ── "Move to..." on a phone ──
* The submenu was pinned to `left: 100%` and opened only on hover. On a narrow
* screen the parent menu already sits against the right edge, so the list of
* notebooks appeared off the side of the display — and with no hover there was
* no way to open it in the first place. */
await createNotebook(p, "Anderes Buch");
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(900);
await p.setViewportSize({ width: 420, height: 780 });
await p.waitForTimeout(900);
// Below the breakpoint the sidebar stays open as a full-screen overlay whose
// backdrop swallows every click. Dismiss it by tapping beside the panel.
await p.mouse.click(408, 420);
await p.waitForTimeout(700);
await p.locator("[data-note-row]").first().click({ button: "right" });
await p.waitForTimeout(600);
const menu = p.locator("[data-context-menu]");
check((await menu.count()) === 1, "the menu opens on a phone-sized screen");
const inside = async (loc) => {
const b = await loc.boundingBox();
return !!b && b.x >= -1 && b.x + b.width <= 421;
};
check(await inside(menu), "and stays on the screen", JSON.stringify(await menu.boundingBox()));
await p.locator("[data-submenu-toggle]").first().click();
await p.waitForTimeout(500);
const sub = p.locator("[data-submenu]");
check((await sub.count()) === 1, "tapping 'Move to...' opens it — there is no hover on a phone");
check(
await inside(sub),
"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 });
await p.waitForTimeout(900);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
+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 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());
}
+22
View File
@@ -0,0 +1,22 @@
/* Throwaway: one wide screenshot of the writing surface, to look at it. */
import { OUT, launch, withFreshUser, createNotebook, createNote, typeInEditor } from "./lib.mjs";
const browser = await launch();
const session = await withFreshUser(browser, "shot");
const p = session.page;
await p.setViewportSize({ width: 1680, height: 900 });
await createNotebook(p, "Look");
await createNote(p);
await typeInEditor(p, "askjdnasjkdnkjasndjknkjaskjdnasjkdskajndjkanHelkl HALLLO");
await p.keyboard.press("Enter");
await p.keyboard.type("sdfsdfsdfsdfasddddd");
await p.waitForTimeout(1500);
await p.screenshot({ path: `${OUT}/look.png` });
const box = await p.evaluate(() => {
const pm = document.querySelector(".ProseMirror").getBoundingClientRect();
const pane = document.querySelector(".note-editor").parentElement.getBoundingClientRect();
return { pm: { x: Math.round(pm.x), w: Math.round(pm.width) }, pane: { x: Math.round(pane.x), w: Math.round(pane.width) } };
});
console.log(JSON.stringify(box));
await session.cleanup();
await browser.close();
+21 -1
View File
@@ -9,7 +9,7 @@
* A second browser context is a second device: its own storage, its own cache,
* its own session.
*/
import { newPage, signIn, check, createNotebook, launch, report, withFreshUser } from "./lib.mjs";
import { newPage, signIn, check, createNotebook, launch, report, search, withFreshUser } from "./lib.mjs";
import { writeFileSync } from "node:fs";
const PDF = "/tmp/sync-test.pdf";
@@ -71,6 +71,26 @@ try {
await signIn(phone, s.user, s.pass);
await phone.locator("button").filter({ hasText: "Sync" }).first().click();
await phone.waitForTimeout(1500);
/* ── found by what is written inside it, without ever opening it ──
* The text is read on the device that imports the file and stored with it, so
* this device can search a document whose bytes it has not even downloaded.
* It used to be extracted on first open and kept in that one browser, which
* made a PDF filed on the desktop invisible to a search on the phone. */
await phone.waitForTimeout(8000); // the backfill runs after the notes are in
await search(phone, "Handbuch Seite");
check(
(await phone.locator("[data-note-row]").count()) === 1,
"the second device finds the PDF by a phrase inside it, having never opened it",
`${await phone.locator("[data-note-row]").count()} rows`
);
await search(phone, "Wortdasnirgendwostehtxyz");
check(
(await phone.locator("[data-note-row]").count()) === 0,
"and finds nothing for a phrase that is not in it"
);
await search(phone, "");
await phone.locator("[data-note-row]").first().click();
await phone.waitForTimeout(6000);
+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);
}
+36 -2
View File
@@ -10,14 +10,48 @@
<meta name="apple-mobile-web-app-title" content="InkNote" />
<meta name="description" content="Write & draw — notes that sync everywhere" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="./manifest.json" />
<!-- `use-credentials` because the manifest is otherwise fetched with
credentials omitted, cookies and all. Behind an authenticating reverse
proxy that request comes back as the proxy's login page, the browser
reads it as an invalid manifest, and no install is ever offered — while
the app itself works perfectly, because its own requests do carry the
cookie. Harmless without a proxy. -->
<link rel="manifest" href="./manifest.json" crossorigin="use-credentials" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✏️</text></svg>" />
<title>InkNote</title>
<script>
/* Catch the browser's install offer before anything else runs.
*
* `beforeinstallprompt` is fired once, early — usually before React has
* mounted — and is never fired again. Listening for it from inside a
* component meant it was simply missed, and the install button never
* appeared however correctly the site was served. It is stashed here, at
* the top of the document, and the app picks it up when it is ready. */
window.__inknoteInstallPrompt = null;
window.addEventListener("beforeinstallprompt", function (e) {
e.preventDefault();
window.__inknoteInstallPrompt = e;
window.dispatchEvent(new Event("inknote:installable"));
});
</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; }
+23 -4
View File
@@ -1,4 +1,21 @@
const CACHE_NAME = 'inknote-v7';
/* Bumped to v8 to throw away anything cached under the old rules — including,
behind an authenticating reverse proxy, a login page that had been stored as
the application itself. */
const CACHE_NAME = 'inknote-v8';
/**
* Whether a response is the thing that was asked for, and worth keeping.
*
* Behind a proxy that authenticates in front of the app, a request whose session
* has lapsed comes back as that proxy's login page — sometimes as a redirect,
* sometimes as a plain 200. Storing either one poisons the cache: the app then
* "works offline" by showing a login screen it can never get past, and the only
* cure is clearing the site's data by hand.
*
* A redirected response cannot be cached at all — `Cache.put` rejects on one —
* so this also stops an unhandled rejection on every navigation.
*/
const worthCaching = (r) => r && r.ok && !r.redirected && r.type !== 'opaqueredirect';
// Install — don't precache, just activate immediately
self.addEventListener('install', () => {
@@ -28,8 +45,10 @@ self.addEventListener('fetch', (e) => {
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request).then(r => {
const clone = r.clone();
caches.open(CACHE_NAME).then(c => c.put(e.request, clone));
if (worthCaching(r)) {
const clone = r.clone();
caches.open(CACHE_NAME).then(c => c.put(e.request, clone)).catch(() => {});
}
return r;
}).catch(() => caches.match(e.request).then(r => r || caches.match('/')))
);
@@ -41,7 +60,7 @@ self.addEventListener('fetch', (e) => {
caches.open(CACHE_NAME).then(cache =>
cache.match(e.request).then(cached => {
const fetchP = fetch(e.request).then(r => {
if (r.ok) cache.put(e.request, r.clone());
if (worthCaching(r)) cache.put(e.request, r.clone()).catch(() => {});
return r;
}).catch(() => cached);
return cached || fetchP;
+634 -54
View File
@@ -7,11 +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 { COVERS, DEFAULT_FOLDERS } 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 { 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";
@@ -22,24 +26,69 @@ 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,
withNewSeparator,
} from "./features/notes/noteSort";
import { EditorPane } from "./features/notes/EditorPane.jsx";
import { HistoryModal } from "./features/notes/HistoryModal.jsx";
import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx";
import { CANON_W, CANON_H, PREVIEW_DRAWING_W } from "./features/drawing/strokes.js";
import {
CANON_W,
CANON_H,
PREVIEW_DRAWING_W,
drawnSheets,
sheetStrokes,
} from "./features/drawing/strokes.js";
/** Views that are not a notebook and therefore cannot hold a new note. */
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.
*
@@ -48,13 +97,43 @@ 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");
const [activeFolder, setActiveFolder] = useState("dashboard");
const [activeNote, setActiveNote] = useState(null);
/* Which chapter of the open notebook is being looked into; null is the
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);
@@ -71,35 +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, 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();
const { canInstall, promptInstall } = usePwaInstall();
/* 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);
const noteDnd = useNotebookDnd((dragId, targetId) => data.rearrange(activeFolder, dragId, targetId));
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();
@@ -139,6 +256,8 @@ export default function App() {
const openFolder = useCallback(
(id) => {
setActiveFolder(id);
setActiveChapter(null);
setInChapter(false);
setActiveNote(null);
if (isMobile) {
setSidebarOpen(false);
@@ -174,6 +293,50 @@ export default function App() {
/* ── Derived note lists ── */
const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]);
/* Chapters of the notebook in hand, with how many notes each holds — the
count is what makes a chapter worth opening rather than a guess. */
const folderChapters = useMemo(() => {
const list = data.chapters.filter((c) => c.notebookId === activeFolder);
return list.map((c) => ({
...c,
noteCount: notes.filter((n) => !n.trashed && n.chapterId === c.id).length,
}));
}, [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";
@@ -181,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
@@ -199,14 +362,58 @@ export default function App() {
/* ── Separators ──
* Lines the user draws between notes. They belong to the notebook's own
* arrangement, so they are stored on it and only ever shown in manual order. */
const separators = activeNotebook?.separators || [];
const allSeparators = activeNotebook?.separators || [];
/* Only the ones drawn in the list being looked at — the notebook itself, or
the chapter that is open. */
const separators = useMemo(
() => 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 writeIn = (chapterId, visible) =>
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== chapterId), ...visible]);
const writeVisible = (visible) => writeIn(listChapter, visible);
const addSeparator = () => writeSeparators(withNewSeparator(separators, `sep-${Date.now().toString(36)}`));
const renameSeparator = (id, label) =>
writeSeparators(separators.map((s) => (s.id === id ? { ...s, label } : s)));
const removeSeparator = (id) => writeSeparators(separators.filter((s) => s.id !== id));
/* 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)}`, "", 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) => {
e.preventDefault();
@@ -215,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",
@@ -237,12 +447,39 @@ 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 list = activeNotes.filter((n) => n.folderId === activeFolder);
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
@@ -255,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, 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
@@ -280,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)
@@ -312,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 ── */
@@ -392,20 +673,38 @@ export default function App() {
const createNote = () =>
withNotebook("New note", async (folderId) => {
const note = await data.createNote(folderId);
/* 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 ? 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({
@@ -491,16 +790,117 @@ export default function App() {
});
};
/** "Move to..." lists every notebook except the one the note is already in. */
/**
* "Move to..." — the other notebooks, and the chapters of this one.
*
* Both in one list because both answer the same question: where should this
* note live. A chapter of the notebook it is already in is one step; another
* notebook is another, and takes it out of whatever chapter it was in, since
* a chapter belongs to the book it is in.
*/
const moveSubmenu = (note) => {
const targets = notebooks
.filter((nb) => nb.id !== note.folderId)
.map((nb) => ({
label: nb.name,
colorDot: COVERS[nb.cover % COVERS.length].bg,
action: () => data.moveNote(note.id, nb.id),
}));
return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId);
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),
}))
);
}
// 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 }] : [];
};
/* ── Chapters ──
* 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. */
/**
* 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();
setCtxMenu({
x: e.clientX,
y: e.clientY,
items: [
{ label: "Edit", icon: I.edit, action: () => setEditingChapter(chapter) },
{ divider: true },
{
label: "Remove chapter",
icon: I.trash,
danger: true,
action: () =>
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: async () => {
if (activeChapter === chapter.id) {
setActiveChapter(null);
setInChapter(false);
}
await data.deleteChapter(chapter.id);
},
}),
},
],
});
};
const noteContextMenu = (e, note) => {
@@ -543,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,
@@ -554,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";
@@ -564,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,
@@ -589,8 +1035,8 @@ export default function App() {
<Sidebar
T={T}
user={user}
dark={dark}
onToggleDark={() => setDark(!dark)}
isMobile={isMobile}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
@@ -604,6 +1050,7 @@ export default function App() {
onNotebookContextMenu={notebookContextMenu}
dnd={dnd}
canInstall={canInstall}
installReason={installReason}
onInstall={promptInstall}
syncStatus={syncStatus}
online={sync.isOnline()}
@@ -643,14 +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}
dnd={noteDnd}
chapters={folderChapters}
/* 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}
@@ -660,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}
@@ -673,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)}
@@ -693,6 +1227,7 @@ export default function App() {
notebook={editingNb}
onClose={() => setEditingNb(null)}
onSave={(u) => updateNotebook(editingNb.id, u)}
defaultMaxVersions={user?.defaultMaxVersions}
dark={dark}
/>
)}
@@ -760,9 +1295,12 @@ export default function App() {
data.setNoteContent(historyFor, content);
setRestoreEpoch((e) => e + 1);
}}
renderDrawing={(strokes) => (
/* The first drawn page stands for the drawing in a thumbnail this
small. Asking for page one would show a blank sheet for a note whose
ink starts on page three. */
renderDrawing={(doc) => (
<DrawingLayer
strokes={strokes.strokes || []}
strokes={sheetStrokes(doc, drawnSheets(doc)[0] ?? 1)}
width={PREVIEW_DRAWING_W}
height={Math.round((PREVIEW_DRAWING_W * CANON_H) / CANON_W)}
zIndex={1}
@@ -770,12 +1308,54 @@ export default function App() {
)}
/>
)}
{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}
/>
)}
{confirmDialog && (
<ConfirmDialog
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"))}
+120 -20
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 {
@@ -37,19 +50,24 @@
text used to run the full width of the pane, past 84, which is beyond the
point where the eye reliably finds the start of the next line. Capped here
rather than on the container so it holds on a page as well as off one. */
max-width: 527px;
/* Left, not centred. A column that floats in the middle moves as the window
is resized, so the start of every line lands somewhere new; against the
margin it is always in the same place. */
margin-left: 0;
/* Roughly 80 characters to the line, leaving about 8% of the sheet as margin
on either side — a document's margin, not a picture frame. It was 527px,
which left 117px of empty paper down each edge and made the writing look
lost on the page. Past about 84 characters the eye stops reliably finding
the start of the next line, so this is close to as wide as it should go. */
max-width: 640px;
/* Centred on the sheet. Pinning the column to the left edge put every line in
the same place, but on a wide window it left the whole right half of the
pane as one dead block. */
margin-left: auto;
margin-right: auto;
outline: none;
color: var(--ne-text);
/* 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;
@@ -57,6 +75,24 @@
.note-editor .ProseMirror > * + * {
margin-top: 0.6em;
}
/* The gap between two sheets, held open inside the text flow so no paragraph is
cut in half by it. Margins of its own would make the space it adds depend on
its neighbours — see pagination.js, which computes the height on the
assumption that it has none. */
.note-editor .ProseMirror > .ne-page-spacer {
margin: 0 !important;
padding: 0;
border: none;
pointer-events: none;
user-select: none;
}
/* The placeholder extension styles `[contenteditable]:empty::before`, and a
spacer is exactly that: empty, and not editable. So "Start writing..." was
printed at the foot of every page that had a break after it. */
.note-editor .ProseMirror > .ne-page-spacer::before,
.note-editor .ProseMirror > .ne-page-spacer::after {
content: none !important;
}
.note-editor .ProseMirror p {
margin: 0;
}
@@ -90,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);
@@ -144,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;
@@ -161,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: "";
@@ -172,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;
@@ -196,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 {
+15 -1
View File
@@ -13,9 +13,18 @@ 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";
export function buildExtensions({ search = () => [], placeholder = "Start writing..." } = {}) {
export function buildExtensions({
search = () => [],
placeholder = "Start writing...",
// Where the page ends, read fresh on every measurement so a resize is enough
// to re-break the text. Absent when there is no page to break against — the
// static list used for parsing, for one.
getGeometry = null,
} = {}) {
return [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
@@ -40,6 +49,11 @@ export function buildExtensions({ search = () => [], placeholder = "Start writin
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) => {
+349
View File
@@ -0,0 +1,349 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
/**
* Where one page ends and the next begins.
*
* The writing surface is drawn as separate sheets with a gap between them, the
* way the PDF reader draws its pages. Text, however, is one continuous flow: the
* browser knows nothing about our sheets and would happily lay a line of text
* across the gap, cutting it in half.
*
* So the flow is measured and blocks that would straddle a gap are pushed past
* it, by inserting a spacer of exactly the right height in front of them. The
* spacers are **decorations, not content**: they never enter the document, never
* reach the server, and never appear in an export. Markdown has no page, and a
* 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 **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);
return Number.isFinite(n) ? n : 0;
};
/**
* The spacer element.
*
* `delta` is how much the layout actually shifts, which is not the same as the
* element's height — see `collapse` below. It is written onto the element
* because the next measurement has to subtract it again to recover where the
* text would sit without any spacers at all.
*/
function makeSpacer(height, delta) {
const el = document.createElement("div");
el.className = SPACER_CLASS;
el.setAttribute("aria-hidden", "true");
el.contentEditable = "false";
el.dataset.delta = String(delta);
el.style.height = `${Math.max(0, Math.round(height))}px`;
return el;
}
/**
* Reads the current layout and works out where the breaks belong.
*
* 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;
const dom = view.dom;
if (!dom?.isConnected) return null;
const style = getComputedStyle(dom);
const padTop = px(style.paddingTop);
const padBottom = px(style.paddingBottom);
const usable = pageHeight - padTop - padBottom;
if (usable <= 0) return null;
const domTop = dom.getBoundingClientRect().top;
// 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
* spacers seen so far undoes the last pass and leaves the natural layout —
* which is what the simulation below has to start from. */
const blocks = [];
let seen = 0;
let index = 0;
for (const child of dom.children) {
if (child.classList?.contains(SPACER_CLASS)) {
seen += Number(child.dataset.delta) || 0;
continue;
}
if (index >= nodes.length) break;
const { node, offset } = nodes[index];
const rect = child.getBoundingClientRect();
const cs = getComputedStyle(child);
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++;
}
const breaks = [];
let shift = 0;
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;
const page = Math.floor(top / pitch);
const pageEnd = page * pitch + pageHeight - padBottom;
// Half a pixel of slack: a block that ends exactly on the line has not
// crossed it, and rounding should not cost a page.
if (top + block.height <= pageEnd + 0.5) continue;
if (i === 0) continue; // nothing above it to be pushed past
const delta = (page + 1) * pitch + padTop - top;
if (delta <= 0) continue;
/* Margins that used to collapse into each other no longer do, because the
* spacer sits between them. The layout gains that much on top of the
* spacer's own height, so the spacer is made exactly that much shorter. */
const collapse = Math.min(blocks[i - 1].marginBottom, block.marginTop);
breaks.push({ pos: block.pos, delta, height: Math.max(0, delta - collapse) });
shift += delta;
}
return breaks;
}
const sameBreaks = (a, b) =>
!!a &&
!!b &&
a.length === b.length &&
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,
() => (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.row ? "row" : "block"}-${b.pos}-${Math.round(b.height)}`,
side: -1,
ignoreSelection: true,
marks: [],
}
)
)
);
export function createPagination(getGeometry) {
return new Plugin({
key: paginationKey,
state: {
init: () => DecorationSet.empty,
apply(tr, old) {
const next = tr.getMeta(paginationKey);
if (next) return next;
// Mapped rather than recomputed: typing moves the breaks with the text
// until the measurement below catches up, so they do not jump about.
return old.map(tr.mapping, tr.doc);
},
},
props: {
decorations: (state) => paginationKey.getState(state),
},
view(view) {
let last = null;
let frame = 0;
const measure = () => {
frame = 0;
if (view.isDestroyed) return;
const breaks = computeBreaks(view, getGeometry?.());
if (!breaks || sameBreaks(last, breaks)) return;
last = breaks;
view.dispatch(
view.state.tr
.setMeta(paginationKey, decorationsFor(view.state.doc, breaks))
// Not an edit: it must not be a step in the undo history, and it
// must not mark the note dirty.
.setMeta("addToHistory", false)
);
};
const schedule = () => {
if (frame) return;
frame = requestAnimationFrame(measure);
};
/* Heights change for reasons no transaction reports: a webfont finishing
* loading, an image arriving, the window being resized. Watching the
* element itself catches all three. */
const observer =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(schedule) : null;
observer?.observe(view.dom);
document.fonts?.ready?.then(schedule).catch(() => {});
schedule();
return {
update: schedule,
destroy() {
if (frame) cancelAnimationFrame(frame);
observer?.disconnect();
},
};
},
});
}
/** The extension wrapper, so this can sit in the list with everything else. */
export const Pagination = Extension.create({
name: "pagination",
addOptions() {
return { getGeometry: null };
},
addProseMirrorPlugins() {
return [createPagination(this.options.getGeometry)];
},
});
export default Pagination;
+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) }),
};
},
});
+100 -22
View File
@@ -43,7 +43,15 @@ function insertDroppedImages(view, transfer, at, onErrorRef) {
* The instance is returned rather than kept inside a component because the
* toolbar lives elsewhere in the tree and needs the same editor to drive it.
*/
export function useNoteEditor({ noteId, content, editable = true, onChange, searchLinkTargets, onError }) {
export function useNoteEditor({
noteId,
content,
editable = true,
onChange,
searchLinkTargets,
onError,
pageGeometry,
}) {
// Held in refs so a changing callback identity never rebuilds the editor,
// which would drop the cursor and the undo history mid-typing.
const searchRef = useRef(searchLinkTargets);
@@ -53,8 +61,17 @@ export function useNoteEditor({ noteId, content, editable = true, onChange, sear
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
// Read through a ref rather than captured: the page changes size with the
// window, and rebuilding the editor to tell it so would drop the cursor.
const geometryRef = useRef(pageGeometry);
geometryRef.current = pageGeometry;
const extensions = useMemo(
() => buildExtensions({ search: (query) => (searchRef.current ? searchRef.current(query) : []) }),
() =>
buildExtensions({
search: (query) => (searchRef.current ? searchRef.current(query) : []),
getGeometry: () => geometryRef.current,
}),
[]
);
@@ -106,7 +123,7 @@ export function useNoteEditor({ noteId, content, editable = true, onChange, sear
}
/** 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.
*
@@ -116,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) => {
@@ -152,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 (
@@ -163,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>
+56 -19
View File
@@ -10,7 +10,15 @@
import * as sync from "../syncEngine";
import { parseNoteContent } from "../editor/content";
import { parsingExtensions } from "../editor/extensions.js";
import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js";
import {
CANON_W,
CANON_H,
allStrokes,
drawnSheets,
sheetStrokes,
paintStrokeDoc,
sizeCanvas,
} from "../features/drawing/strokes.js";
import type { NoteContent, NoteMeta, Notebook } from "../types";
import { noteToMarkdown, safeFilename } from "./markdown";
import { asPdfStrokeDoc, hasOverlay } from "../features/pdf/pageStrokes";
@@ -31,18 +39,18 @@ type Loaded = { meta: NoteMeta; content: NoteContent };
/* ── pieces ── */
/** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */
async function drawingToPng(doc: unknown): Promise<Uint8Array | null> {
/** Renders one page of a stroke document to PNG bytes. */
async function drawingToPng(doc: unknown, page = 1): Promise<Uint8Array | null> {
// `doc` is already a stroke document as stored — strokeDocOf() builds one from
// a bare array and would wrap this one a second time, hiding the strokes.
if (docStrokes(doc).length === 0) return null;
if (sheetStrokes(doc, page).length === 0) return null;
const canvas = document.createElement("canvas");
const height = Math.round((DRAWING_EXPORT_W * CANON_H) / CANON_W);
const { bw, bh } = sizeCanvas(canvas, DRAWING_EXPORT_W, height);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
paintStrokeDoc(ctx, doc, bw, bh);
paintStrokeDoc(ctx, doc, bw, bh, page);
// Strokes are drawn on nothing, which would export as a transparent PNG and
// leave black ink invisible in a dark image viewer. The paper goes in behind
// afterwards, because paintStrokeDoc begins by clearing the canvas.
@@ -57,6 +65,22 @@ async function drawingToPng(doc: unknown): Promise<Uint8Array | null> {
return new Uint8Array(await blob.arrayBuffer());
}
/**
* Every drawn page of a note, as PNGs with the name each should be filed under.
*
* Page one keeps the name a drawing has always had, so an archive written before
* notes could be drawn on more than one page still reads the same. The rest are
* numbered after it.
*/
async function drawingPages(doc: unknown, base: string): Promise<Array<{ name: string; data: Uint8Array }>> {
const out: Array<{ name: string; data: Uint8Array }> = [];
for (const page of drawnSheets(doc)) {
const data = await drawingToPng(doc, page);
if (data) out.push({ name: page === 1 ? `${base}.png` : `${base}-${page}.png`, data });
}
return out;
}
/** Every attachment id the document refers to. */
function attachmentIds(doc: unknown): string[] {
const ids = new Set<string>();
@@ -133,24 +157,35 @@ export type ExportParts = "text" | "drawing" | "both";
export async function noteHasDrawing(meta: NoteMeta): Promise<boolean> {
try {
const { content } = await loadNote(meta);
return docStrokes(content.drawingStrokes).length > 0;
return allStrokes(content.drawingStrokes).length > 0;
} catch {
return false;
}
}
/** The drawing on its own, as a PNG on white paper. */
/**
* The drawing on its own, on white paper.
*
* One page is a PNG, as it always was. Several are a ZIP: a drawing spread over
* four pages is four pictures, and silently exporting only the first would lose
* the rest without saying so.
*/
export async function exportDrawingAsPng(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta);
const png = await drawingToPng(content.drawingStrokes);
if (!png) throw new Error("This note has no drawing.");
download(bytesToBlob(png, "image/png"), `${safeFilename(meta.title)}.png`);
const name = safeFilename(meta.title);
const pages = await drawingPages(content.drawingStrokes, name);
if (pages.length === 0) throw new Error("This note has no drawing.");
if (pages.length === 1) {
download(bytesToBlob(pages[0]!.data, "image/png"), `${name}.png`);
return;
}
download(createZip(pages.map((p) => ({ name: p.name, data: p.data }))), `${name}.zip`);
}
export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
const hasDrawing = allStrokes(content.drawingStrokes).length > 0;
const ids = attachmentIds(doc);
const md = noteToMarkdown(doc, {
@@ -173,8 +208,7 @@ export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
const bytes = await fetchAttachment(id);
if (bytes) entries.push({ name: `${folder}-images/${id}.png`, data: bytes });
}
const png = await drawingToPng(content.drawingStrokes);
if (png) entries.push({ name: `${folder}-drawing.png`, data: png });
entries.push(...(await drawingPages(content.drawingStrokes, `${folder}-drawing`)));
download(createZip(entries), `${folder}.zip`);
}
@@ -220,10 +254,14 @@ export async function exportNotesAsPdf(
}
}
const png = parts === "text" ? null : await drawingToPng(content.drawingStrokes);
const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null;
const drawings: string[] = [];
if (parts !== "text") {
for (const page of await drawingPages(content.drawingStrokes, "page")) {
drawings.push(await blobToDataUrl(bytesToBlob(page.data, "image/png")));
}
}
printable.push({ meta, content, drawing, images, includeText: parts !== "drawing" });
printable.push({ meta, content, drawings, images, includeText: parts !== "drawing" });
} catch {
/* an unreadable note is left out rather than aborting the whole print */
}
@@ -292,7 +330,7 @@ export async function exportNotesAsZip({
try {
const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
const hasDrawing = allStrokes(content.drawingStrokes).length > 0;
entries.push({
name: `${name}.md`,
@@ -310,8 +348,7 @@ export async function exportNotesAsZip({
const bytes = await fetchAttachment(id);
if (bytes) entries.push({ name: `images/${id}.png`, data: bytes });
}
const png = await drawingToPng(content.drawingStrokes);
if (png) entries.push({ name: `drawings/${name}.png`, data: png });
entries.push(...(await drawingPages(content.drawingStrokes, `drawings/${name}`)));
} catch (err) {
// A note that cannot be read is named in the index rather than skipped in
// silence — an export that quietly drops things is worse than useless.
+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.");
+18 -3
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; }
@@ -145,8 +158,8 @@ const STYLES = `
export interface PrintableNote {
meta: NoteMeta;
content: NoteContent;
/** Drawing rendered as a data URL, if the note has one and it was asked for. */
drawing?: string | null;
/** The drawing's pages as data URLs, in order, if it was asked for. */
drawings?: string[] | null;
/** Attachment id → data URL, for images to survive into the print document. */
images?: Map<string, string>;
/** Omitted for a drawing-only export, where the text would be in the way. */
@@ -165,7 +178,9 @@ function articleFor(note: PrintableNote): string {
<h1>${esc(note.meta.title || "Untitled")}</h1>
${when ? `<div class="meta">${esc(when)}</div>` : ""}
${body}
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Drawing"></div>` : ""}
${(note.drawings ?? [])
.map((src, i) => `<div class="drawing"><img src="${src}" alt="Drawing, page ${i + 1}"></div>`)
.join("\n ")}
</article>`;
}
+94 -7
View File
@@ -10,6 +10,15 @@ export const CANON_W = 1200,
CANON_H = 1600;
export const PREVIEW_DRAWING_W = 260; // history preview thumbnail
export const STROKES_VERSION = 1;
/* A drawing spread over more than one page.
*
* A note used to have exactly one sheet to draw on, which stopped making sense
* once the writing could run to four pages: the ink was stuck on the first one
* while the text carried on without it. Pages are numbered the way the PDF
* reader numbers them, and each keeps its own strokes in the same canonical
* space — so page 3 of a note means the same thing on a phone as on a desktop.
*/
export const SHEETS_VERSION = 2;
// Widths are canonical units, so a "Medium" pen covers the same fraction of the
// page everywhere. At the 760px desktop page width, M lands at roughly 3.5 CSS px.
@@ -41,10 +50,86 @@ export const strokeDocOf = (strokes, t) => ({
strokes,
...(t ? { t } : {}),
});
export const docStrokes = (doc) => (Array.isArray(doc?.strokes) ? doc.strokes : []);
export const sameStrokeDoc = (a, b) =>
a === b ||
(docStrokes(a).length === docStrokes(b).length && docStrokes(a).every((s, i) => s === docStrokes(b)[i]));
/** The strokes on one canvas — page one, for a drawing that has several. */
export const docStrokes = (doc) => sheetStrokes(doc, 1);
/**
* A drawing's pages, keyed by 1-based page number.
*
* Reads either shape, so a note drawn before there were pages needs no
* migration: its single list of strokes is page one, which is where it was.
* Empty pages are dropped rather than stored, so "which pages are drawn on" is
* simply the keys.
*/
export function noteSheets(doc) {
if (!doc || typeof doc !== "object") return {};
const source =
doc.sheets && typeof doc.sheets === "object"
? doc.sheets
: Array.isArray(doc.strokes)
? { 1: doc.strokes }
: {};
const out = {};
for (const [key, strokes] of Object.entries(source)) {
const page = Number(key);
if (!Number.isInteger(page) || page < 1) continue;
if (!Array.isArray(strokes) || strokes.length === 0) continue;
out[String(page)] = strokes;
}
return out;
}
export const sheetStrokes = (doc, page) => noteSheets(doc)[String(page)] ?? [];
/** Page numbers carrying ink, in reading order. */
export const drawnSheets = (doc) =>
Object.keys(noteSheets(doc))
.map(Number)
.sort((a, b) => a - b);
/** Every stroke in the drawing — for "is there anything here at all". */
export const allStrokes = (doc) => {
const sheets = noteSheets(doc);
return drawnSheets(doc).flatMap((page) => sheets[String(page)]);
};
/**
* Builds a stroke document from pages.
*
* A drawing that lives entirely on page one is written in the old single-page
* shape. Nothing needs it to be — but it means an ordinary note's drawing is
* stored exactly as it always was, and a client that has not been updated yet
* still shows it.
*/
export function sheetDocOf(sheets, t) {
const clean = noteSheets({ sheets });
const pages = Object.keys(clean);
if (pages.length === 0 || (pages.length === 1 && pages[0] === "1")) {
return strokeDocOf(clean["1"] ?? [], t);
}
return { v: SHEETS_VERSION, w: CANON_W, h: CANON_H, sheets: clean, ...(t ? { t } : {}) };
}
/** The pages of `doc` with one page replaced — an empty page is removed. */
export function sheetsWith(doc, page, strokes) {
const sheets = { ...noteSheets(doc) };
if (strokes?.length) sheets[String(page)] = strokes;
else delete sheets[String(page)];
return sheets;
}
/* Compared page by page. Flattening first would call a stroke moved from page
* two to page three "unchanged", and the move would never be saved. */
export const sameStrokeDoc = (a, b) => {
if (a === b) return true;
const x = noteSheets(a),
y = noteSheets(b);
const keys = Object.keys(x);
if (keys.length !== Object.keys(y).length) return false;
return keys.every(
(k) => y[k] && x[k].length === y[k].length && x[k].every((s, i) => s === y[k][i])
);
};
export function paintStroke(ctx, s) {
const p = s?.p;
@@ -77,8 +162,10 @@ export function paintStroke(ctx, s) {
ctx.globalCompositeOperation = "source-over";
}
// Replays a whole drawing into a context of targetW × targetH device pixels.
export function paintStrokeDoc(ctx, doc, targetW, targetH) {
// Replays one page of a drawing into a context of targetW × targetH device
// pixels. Single-page drawings are page one, which is what every caller that
// never heard of pages asks for.
export function paintStrokeDoc(ctx, doc, targetW, targetH, page = 1) {
const w = doc?.w || CANON_W,
h = doc?.h || CANON_H;
ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -86,7 +173,7 @@ export function paintStrokeDoc(ctx, doc, targetW, targetH) {
ctx.scale(targetW / w, targetH / h);
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const s of docStrokes(doc)) paintStroke(ctx, s);
for (const s of sheetStrokes(doc, page)) paintStroke(ctx, s);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import {
CANON_W,
CANON_H,
STROKES_VERSION,
SHEETS_VERSION,
allStrokes,
docStrokes,
drawnSheets,
noteSheets,
sameStrokeDoc,
sheetDocOf,
sheetStrokes,
sheetsWith,
strokeDocOf,
} from "./strokes.js";
/* `strokes.js` is untyped, so what comes back is a union TypeScript cannot see
* through. These two keep the tests about behaviour rather than about casts. */
interface Stroke {
label: string;
}
interface Doc {
v: number;
t?: string;
strokes?: Stroke[];
sheets?: Record<string, Stroke[]>;
}
const stroke = (label: string): Stroke =>
({ t: "pen", c: "#000", s: 3, p: [0, 0, 1, 1], label }) as Stroke;
const asDoc = (doc: unknown) => doc as Doc;
describe("reading a drawing", () => {
it("treats a note drawn before there were pages as page one", () => {
const a = stroke("a");
const doc = strokeDocOf([a]);
expect(noteSheets(doc)).toEqual({ 1: [a] });
expect(sheetStrokes(doc, 1)).toEqual([a]);
expect(sheetStrokes(doc, 2)).toEqual([]);
});
it("reads a drawing kept per page", () => {
const doc = { v: SHEETS_VERSION, w: CANON_W, h: CANON_H, sheets: { 1: [stroke("a")], 3: [stroke("c")] } };
expect(drawnSheets(doc)).toEqual([1, 3]);
expect(sheetStrokes(doc, 3)).toEqual([stroke("c")]);
});
it("has no pages at all for nothing, an empty list or rubbish", () => {
for (const doc of [null, undefined, {}, { strokes: [] }, { sheets: {} }, 7, "x"]) {
expect(drawnSheets(doc)).toEqual([]);
expect(allStrokes(doc)).toEqual([]);
}
});
it("ignores pages that are not pages", () => {
const good = stroke("good");
const doc = { sheets: { 1: [good], 0: [stroke("zero")], "-2": [stroke("neg")], x: [stroke("x")], 2: "no" } };
expect(drawnSheets(doc)).toEqual([1]);
expect(allStrokes(doc)).toEqual([good]);
});
it("puts every stroke in reading order, page ten after page nine", () => {
const doc = { sheets: { 10: [stroke("j")], 9: [stroke("i")], 1: [stroke("a")] } };
expect(allStrokes(doc).map((s: Stroke) => s.label)).toEqual(["a", "i", "j"]);
});
it("still answers page one for callers that never heard of pages", () => {
const a = stroke("a");
expect(docStrokes({ sheets: { 1: [a], 2: [stroke("b")] } })).toEqual([a]);
});
});
describe("writing a drawing", () => {
it("keeps the old single-page shape when everything is on page one", () => {
const doc = asDoc(sheetDocOf({ 1: [stroke("a")] }));
expect(doc.v).toBe(STROKES_VERSION);
expect(doc.strokes).toHaveLength(1);
expect(doc.sheets).toBeUndefined();
});
it("writes pages once there is more than the first", () => {
const doc = asDoc(sheetDocOf({ 1: [stroke("a")], 2: [stroke("b")] }));
expect(doc.v).toBe(SHEETS_VERSION);
expect(Object.keys(doc.sheets!)).toEqual(["1", "2"]);
expect(doc.strokes).toBeUndefined();
});
it("writes pages for ink that starts on page two", () => {
const doc = asDoc(sheetDocOf({ 2: [stroke("b")] }));
expect(doc.v).toBe(SHEETS_VERSION);
expect(sheetStrokes(doc, 2)).toHaveLength(1);
});
it("is an empty single-page drawing when nothing is drawn", () => {
const doc = asDoc(sheetDocOf({}));
expect(doc.strokes).toEqual([]);
expect(allStrokes(doc)).toEqual([]);
});
it("carries the save token through either shape", () => {
expect(asDoc(sheetDocOf({ 1: [stroke("a")] }, "tok")).t).toBe("tok");
expect(asDoc(sheetDocOf({ 1: [stroke("a")], 2: [stroke("b")] }, "tok")).t).toBe("tok");
});
it("drops a page that has been emptied rather than storing it", () => {
const doc = { sheets: { 1: [stroke("a")], 2: [stroke("b")] } };
expect(drawnSheets({ sheets: sheetsWith(doc, 2, []) })).toEqual([1]);
expect(drawnSheets({ sheets: sheetsWith(doc, 1, []) })).toEqual([2]);
});
it("replaces one page and leaves the others alone", () => {
const doc = { sheets: { 1: [stroke("a")], 2: [stroke("b")] } };
const next = sheetsWith(doc, 2, [stroke("c"), stroke("d")]) as Record<string, Stroke[]>;
expect(next["1"]).toEqual([stroke("a")]);
expect(next["2"]).toHaveLength(2);
});
});
describe("noticing a change", () => {
it("calls a drawing the same as itself", () => {
const doc = sheetDocOf({ 1: [stroke("a")] });
expect(sameStrokeDoc(doc, doc)).toBe(true);
});
it("sees the same strokes through either shape", () => {
const a = stroke("a");
expect(sameStrokeDoc(strokeDocOf([a]), sheetDocOf({ 1: [a] }))).toBe(true);
});
it("sees a stroke moved to another page", () => {
const a = stroke("a");
// Flattening first would call this unchanged, and the move would never save.
expect(sameStrokeDoc(sheetDocOf({ 2: [a] }), sheetDocOf({ 3: [a] }))).toBe(false);
});
it("sees a page added and a page emptied", () => {
const one = sheetDocOf({ 1: [stroke("a")] });
expect(sameStrokeDoc(one, sheetDocOf({ 1: [stroke("a")], 2: [stroke("b")] }))).toBe(false);
expect(sameStrokeDoc(one, sheetDocOf({}))).toBe(false);
});
});
+115 -89
View File
@@ -1,4 +1,4 @@
import { COVERS } from "../../ui/constants.jsx";
import { coverBackground } from "../../ui/constants.jsx";
import { I } from "../../ui/icons.jsx";
import { NotebookCover } from "../../ui/NotebookCover.jsx";
import { fmtDate } from "../../ui/format";
@@ -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: COVERS[nb.cover % COVERS.length].bg,
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()}
+317 -146
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";
@@ -14,13 +14,42 @@ import {
MIN_POINT_DIST,
newSaveToken,
strokeDocOf,
docStrokes,
sheetDocOf,
noteSheets,
sheetStrokes,
sheetsWith,
drawnSheets,
paintStroke,
paintStrokeDoc,
sizeCanvas,
} 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";
@@ -33,10 +62,10 @@ export function NoteEditor({
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
resolveLinkIcon,
backlinks,
}) {
const [mode, setMode] = useState("text");
const canvasRef = useRef(null);
const [tool, setTool] = useState("pen");
const [penColor, setPenColor] = useState(dark ? "#e6edf3" : "#1a1a2e");
const [penSizeIdx, setPenSizeIdx] = useState(DEFAULT_PEN_SIZE);
@@ -63,33 +92,50 @@ export function NoteEditor({
const scrollerRef = useRef(null);
/* ── DRAWING STATE ──
* strokesRef is what the pointer handlers read and write synchronously;
* `strokes` mirrors it so React can repaint the read-only layer. */
const strokesRef = useRef([]);
const [strokes, setStrokes] = useState([]);
* sheetsRef is what the pointer handlers read and write synchronously;
* `sheets` mirrors it so React can repaint the read-only layers. Both are the
* drawing's pages: page number → strokes, with drawn-on pages only. */
const sheetsRef = useRef({});
const [sheets, setSheets] = useState({});
const liveRef = useRef(null); // stroke currently under the pointer
const drawingRef = useRef(false);
const committedRef = useRef(null); // offscreen canvas holding all finished strokes
const drawPageRef = useRef(1); // the page that stroke is being drawn on
const canvasesRef = useRef(new Map()); // page number → its canvas
const committedRef = useRef(null); // offscreen copy of the page being drawn on
const appliedDocRef = useRef(undefined);
const pendingTokenRef = useRef(null);
const hasStrokes = strokes.length > 0;
const drawnPages = useMemo(() => drawnSheets({ sheets }), [sheets]);
const hasStrokes = drawnPages.length > 0;
// Old notes may still carry a raster drawing. It stays visible underneath any
// new vector strokes so migrating a note never loses what was already drawn.
const legacyRaster = note.drawingData && !hideDrawing;
/* Whether this note needs a page at all.
*
* The fixed 3:4 stage exists for the canvas: a stroke has to land in the same
* place on every screen, which needs one agreed page. Writing needs no such
* thing, and paid for it with a bordered box and an empty page below two lines
* of text. So the page appears exactly when something is on it — or about to
* be — and plain text is simply text. */
const needsPage = mode === "draw" || hasStrokes || !!legacyRaster;
/* 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;
const commitStrokes = (next) => {
strokesRef.current = next;
setStrokes(next);
/* 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 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: PAGE_H, gap: PAGE_GAP, scale }),
[scale]
);
const commitSheets = (next) => {
sheetsRef.current = next;
setSheets(next);
};
const pageStrokes = (page) => sheetsRef.current[String(page)] ?? [];
const T = useMemo(
() => ({
@@ -100,10 +146,6 @@ export function NoteEditor({
muted: dark ? "#8b949e" : "#656d76",
active: dark ? "#388bfd" : "#0969da",
faint: dark ? "#484f58" : "#afb8c1",
/* A shade apart from the desk it lies on — enough to read as a surface,
not so much that it glares in a dark room. */
pageBg: dark ? "#131922" : "#ffffff",
desk: dark ? "#0b0f14" : "#e9ecef",
}),
[dark]
);
@@ -116,6 +158,7 @@ export function NoteEditor({
content: note.content,
editable: mode === "text",
searchLinkTargets,
pageGeometry,
onChange: ({ content, links }) => onUpdate({ content, links }),
onError: (msg) => {
setEditorNotice(msg);
@@ -168,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;
@@ -202,7 +252,7 @@ export function NoteEditor({
const saveDrawingNow = () => {
if (!dirtyRef.current) return;
const token = newSaveToken();
const doc = strokeDocOf(strokesRef.current, token);
const doc = sheetDocOf(sheetsRef.current, token);
appliedDocRef.current = doc; // ignore the echo coming straight back through props
pendingTokenRef.current = token; // ...and any server read still in flight
dirtyRef.current = false;
@@ -223,17 +273,26 @@ export function NoteEditor({
const surface = () => scrollerRef.current?.querySelector(".ProseMirror");
const recount = () => {
const el = surface();
const pageH = stageSize.h;
if (!el || !pageH) return;
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;
const count = Math.max(1, Math.ceil(height / pageH));
/* Counted in whole pitches — a page and the gap after it — because that is
* what the text is broken against. Drawn pages count too: ink put on page
* 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 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 / pageH) + 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 }));
};
@@ -248,11 +307,14 @@ export function NoteEditor({
scroller?.removeEventListener("scroll", recount);
textEditor.off("transaction", recount);
};
}, [textEditor, stageSize.h, mode]);
}, [textEditor, stageSize.h, scale, mode, drawnPages]);
/* ── DRAWING: painting ── */
/* ── DRAWING: painting ──
* Only the page under the pointer is repainted while a stroke is being drawn;
* the others cannot have changed, and repainting four canvases per pointer
* move on a phone is felt. */
const renderFrame = () => {
const c = canvasRef.current;
const c = canvasesRef.current.get(drawPageRef.current);
if (!c) return;
const ctx = c.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -268,23 +330,30 @@ export function NoteEditor({
ctx.restore();
}
};
// Repaint the offscreen canvas from scratch, then show it. Cheap enough at
// stroke boundaries, and it keeps eraser strokes ordered correctly.
const repaintAll = () => {
const c = canvasRef.current;
// Repaint one page's offscreen canvas from scratch, then show it. Cheap enough
// at stroke boundaries, and it keeps eraser strokes ordered correctly.
const repaintPage = (page) => {
const c = canvasesRef.current.get(page);
if (!c) return;
const { bw, bh } = sizeCanvas(c, stageSize.w, stageSize.h);
let off = committedRef.current;
if (!off) {
off = document.createElement("canvas");
committedRef.current = off;
if (page === drawPageRef.current) {
let off = committedRef.current;
if (!off) {
off = document.createElement("canvas");
committedRef.current = off;
}
if (off.width !== bw || off.height !== bh) {
off.width = bw;
off.height = bh;
}
paintStrokeDoc(off.getContext("2d"), strokeDocOf(pageStrokes(page)), bw, bh);
renderFrame();
return;
}
if (off.width !== bw || off.height !== bh) {
off.width = bw;
off.height = bh;
}
paintStrokeDoc(off.getContext("2d"), strokeDocOf(strokesRef.current), bw, bh);
renderFrame();
paintStrokeDoc(c.getContext("2d"), strokeDocOf(pageStrokes(page)), bw, bh);
};
const repaintAll = () => {
for (const page of canvasesRef.current.keys()) repaintPage(page);
};
/* Adopt strokes arriving from props (note switch, or a sync push from another
@@ -301,7 +370,7 @@ export function NoteEditor({
pendingTokenRef.current = null; // our own save echoed back; in sync again
return;
}
commitStrokes(docStrokes(doc));
commitSheets(noteSheets(doc));
setUndoStack([]);
setRedoStack([]);
repaintAll();
@@ -309,11 +378,15 @@ export function NoteEditor({
useEffect(() => {
if (mode === "draw") repaintAll();
}, [mode, stageSize.w, stageSize.h]);
}, [mode, stageSize.w, stageSize.h, pages.count]);
/* ── DRAWING: input ── */
const canonPos = (e) => {
const r = canvasRef.current.getBoundingClientRect();
/* ── DRAWING: input ──
* Coordinates are canonical *within the page drawn on*, so a stroke at the top
* of page three is at the top of page three on every other screen too. */
const canonPos = (e, page) => {
const c = canvasesRef.current.get(page);
if (!c) return { x: 0, y: 0 };
const r = c.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { x: (t.clientX - r.left) * (CANON_W / r.width), y: (t.clientY - r.top) * (CANON_H / r.height) };
};
@@ -325,10 +398,18 @@ export function NoteEditor({
};
const r1 = (n) => Math.round(n * 10) / 10;
const startDraw = (e) => {
const startDraw = (page) => (e) => {
if (mode !== "draw") return;
e.preventDefault();
const p = canonPos(e);
// The offscreen copy belongs to whichever page is being drawn on, so it has
// to be rebuilt before the first frame — otherwise the previous page's ink
// would appear on this one.
if (drawPageRef.current !== page) {
drawPageRef.current = page;
committedRef.current = null;
repaintPage(page);
}
const p = canonPos(e, page);
liveRef.current = { t: tool, c: penColor, s: r1(currentStrokeWidth()), p: [r1(p.x), r1(p.y)] };
drawingRef.current = true;
renderFrame();
@@ -336,7 +417,7 @@ export function NoteEditor({
const doDraw = (e) => {
if (!drawingRef.current) return;
e.preventDefault();
const p = canonPos(e),
const p = canonPos(e, drawPageRef.current),
live = liveRef.current,
n = live.p.length;
const dx = p.x - live.p[n - 2],
@@ -355,10 +436,11 @@ export function NoteEditor({
renderFrame();
return;
}
const page = drawPageRef.current;
pushUndo();
commitStrokes([...strokesRef.current, live]);
commitSheets(sheetsWith({ sheets: sheetsRef.current }, page, [...pageStrokes(page), live]));
dirtyRef.current = true;
repaintAll();
repaintPage(page);
scheduleDrawingSave();
};
@@ -392,12 +474,15 @@ export function NoteEditor({
setTableMenu({ x: e.clientX, y: e.clientY, items });
};
/* History holds whole drawings, not single pages. Undo after a stroke on page
* three has to be able to put page three back exactly as it was, and a stack
* kept per page would also make one Undo button mean four different things. */
const pushUndo = () => {
setUndoStack((p) => [...p.slice(-30), strokesRef.current]);
setUndoStack((p) => [...p.slice(-30), sheetsRef.current]);
setRedoStack([]);
};
const applyHistory = (next) => {
commitStrokes(next);
commitSheets(next);
dirtyRef.current = true;
repaintAll();
scheduleDrawingSave();
@@ -406,19 +491,21 @@ export function NoteEditor({
if (!undoStack.length) return;
const prev = undoStack[undoStack.length - 1];
setUndoStack((p) => p.slice(0, -1));
setRedoStack((p) => [...p, strokesRef.current]);
setRedoStack((p) => [...p, sheetsRef.current]);
applyHistory(prev);
};
const redo = () => {
if (!redoStack.length) return;
const next = redoStack[redoStack.length - 1];
setRedoStack((p) => p.slice(0, -1));
setUndoStack((p) => [...p, strokesRef.current]);
setUndoStack((p) => [...p, sheetsRef.current]);
applyHistory(next);
};
/* Clears the page being looked at, not the whole drawing. Emptying four pages
* because the bin was pressed on one of them is not what anyone means by it. */
const clearCanvas = () => {
pushUndo();
applyHistory([]);
applyHistory(sheetsWith({ sheets: sheetsRef.current }, pages.current, []));
};
/* Save drawing on mode switch and debounced stroke-end updates. */
@@ -486,6 +573,18 @@ export function NoteEditor({
</button>
);
/* While drawing there is always one blank page past the end.
*
* Pages otherwise come from how far the writing runs, which would leave a
* drawing with nowhere to go: to draw on page three you would first have to
* write three pages. The spare page costs an empty canvas and becomes a real
* page the moment something is drawn on it. */
const canvasPages = mode === "draw" ? pages.count + 1 : pages.count;
const registerCanvas = (page) => (el) => {
if (el) canvasesRef.current.set(page, el);
else canvasesRef.current.delete(page);
};
return (
<div
style={{
@@ -502,7 +601,11 @@ export function NoteEditor({
* width a property of the window, which is what it always looked like. */
flex: 1,
minWidth: 0,
background: isMobileViewport ? T.pageBg : T.desk,
background: isMobileViewport
? T.bg
: dark
? "linear-gradient(180deg,#0f141b 0%,#0d1117 60%)"
: "linear-gradient(180deg,#f8fafd 0%,#eef3f8 70%)",
}}
>
{/* Main toolbar */}
@@ -678,7 +781,12 @@ export function NoteEditor({
)}
</div>
<div style={{ flex: 1 }} />
<button style={{ ...tb(), color: "#e5534b" }} onClick={clearCanvas}>
<button
data-clear-page
title={`Clear page ${pages.current}`}
style={{ ...tb(), color: "#e5534b" }}
onClick={clearCanvas}
>
{I.trash}
</button>
</>
@@ -716,77 +824,111 @@ export function NoteEditor({
padding: isMobileViewport ? "10px 0 18px" : "18px 0 28px",
}}
>
{/* The page is a surface, always.
With the text capped at a readable measure and nothing behind it,
the right 40% of the pane was simply void. A page gives that space
a meaning — it is the margin — and it is what lets "page 2" show as
a page rather than as a line across an empty field. */}
<div
style={{
position: "relative",
width: stageSize.w,
maxWidth: "100%",
minHeight: stageSize.h,
margin: isMobileViewport ? "0 auto" : "0 0 0 28px",
background: T.pageBg,
border: isMobileViewport ? "none" : `1px solid ${T.border}`,
borderRadius: isMobileViewport ? 0 : 6,
boxShadow: isMobileViewport ? "none" : "0 6px 20px rgba(0,0,0,0.16)",
// Every sheet plus the gaps between them, but not a gap trailing
// off the last one.
minHeight: canvasPages * pitch - PAGE_GAP * scale,
margin: "0 auto",
}}
>
{/* Where one page ends and the next begins.
A band rather than a hairline, with the new page's number set in
the margin — the count in the toolbar should correspond to
something that reads as a page, not to a line across a field.
The text flows on through: the note is one document, and a real
page break would mean laying the text out ourselves. */}
{Array.from({ length: Math.max(0, pages.count - 1) }, (_, i) => (
<div
key={i}
aria-hidden
data-page-break={i + 2}
style={{
position: "absolute",
left: 0,
right: 0,
top: stageSize.h * (i + 1),
height: 0,
pointerEvents: "none",
borderTop: `1px dashed ${T.border}`,
}}
>
<span
{/* The paper.
One sheet per page with a gap between them, the way the PDF
reader draws its pages — and the reason the writing sits on
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) => {
/* 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",
right: 14,
top: 8,
fontSize: 10.5,
letterSpacing: 0.5,
color: T.faint,
background: T.pageBg,
padding: "0 4px",
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,
}}
>
Page {i + 2}
</span>
</div>
))}
{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",
zIndex: 1,
minHeight: stageSize.h,
width: "100%",
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 && (
@@ -808,33 +950,62 @@ export function NoteEditor({
}}
/>
)}
{/* Saved drawing — re-rendered from vectors, so identical on every screen */}
{hasStrokes && mode !== "draw" && (
<DrawingLayer strokes={strokes} width={stageSize.w} height={stageSize.h} zIndex={2} />
)}
{mode === "draw" && (
<canvas
ref={canvasRef}
onMouseDown={startDraw}
onMouseMove={doDraw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
onTouchStart={startDraw}
onTouchMove={doDraw}
onTouchEnd={stopDraw}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: stageSize.h,
touchAction: "none",
cursor: "crosshair",
background: "transparent",
zIndex: 3,
}}
/>
)}
{/* Saved drawing — re-rendered from vectors, so identical on every
screen. One layer per drawn page; a page nobody has drawn on
needs no canvas at all. */}
{mode !== "draw" &&
drawnPages.map((page) => (
<div
key={page}
data-drawn-page={page}
style={{
position: "absolute",
left: 0,
right: 0,
top: (page - 1) * pitch,
height: stageSize.h,
/* Deaf to the pointer, or this box lies over the writing and
swallows every click meant for the text — which is exactly
what it did: a note with a drawing could not be typed in
at all. The canvas inside says the same thing, but a
transparent wrapper is still a wrapper. */
pointerEvents: "none",
}}
>
<DrawingLayer
strokes={sheetStrokes({ sheets }, page)}
width={stageSize.w}
height={stageSize.h}
zIndex={2}
/>
</div>
))}
{mode === "draw" &&
Array.from({ length: canvasPages }, (_, i) => i + 1).map((page) => (
<canvas
key={page}
ref={registerCanvas(page)}
data-draw-page={page}
onMouseDown={startDraw(page)}
onMouseMove={doDraw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
onTouchStart={startDraw(page)}
onTouchMove={doDraw}
onTouchEnd={stopDraw}
style={{
position: "absolute",
left: 0,
top: (page - 1) * pitch,
width: "100%",
height: stageSize.h,
touchAction: "none",
cursor: "crosshair",
background: "transparent",
zIndex: 3,
}}
/>
))}
</div>
</div>
</div>
+540 -157
View File
@@ -3,7 +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",
@@ -18,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.
@@ -35,31 +38,61 @@ const DropLine = ({ T }) => (
<div data-drop-line style={{ height: 2, borderRadius: 2, background: T.accent, margin: "2px 4px" }} />
);
/** Width the separator's label keeps in both states, so renaming moves nothing. */
const MIDDLE_W = 132;
/**
* What you take hold of to move a row.
*
* 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.
*
* 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
{...rest}
aria-hidden
title="Drag to reorder"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
flexShrink: 0,
color: T.faint,
cursor: "grab",
// Without this the browser claims the gesture for scrolling and the drag
// never begins.
touchAction: "none",
}}
>
{I.grip}
</div>
);
/** 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 }) {
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
@@ -68,74 +101,248 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 8px 6px",
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",
}}
>
{/* 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: 1, background: T.border, 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.textSec,
fontSize: 10.5,
fontWeight: 700,
letterSpacing: 0.6,
textAlign: "center",
// Matches the button's box exactly, so nothing shifts vertically
// either when the input replaces it.
padding: "1px 4px",
outline: "none",
}}
/>
{grip}
{/* 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>
<Rule colour={colour} style={separator.style} />
</div>
);
}
/**
* A chapter, in the list of the notebook it belongs to.
*
* Shown above the loose notes rather than mixed in with them: a chapter is a
* 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, open, onOpen, onContextMenu }) {
return (
<button
data-chapter-row={chapter.id}
onClick={() => onOpen(chapter.id)}
onContextMenu={(e) => onContextMenu(e, chapter)}
style={{
display: "flex",
alignItems: "center",
gap: 9,
flex: 1,
minWidth: 0,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${open ? T.accent + "55" : T.borderLight}`,
background: open ? T.accentSoft : "transparent",
cursor: "pointer",
marginBottom: 4,
textAlign: "left",
}}
>
{/* 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,
minWidth: 0,
fontSize: 13,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{chapter.name}
</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{chapter.noteCount}</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
title="Right-click for options"
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%",
border: "1px solid transparent",
background: "transparent",
color: separator.label ? T.textSec : T.faint,
fontSize: 10.5,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
padding: "1px 4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
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,
}}
>
{separator.label || "Unnamed"}
<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>
)}
</div>
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
</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. */}
</>
);
}
@@ -226,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);
@@ -271,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",
@@ -313,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,
@@ -329,13 +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,
dnd,
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,
@@ -344,12 +595,34 @@ export function NoteList({
onImportMarkdown,
onEmptyTrash,
}) {
// Opened by the button and kept open while there is something typed in it, so
// a search never disappears because the box collapsed under it.
const [searching, setSearching] = useState(false);
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(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,
@@ -359,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
@@ -371,8 +646,13 @@ export function NoteList({
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
{/* Inside a chapter this is the way back out to the notebook — which
on a phone is the only way, since the chapter fills the screen. */}
<button
onClick={onToggleSidebar}
onClick={openChapter ? () => onOpenChapter(null) : onToggleSidebar}
title={openChapter ? "Back to the notebook" : "Menu"}
aria-label={openChapter ? "Back to the notebook" : "Menu"}
data-chapter-back={openChapter ? "on" : undefined}
style={{
display: "flex",
alignItems: "center",
@@ -383,10 +663,10 @@ export function NoteList({
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
color: openChapter ? T.accent : T.muted,
}}
>
{I.menu}
{openChapter ? I.back : I.menu}
</button>
<h2
style={{
@@ -400,25 +680,21 @@ export function NoteList({
whiteSpace: "nowrap",
}}
>
{title}
{openChapter ? openChapter.name : title}
</h2>
</div>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
{!isTrashView && (
<button
onClick={() => setSearching((v) => !v)}
data-search-toggle={searching || query ? "on" : "off"}
title="Search this notebook"
aria-label="Search notes"
style={{ ...iconButton(T), color: searching || query ? T.accent : T.muted }}
>
{I.search}
</button>
{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}
@@ -450,20 +726,22 @@ export function NoteList({
{/* The search box lives with the list it narrows, not in the sidebar.
Searching has always been scoped to the notebook you are in; from the
sidebar it read as searching everything, which it never did. */}
{(searching || query) && !isTrashView && (
sidebar it read as searching everything, which it never did.
Always here, rather than behind a button. A search box is one line and
says for itself what it does; a magnifying glass is a line too, plus a
click, plus having to remember that searching is possible at all. */}
{!isTrashView && (
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.borderLight}` }}>
<input
autoFocus
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
onQueryChange("");
setSearching(false);
}
if (e.key !== "Escape") return;
onQueryChange("");
e.currentTarget.blur();
}}
placeholder={`Search in ${title}...`}
placeholder={`Search in ${openChapter ? openChapter.name : title}...`}
aria-label="Search in this notebook"
style={{
width: "100%",
@@ -481,15 +759,53 @@ export function NoteList({
{/* Just the one control. The sentence that used to sit here explained the
order the sort menu already names, on every visit, forever. */}
{reorderable && (
{(reorderable || canAddChapter) && (
<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. */}
{canAddChapter && (
<button
onClick={onAddChapter}
data-add-chapter
style={{
display: "flex",
alignItems: "center",
gap: 5,
padding: "5px 10px",
borderRadius: 8,
border: `1px solid ${T.border}`,
background: "transparent",
cursor: "pointer",
color: T.muted,
fontSize: 11.5,
fontWeight: 600,
}}
>
+ 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
@@ -499,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",
@@ -554,40 +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. */}
{(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
? {
draggable: true,
onDragStart: (e) => dnd.onDragStart(e, entry.id),
onDragOver: (e) => dnd.onDragOver(e, entry.id),
onDrop: (e) => dnd.onDrop(e, entry.id),
onDragEnd: dnd.onDragEnd,
}
: {};
{/* 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>
)}
{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}>
{showDropLine && <DropLine T={T} />}
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
<SeparatorRow
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>
);
}
@@ -595,8 +959,9 @@ export function NoteList({
const note = entry.note;
const snippet = snippetFor(note.id);
return (
<div key={note.id} {...rowProps}>
{showDropLine && <DropLine T={T} />}
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{moving && <Grip T={T} {...dnd.gripProps} />}
<button
data-note-row={note.id}
onClick={() => onSelectNote(note.id)}
@@ -604,12 +969,13 @@ export function NoteList({
style={{
display: "flex",
flexDirection: "column",
width: "100%",
flex: 1,
minWidth: 0,
padding: "9px 12px",
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",
}}
@@ -623,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
@@ -664,19 +1043,23 @@ export function NoteList({
{fmtDate(note.updatedAt)}
</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 && (
<div
data-drop-end
onDragOver={(e) => dnd.onDragOver(e, END_ID)}
onDrop={(e) => dnd.onDrop(e, END_ID)}
style={{ minHeight: 40 }}
>
{dnd?.overId === END_ID && <DropLine T={T} />}
{/* 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);
});
});
+198 -39
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,38 +212,131 @@ 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[], id: string, label = ""): Separator[] => [
{ id, label, order: 0 },
...separators,
];
export const withNewSeparator = (
separators: Separator[],
id: string,
label = "",
chapterId: string | null = null
): Separator[] => [{ id, label, order: 0, chapterId }, ...separators];
/**
* The separators belonging to one list.
*
* A separator's `order` counts the notes before it, which only means anything
* inside the list it was drawn in — the notebook itself, or one of its chapters.
* Showing all of a notebook's separators in every chapter would put lines in
* places nobody drew them.
*/
export const separatorsFor = (separators: Separator[], chapterId: string | null): Separator[] =>
separators.filter((s) => (s.chapterId ?? null) === chapterId);
/**
* The full notebook renumbered after a note was dragged onto another.
+13 -10
View File
@@ -15,8 +15,11 @@ import { useEffect, useRef, useState } from "react";
* others, because that one is being read closely and the rest are not.
*/
export const MARGIN_W = 208;
const CARD_H = 74;
/* The margin is a column you actually write in, not a strip of labels. It was
* 208 x 74, which held about three short lines and left the space beside the
* page mostly empty — for a remark on a passage, three lines is a caption. */
export const MARGIN_W = 288;
const CARD_H = 112;
const CARD_GAP = 6;
/** Cards at their anchors, pushed down where they would sit on top of each other. */
@@ -108,7 +111,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
}}
aria-label="Pinned note"
placeholder="Note on this passage…"
rows={4}
rows={7}
style={{
width: "100%",
resize: "vertical",
@@ -116,9 +119,9 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
borderRadius: 8,
background: "transparent",
color: T.text,
fontSize: 11.5,
lineHeight: 1.5,
padding: "5px 7px",
fontSize: 12.5,
lineHeight: 1.55,
padding: "7px 9px",
outline: "none",
fontFamily: "inherit",
}}
@@ -162,13 +165,13 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
) : (
<div
style={{
fontSize: 11.5,
lineHeight: 1.45,
fontSize: 12.5,
lineHeight: 1.5,
color: note.text ? T.text : T.muted,
maxHeight: CARD_H - 22,
maxHeight: CARD_H - 24,
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitLineClamp: 5,
WebkitBoxOrient: "vertical",
wordBreak: "break-word",
}}
+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}
+116 -11
View File
@@ -5,17 +5,26 @@
* same index that holds the notes — so one search box finds a sentence whether
* it was typed or imported.
*
* **The text is not sent to the server.** It is derivable from a file the server
* already has, so storing it again would cost space and a second thing to
* encrypt for no information gained. The price is that a PDF becomes searchable
* on a device once that device has opened it. That is the same shape as the
* limitation notes already have, and the honest trade for not duplicating data.
* **The reading happens on a device, never on the server.** The server has no
* PDF library and no way to open one: the bytes arrive encrypted and are stored
* that way. The device that imports the file does the work once, and the result
* is stored beside the file — encrypted at rest like everything else — so every
* other device can search a document it has never opened.
*
* It used to be kept only in the browser that had opened the file, on the
* reasoning that text derivable from bytes the server already holds should not
* be stored twice. That was true and useless: a PDF imported on the desktop was
* invisible to a search on the phone. Storing it is what makes an archive of
* scanned invoices findable from anywhere, and it costs a few hundred kilobytes
* against a file that is measured in megabytes.
*/
import * as db from "../../offlineStore";
import * as sync from "../../syncEngine";
import * as searchIndex from "../../search";
import type { NoteMeta } from "../../types";
import type { PDFDocumentProxy } from "./pdfjs";
import { extractPageText } from "./pdfjs";
import { extractPageText, openPdf } from "./pdfjs";
/** Cache key. Keyed by attachment, not note: the same file in two notes is extracted once. */
const keyFor = (attachmentId: string) => `pdftext:${attachmentId}`;
@@ -34,6 +43,92 @@ export async function cachedPdfText(attachmentId: string): Promise<PdfText | nul
return (await db.getMeta<PdfText>(keyFor(attachmentId))) ?? null;
}
const textOf = (pages: string[]): PdfText => ({
pages,
full: pages.join("\n").slice(0, MAX_TEXT_CHARS),
});
const cache = async (attachmentId: string, text: PdfText): Promise<void> => {
try {
await db.setMeta(keyFor(attachmentId), text);
} catch {
/* a full quota costs the cache, not the search */
}
};
/** Stores the text with the file if nothing is stored there yet. */
async function ensureStored(attachmentId: string, pages: string[]): Promise<void> {
try {
if (await sync.fetchPdfText(attachmentId)) return;
await sync.storePdfText(attachmentId, pages);
} catch {
/* offline, or the file is gone — either way, nothing to do about it here */
}
}
/**
* The text of a file, from wherever it can be had cheapest.
*
* Locally first, then from the server — which is where the device that imported
* the file left it. Null means nobody has read this document yet, and whoever
* has the bytes should.
*/
export async function loadPdfText(attachmentId: string): Promise<PdfText | null> {
const local = await cachedPdfText(attachmentId);
if (local) return local;
try {
const pages = await sync.fetchPdfText(attachmentId);
if (!pages) return null;
const text = textOf(pages);
await cache(attachmentId, text);
return text;
} catch {
// Offline, or the file is gone. Either way there is nothing to index and
// nothing worth interrupting the user about.
return null;
}
}
/**
* Reads a freshly imported PDF, so it is searchable everywhere immediately.
*
* Done on the device that has the file open anyway, rather than left until some
* device opens the document — which for an invoice filed and never looked at
* again may be never. Failure is silent on purpose: the import succeeded, and
* the text can still be extracted the next time the file is opened.
*/
export async function extractOnImport(file: Blob, attachmentId: string): Promise<void> {
try {
const { doc } = await openPdf(new Uint8Array(await file.arrayBuffer()));
await extractAndCache(doc, attachmentId);
} catch (err) {
console.warn(`[pdf] could not read the text of ${attachmentId} on import:`, err);
}
}
/**
* Fills the search index with the text of every PDF the account holds.
*
* Runs beside the note backfill after login. A document whose text nobody has
* extracted yet is left alone: this device may not have its bytes, and
* downloading every PDF to index it is not a thing to do in the background.
*/
export async function backfillPdfText(
notes: NoteMeta[],
signal?: { aborted: boolean }
): Promise<number> {
let indexed = 0;
for (const note of notes) {
if (signal?.aborted) break;
if (!note.pdfAttachmentId) continue;
const text = await loadPdfText(note.pdfAttachmentId);
if (!text) continue;
indexPdfText(note.id, note.title || "", text);
indexed++;
}
return indexed;
}
/**
* Extracts every page's text and remembers it.
*
@@ -47,7 +142,12 @@ export async function extractAndCache(
signal?: { aborted: boolean }
): Promise<PdfText | null> {
const existing = await cachedPdfText(attachmentId);
if (existing) return existing;
if (existing) {
// Extracted here before the text was ever stored with the file. Sending it
// now is what makes those documents findable on the other devices too.
void ensureStored(attachmentId, existing.pages);
return existing;
}
const pages: string[] = [];
let total = 0;
@@ -66,11 +166,16 @@ export async function extractAndCache(
await new Promise((r) => setTimeout(r, 0));
}
const result: PdfText = { pages, full: pages.join("\n").slice(0, MAX_TEXT_CHARS) };
const result = textOf(pages);
await cache(attachmentId, result);
// Left with the file, so the next device does not have to read it again — and
// can find the document without ever opening it. A failure here is not worth
// reporting: the text is cached locally, and the next device to open the file
// will store it.
try {
await db.setMeta(keyFor(attachmentId), result);
} catch {
/* a full quota costs the cache, not the search */
await sync.storePdfText(attachmentId, pages);
} catch (err) {
console.warn(`[pdf] could not store the text of ${attachmentId}:`, err);
}
return result;
}
+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]);
}
+332 -64
View File
@@ -3,8 +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 type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
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,
Separator,
SortMode,
StrokeDoc,
User,
} from "../types";
/** How often the whole account is reconciled with the server. */
const FULL_SYNC_MS = 30_000;
@@ -35,21 +47,37 @@ export interface NoteData {
reload: () => Promise<void>;
reset: () => void;
createNote: (folderId: string) => Promise<NoteMeta>;
createNote: (folderId: string, chapterId?: string | null) => Promise<NoteMeta>;
importPdf: (folderId: string, file: File) => Promise<NoteMeta>;
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>;
emptyTrash: () => Promise<void>;
moveNote: (id: string, folderId: string) => Promise<void>;
moveNote: (id: string, folderId: string, chapterId?: string | null) => Promise<void>;
/** Drag-to-reorder within a notebook; moves notes and separators alike. */
rearrange: (notebookId: string, dragId: string, targetId: string) => void;
rearrange: (notebookId: string, dragId: string, targetId: string, chapterId?: string | null) => void;
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
chapters: Chapter[];
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;
}
@@ -66,6 +94,7 @@ export interface NoteData {
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
const [notes, setNotes] = useState<NoteMeta[]>([]);
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
const [chapters, setChapters] = useState<Chapter[]>([]);
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
@@ -73,17 +102,21 @@ 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;
const reload = useCallback(async () => {
setNotebooks(await sync.getNotebooks());
setChapters(await sync.getChapters());
setNotes(await sync.getNotes());
}, []);
const reset = useCallback(() => {
setNotes([]);
setNotebooks([]);
setChapters([]);
setNoteContents({});
setBacklinks([]);
}, []);
@@ -184,26 +217,18 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
/* ── Notes ── */
const createNote = useCallback(async (folderId: string) => {
const note = await sync.createNote(folderId);
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]);
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) => {
@@ -217,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);
@@ -259,43 +281,284 @@ 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) => {
await sync.moveNote(id, folderId);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
/**
* Everything one list is made of: its notes, its lines, its chapters.
*
* `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.
*/
const listOf = useCallback((notebookId: string, chapterId: string | null, extra?: NoteMeta) => {
const notebook = notebooksRef.current.find((nb) => nb.id === notebookId);
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 || [];
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),
};
}, []);
/** 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.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);
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 ──
*
* Folders inside a notebook. Deleting one never deletes its notes: they come
* back to the notebook, which is why the local copy is refreshed rather than
* having the removal applied by hand here.
*/
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;
},
[]
);
/* 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);
}, []);
/**
* Puts one row where another was, in the user's own order.
* Removing a chapter: the row goes, the notes come back.
*
* 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.
* 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 rearrange = useCallback((notebookId: string, dragId: string, targetId: string) => {
const notebook = notebooksRef.current.find((nb) => nb.id === notebookId);
if (!notebook) return;
const inNotebook = notesRef.current.filter((n) => !n.trashed && n.folderId === notebookId);
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 next = reindexEntries(inNotebook, notebook.separators || [], dragId, targetId);
if (!next) return;
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);
// Only written when it actually changed: a plain note drag leaves the
// separators alone, and a pointless notebook write would sync for nothing.
const before = JSON.stringify(notebook.separators || []);
if (JSON.stringify(next.separators) !== before) {
setNotebooks((p) =>
p.map((nb) => (nb.id === notebookId ? { ...nb, separators: next.separators } : nb))
);
void sync.updateNotebook(notebookId, { separators: next.separators });
}
}, []);
/* ── Notebooks ── */
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 }) => {
const nb = await sync.createNotebook(fields);
@@ -340,6 +603,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
updateNote,
setNoteContent,
toggleStar,
setNoteIcon,
trashNote,
restoreNote,
permanentDeleteNote,
@@ -348,6 +612,10 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
rearrange,
createNotebook,
updateNotebook,
chapters,
createChapter,
editChapter,
deleteChapter,
deleteNotebook,
reorderNotebooks,
};
+60 -7
View File
@@ -7,25 +7,63 @@ interface BeforeInstallPromptEvent extends Event {
}
export interface PwaInstall {
/** Non-null only while the browser is willing to show an install prompt. */
/** True while the browser is willing to show its own install prompt. */
canInstall: boolean;
/** True when the app is already running as an installed app. */
installed: boolean;
/**
* Why there is no prompt, when there is none. Empty while one is available.
*
* The button used to appear only when the browser offered a prompt, so when
* installing was impossible there was nothing on screen at all — no button, no
* reason, nothing to act on. Every one of these is fixable by whoever is
* holding the phone, which is why it is worth saying.
*/
reason: "" | "installed" | "insecure" | "manual";
promptInstall: () => Promise<void>;
}
const isStandalone = () =>
window.matchMedia?.("(display-mode: standalone)")?.matches ||
// iOS keeps its own flag rather than reporting the display mode.
(navigator as unknown as { standalone?: boolean }).standalone === true;
/**
* Captures the browser's install offer so the app can present it at a sensible
* moment instead of letting the browser choose one.
* The offer, if the browser made one before the app was running.
*
* `beforeinstallprompt` fires once, early, and is never fired again. A listener
* added from a component is routinely too late — which is why the install button
* never appeared even on a correctly served site. `index.html` catches it at the
* top of the document and leaves it here.
*/
const stashed = (): BeforeInstallPromptEvent | null =>
(window as unknown as { __inknoteInstallPrompt?: BeforeInstallPromptEvent }).__inknoteInstallPrompt ?? null;
export function usePwaInstall(): PwaInstall {
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(stashed);
const [installed, setInstalled] = useState(isStandalone);
useEffect(() => {
const onPrompt = (e: Event) => {
e.preventDefault();
setEvent(e as BeforeInstallPromptEvent);
};
// Fired by the catcher in index.html when the offer arrives after mount.
const onStashed = () => setEvent(stashed());
const onInstalled = () => {
setInstalled(true);
setEvent(null);
};
window.addEventListener("beforeinstallprompt", onPrompt);
return () => window.removeEventListener("beforeinstallprompt", onPrompt);
window.addEventListener("inknote:installable", onStashed);
window.addEventListener("appinstalled", onInstalled);
// One more look, in case it landed between the first render and this effect.
setEvent((current) => current ?? stashed());
return () => {
window.removeEventListener("beforeinstallprompt", onPrompt);
window.removeEventListener("inknote:installable", onStashed);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
const promptInstall = async () => {
@@ -34,8 +72,23 @@ export function usePwaInstall(): PwaInstall {
const { outcome } = await event.userChoice;
// The captured event is single-use; drop it either way so the button does
// not linger after a decision.
if (outcome === "accepted") setEvent(null);
if (outcome === "accepted") {
setEvent(null);
(window as unknown as { __inknoteInstallPrompt?: unknown }).__inknoteInstallPrompt = null;
}
};
return { canInstall: event !== null, promptInstall };
/* An app can only be installed from a secure origin. Served over plain HTTP
* the service worker never registers either, so this one condition takes the
* install *and* offline with it — which is worth naming rather than leaving
* as two separate mysteries. */
const reason: PwaInstall["reason"] = installed
? "installed"
: event
? ""
: window.isSecureContext
? "manual"
: "insecure";
return { canInstall: event !== null, installed, reason, promptInstall };
}
+197
View File
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Drag-to-reorder that works with a finger.
*
* The list used to use the HTML5 drag-and-drop API, which mobile browsers never
* fire from touch — so on a phone the arrangement simply could not be changed,
* with nothing on screen to say why. This uses pointer events instead, which
* mouse, pen and finger all produce.
*
* The two devices want different things, so they get different starts:
*
* - **With a mouse**, pressing anywhere on a row and moving a few pixels begins
* the drag. That is what a mouse has always done here.
* - **With a finger**, only the grip begins it. Pressing the row itself has to
* go on scrolling the list, and a long press has to go on opening the menu —
* both would be lost to a drag that started from anywhere.
*/
/** Pixels a mouse must travel before a press counts as a drag rather than a click. */
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.
*
* `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,
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;
drag.current = { id, x: e.clientX, y: e.clientY, active: fromGrip, fromGrip };
if (fromGrip) {
setDraggingId(id);
document.body.style.userSelect = "none";
// The grip owns the gesture from here, so the list must not also scroll.
e.preventDefault();
}
},
[enabled, scope]
);
useEffect(() => {
if (!enabled) return;
const move = (e: PointerEvent) => {
const d = drag.current;
if (!d) return;
if (!d.active) {
if (Math.abs(e.clientX - d.x) + Math.abs(e.clientY - d.y) < MOUSE_THRESHOLD) return;
d.active = true;
setDraggingId(d.id);
document.body.style.userSelect = "none";
}
// Which row is under the finger — asked of the document rather than
// 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-scope="${scope}"]`) as HTMLElement | null;
const target = row?.dataset.reorderId ?? null;
const next = target && target !== d.id ? target : null;
/* A chapter is both a place and a neighbour, so which one it is depends on
* 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();
};
const up = () => {
const d = drag.current;
const target = overRef.current;
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();
};
window.addEventListener("pointermove", move, { passive: false });
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", stop);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", stop);
};
}, [enabled, reorder, fileInto, scope, stop]);
// A drag that outlives its list would leave the page unselectable.
useEffect(
() => () => {
document.body.style.removeProperty("user-select");
},
[]
);
return {
overId,
intoId,
draggingId,
rowProps: (id: string, into = false) => ({
"data-reorder-id": id,
"data-reorder-scope": scope,
...(into ? { "data-reorder-into": "" } : {}),
onPointerDown: onPointerDown(id),
}),
gripProps: { "data-reorder-grip": "" },
};
}
+7
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import * as sync from "../syncEngine";
import * as searchIndex from "../search";
import { backfillPdfText } from "../features/pdf/pdfText";
import type { NoteMeta, SearchHit, User } from "../types";
const DEBOUNCE_MS = 120;
@@ -52,6 +53,12 @@ export function useSearchIndex(user: User | null, notes: NoteMeta[], query: stri
},
});
if (!cancelled) setVersion((v) => v + 1);
/* PDFs last: their text is stored with the file rather than derived from a
* note body, so it has to be fetched separately — and a document nobody
* has read yet is simply skipped. Searching a note is more urgent than
* searching a scan, so this waits until the notes are in. */
const indexed = await backfillPdfText(notesRef.current, signal);
if (indexed && !cancelled) setVersion((v) => v + 1);
running.current = false;
})();
+50 -19
View File
@@ -29,6 +29,11 @@ export interface ImportProgress {
label: string;
}
/* How far to look for further pages of one note's drawing. A bound rather than
* a limit anyone should reach: without one, a malformed archive would have this
* loop asking for filenames forever. */
const MAX_DRAWING_PAGES = 200;
export interface ImportResult {
notes: NoteMeta[];
/** Notes whose drawing came back as a picture instead of editable strokes. */
@@ -140,14 +145,42 @@ async function importOne(
else result.missingImages++;
}
/* The drawing, if the archive carried one. Two layouts exist: a notebook
* export puts it in `zeichnungen/`, a single-note export writes it beside the
* file as `<name>-zeichnung.png`. */
/* The drawing, if the archive carried one.
*
* Four layouts exist. A notebook export files it under `drawings/`, a
* single-note export writes it beside the file as `<name>-drawing.png`, and
* both had German names — `zeichnungen/`, `-zeichnung` — before the interface
* was put into English. All four are read: an archive written last year is
* exactly the archive someone reaches for.
*
* Only the first two were being looked for until now, which meant a drawing
* exported by this very app came back as nothing at all.
*
* A drawing may run over several pages. Page one keeps the plain name and the
* rest are numbered after it, so they come back in order.
*/
const base = baseName(markdownPath);
const drawingPath = [`zeichnungen/${base}.png`, `${base}-zeichnung.png`].find((p) => sources.files.has(p));
const drawingId = drawingPath ? await uploadPicture(sources.files.get(drawingPath)!, drawingPath) : null;
if (drawingId) result.flattenedDrawings++;
const drawingIds: string[] = [];
for (const suffix of ["", ...Array.from({ length: MAX_DRAWING_PAGES - 1 }, (_, i) => `-${i + 2}`)]) {
const path = [
`drawings/${base}${suffix}.png`,
`${base}-drawing${suffix}.png`,
`zeichnungen/${base}${suffix}.png`,
`${base}-zeichnung${suffix}.png`,
].find((p) => sources.files.has(p));
const bytes = path ? sources.files.get(path) : null;
if (!path || !bytes) break; // pages are consecutive; the first gap is the end
const id = await uploadPicture(bytes, path);
if (id) drawingIds.push(id);
}
if (drawingIds.length) result.flattenedDrawings++;
const asPicture = (id: string) => ({
type: "image",
attrs: { attachmentId: id, alt: "Drawing", src: null },
});
let replaced = false;
let doc = mapNodes(parsed.doc, (n) => {
if (n.type === "image") {
const id = n.attrs?.src ? uploaded.get(n.attrs.src) : null;
@@ -158,22 +191,20 @@ async function importOne(
}
// The exporter leaves a note saying the drawing lies beside the file. Now it
// is back inside the note, so the note about it would be wrong.
if (drawingId && isDrawingMarker(n)) {
return { type: "image", attrs: { attachmentId: drawingId, alt: "Zeichnung", src: null } };
const first = drawingIds[0];
if (first && isDrawingMarker(n)) {
replaced = true;
return asPicture(first);
}
return n;
})!;
// No marker to replace (a hand-written archive, or an older export) — the
// drawing still belongs in the note, so it goes at the end.
if (drawingId && !JSON.stringify(doc).includes(drawingId)) {
doc = {
...doc,
content: [
...(doc.content ?? []),
{ type: "image", attrs: { attachmentId: drawingId, alt: "Zeichnung", src: null } },
],
};
// The marker stands for the drawing as a whole, so it can only become the
// first page; the others follow it at the end. With no marker at all (a
// hand-written archive, or an older export) every page goes there.
const trailing = replaced ? drawingIds.slice(1) : drawingIds;
if (trailing.length) {
doc = { ...doc, content: [...(doc.content ?? []), ...trailing.map(asPicture)] };
}
const note = await sync.createNote(folderId);
@@ -182,7 +213,7 @@ async function importOne(
note.id,
serializeNoteContent(doc),
[],
[...uploaded.values(), ...(drawingId ? [drawingId] : [])]
[...uploaded.values(), ...drawingIds]
);
return { ...note, title };
}
@@ -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 () => {
+60 -3
View File
@@ -5,12 +5,12 @@
* pushes queued changes to the server when a connection is available.
*/
import type { NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
import type { Chapter, NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
const DB_NAME = "inknote";
const DB_VERSION = 3;
const DB_VERSION = 4;
type StoreName = "notebooks" | "notes" | "noteContents" | "syncQueue" | "meta" | "attachments";
type StoreName = "notebooks" | "chapters" | "notes" | "noteContents" | "syncQueue" | "meta" | "attachments";
let db: IDBDatabase | null = null;
@@ -53,6 +53,12 @@ function open(): Promise<IDBDatabase> {
if (!d.objectStoreNames.contains("attachments")) {
d.createObjectStore("attachments", { keyPath: "id" });
}
// Chapters — folders inside a notebook.
if (!d.objectStoreNames.contains("chapters")) {
const ch = d.createObjectStore("chapters", { keyPath: "id" });
ch.createIndex("notebookId", "notebookId");
}
};
req.onsuccess = (e) => {
@@ -101,6 +107,29 @@ export async function deleteMeta(key: string): Promise<void> {
await reqToPromise(s.delete(key));
}
/* ─── Chapters ─── */
export async function getAllChapters(): Promise<Chapter[]> {
const s = await store("chapters", "readonly");
return reqToPromise<Chapter[]>(s.getAll());
}
export async function putChapter(c: Chapter): Promise<void> {
const s = await store("chapters", "readwrite");
await reqToPromise(s.put(c));
}
export async function putChapters(list: Chapter[]): Promise<void> {
const s = await store("chapters", "readwrite");
for (const c of list) s.put(c);
await txDone(s.transaction);
}
export async function deleteChapter(id: string): Promise<void> {
const s = await store("chapters", "readwrite");
await reqToPromise(s.delete(id));
}
/* ─── Notebooks ─── */
export async function getAllNotebooks(): Promise<Notebook[]> {
const s = await store("notebooks", "readonly");
@@ -225,6 +254,34 @@ export async function updateQueueEntry(queueId: number, patch: Partial<QueueEntr
await reqToPromise(s.put({ ...entry, ...patch }));
}
/**
* Rewrites every queued reference to a temporary id.
*
* Something made offline carries an id this device invented. When its create
* finally reaches the server, the server names it something else — and every
* entry still waiting in the queue is addressed to the name nobody else knows.
*
* They used to be left as they were. The database was repointed and the queue
* was not, so the create arrived and then every edit behind it asked the server
* for `local_xxxx`, got a 404, retried five times and was thrown away: a note
* written and drawn on offline reached the server empty and untitled, with the
* work silently dropped.
*
* The replacement is a plain string match over the whole payload rather than a
* list of fields to look in. Ids are long random strings, so a false match is
* not a practical worry — and a list of fields is a list to forget to add to.
*/
export async function repointQueue(oldId: string, newId: string): Promise<void> {
if (!oldId || oldId === newId) return;
const s = await store("syncQueue", "readwrite");
const entries = await reqToPromise<QueueEntry[]>(s.getAll());
for (const entry of entries) {
const before = JSON.stringify(entry.data ?? {});
if (!before.includes(oldId)) continue;
await reqToPromise(s.put({ ...entry, data: JSON.parse(before.split(oldId).join(newId)) }));
}
}
export async function clearQueue(): Promise<void> {
const s = await store("syncQueue", "readwrite");
await reqToPromise(s.clear());
+436 -20
View File
@@ -8,6 +8,7 @@
import * as db from "./offlineStore";
import type {
Backlink,
Chapter,
ManagedUser,
NoteContent,
NoteMeta,
@@ -15,6 +16,7 @@ import type {
NoteVersionSummary,
Notebook,
QueueEntry,
SortMode,
StrokeDoc,
SyncStatus,
User,
@@ -71,16 +73,30 @@ interface ServerNotebook {
id: string;
name: string;
cover_index?: number;
cover_pattern?: number;
sort_order?: number;
max_versions?: number | null;
sort_mode?: string | null;
separators?: string | null;
}
interface ServerChapter {
id: string;
notebook_id: string;
name: string;
color?: string | null;
icon?: string | null;
sort_order?: number;
sort_mode?: string | null;
}
interface ServerNote {
id: string;
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;
@@ -107,7 +123,18 @@ export interface NoteUpdate {
title?: string;
content?: string;
notebookId?: string | null;
/**
* Which chapter the note is filed in; `null` means the notebook itself.
*
* Meaningful as null, so it must not be dropped the way an absent field is —
* "take it out of its chapter" and "leave it where it is" are different
* instructions and both have to be sendable.
*/
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[];
@@ -228,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();
},
};
@@ -327,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". */
@@ -365,6 +429,7 @@ export async function restoreSession(): Promise<User | Partial<User> | null> {
export async function logout(): Promise<void> {
api.forgetToken();
forgetAvatars();
await db.clearAll();
}
@@ -403,12 +468,21 @@ export async function fullSync(): Promise<void> {
try {
await flushQueue();
// Chapters first: the notes arriving below point at them.
try {
await fetchChapters();
} catch (e) {
if (isAuthError(e)) throw e;
// An older server has no /chapters. Everything else still syncs.
}
const localNbs = await db.getAllNotebooks();
const serverNbs = await api.get<ServerNotebook[]>("/notebooks");
const nbs: Notebook[] = serverNbs.map((nb) => ({
id: nb.id,
name: nb.name,
cover: nb.cover_index || 0,
coverPattern: nb.cover_pattern || 0,
sortOrder: nb.sort_order || 0,
maxVersions: nb.max_versions ?? null,
sortMode: (nb.sort_mode as Notebook["sortMode"]) ?? null,
@@ -470,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,
@@ -484,13 +567,17 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
/** Every kind of change that can wait offline. */
type QueuedAction =
| { action: "createNotebook"; data: { localId: string; name: string; cover: number; sortOrder: number } }
| {
action: "createNotebook";
data: { localId: string; name: string; cover: number; coverPattern?: number; sortOrder: number };
}
| {
action: "updateNotebook";
data: {
id: string;
name?: string;
cover?: number;
coverPattern?: number;
sortOrder?: number;
maxVersions?: number | null;
sortMode?: Notebook["sortMode"];
@@ -498,7 +585,33 @@ type QueuedAction =
};
}
| { action: "deleteNotebook"; data: { id: string } }
| { action: "createNote"; data: { localId: string; title: string; folderId: string | null } }
| {
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";
data: { localId: string; title: string; folderId: string | null; chapterId?: string | null };
}
| { action: "updateNote"; data: { id: string; updates: NoteUpdate } }
| { action: "updateDrawing"; data: { id: string; drawingStrokes: StrokeDoc | null } }
| { action: "trashNote"; data: { id: string } }
@@ -562,15 +675,28 @@ async function processEntry(entry: QueueEntry): Promise<void> {
switch (a.action) {
case "createNotebook": {
const d = a.data;
const r = await api.post<{ id: string }>("/notebooks", { name: d.name, coverIndex: d.cover });
const r = await api.post<{ id: string }>("/notebooks", {
name: d.name,
coverIndex: d.cover,
coverPattern: d.coverPattern || 0,
});
if (r.id !== d.localId) {
// The server assigned a different id — repoint everything that referenced
// the temporary local one.
await db.deleteNotebook(d.localId);
await db.putNotebook({ id: r.id, name: d.name, cover: d.cover, sortOrder: d.sortOrder || 0 });
await db.putNotebook({
id: r.id,
name: d.name,
cover: d.cover,
coverPattern: d.coverPattern || 0,
sortOrder: d.sortOrder || 0,
});
for (const n of await db.getAllNotes()) {
if (n.folderId === d.localId) await db.putNote({ ...n, folderId: r.id });
}
// ...and everything still queued behind this create, which is addressed
// to a name only this device ever knew.
await db.repointQueue(d.localId, r.id);
}
break;
}
@@ -578,6 +704,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
await api.put(`/notebooks/${a.data.id}`, {
name: a.data.name,
coverIndex: a.data.cover,
coverPattern: a.data.coverPattern,
sortOrder: a.data.sortOrder,
...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}),
...(a.data.sortMode !== undefined ? { sortMode: a.data.sortMode } : {}),
@@ -587,12 +714,55 @@ async function processEntry(entry: QueueEntry): Promise<void> {
case "deleteNotebook":
await api.del(`/notebooks/${a.data.id}`);
break;
case "createChapter": {
const d = a.data;
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);
await db.putChapter({
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.
for (const n of await db.getAllNotes()) {
if (n.chapterId === d.localId) await db.putNote({ ...n, chapterId: r.id });
}
await db.repointQueue(d.localId, r.id);
}
break;
}
case "updateChapter":
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}`);
break;
case "createNote": {
const d = a.data;
const content = await db.getNoteContent(d.localId);
const r = await api.post<{ id: string }>("/notes", {
title: d.title,
notebookId: d.folderId,
chapterId: d.chapterId ?? null,
content: content?.content || "",
});
if (r.id !== d.localId) {
@@ -605,6 +775,11 @@ async function processEntry(entry: QueueEntry): Promise<void> {
await db.putNote({ ...rest, id: r.id });
}
if (oldC) await db.putNoteContent(r.id, oldC.content, oldC.drawingData, oldC.drawingStrokes ?? null);
/* The queue too. Without this the title, the text and the drawing that
* followed were all addressed to the local id, met a 404, and were
* thrown away after five attempts — so a note written offline arrived
* empty and untitled, with no sign that anything had been lost. */
await db.repointQueue(d.localId, r.id);
}
break;
}
@@ -690,11 +865,127 @@ export async function getNotebooks(): Promise<Notebook[]> {
return db.getAllNotebooks();
}
export async function createNotebook({ name, cover }: { name: string; cover: number }): Promise<Notebook> {
/* ─── Chapters ───
*
* Folders inside a notebook. They ride the same road as notebooks: written
* locally first so the app works offline, queued when there is no connection,
* and repointed when the server names a locally created one something else.
*/
/** The local copy, in the order they were made. */
export async function getChapters(): Promise<Chapter[]> {
return (await db.getAllChapters()).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
export async function fetchChapters(): Promise<Chapter[]> {
const rows = await api.get<ServerChapter[]>("/chapters");
const chapters: Chapter[] = rows.map((c) => ({
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
// server cannot know about it yet.
const server = new Set(chapters.map((c) => c.id));
const pending = local.filter((c) => c._pendingCreate && !server.has(c.id));
await db.putChapters([...chapters, ...pending]);
return [...chapters, ...pending];
}
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 }>("/notebooks", { name, coverIndex: cover });
const nb: Notebook = { id: r.id, name, cover, sortOrder: (await db.getAllNotebooks()).length };
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;
} catch (e) {
if (isAuthError(e)) throw e;
}
}
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, sortMode, _pendingCreate: true };
await db.putChapter(chapter);
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon, sortMode } });
notifyData("chapter", chapter);
notifyStatus("pending");
return chapter;
}
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 });
notifyData("chapter", { ...chapter, ...updates });
if (_online && api.token) {
try {
await api.put(`/chapters/${id}`, updates);
return;
} catch (e) {
if (isAuthError(e)) throw e;
}
}
await enqueue({ action: "updateChapter", data: { id, ...updates } });
notifyStatus("pending");
}
/** Removes a chapter. Its notes are not removed — they return to the notebook. */
export async function deleteChapter(id: string): Promise<void> {
await db.deleteChapter(id);
for (const n of await db.getAllNotes()) {
if (n.chapterId === id) await db.putNote({ ...n, chapterId: null });
}
notifyData("chapter", null);
if (_online && api.token) {
try {
await api.del(`/chapters/${id}`);
return;
} catch (e) {
if (isAuthError(e)) throw e;
}
}
await enqueue({ action: "deleteChapter", data: { id } });
notifyStatus("pending");
}
export async function createNotebook({
name,
cover,
coverPattern = 0,
}: {
name: string;
cover: number;
coverPattern?: number;
}): Promise<Notebook> {
if (_online && api.token) {
try {
const r = await api.post<{ id: string }>("/notebooks", { name, coverIndex: cover, coverPattern });
const nb: Notebook = {
id: r.id,
name,
cover,
coverPattern,
sortOrder: (await db.getAllNotebooks()).length,
};
await db.putNotebook(nb);
notifyData("notebook", nb);
return nb;
@@ -708,13 +999,14 @@ export async function createNotebook({ name, cover }: { name: string; cover: num
id: lid,
name,
cover,
coverPattern,
sortOrder: (await db.getAllNotebooks()).length,
_pendingCreate: true,
};
await db.putNotebook(nb);
await enqueue({
action: "createNotebook",
data: { localId: lid, name, cover, sortOrder: nb.sortOrder },
data: { localId: lid, name, cover, coverPattern, sortOrder: nb.sortOrder },
});
notifyData("notebook", nb);
notifyStatus("pending");
@@ -734,6 +1026,7 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
await api.put(`/notebooks/${id}`, {
name: updates.name,
coverIndex: updates.cover,
coverPattern: updates.coverPattern,
sortOrder: updates.sortOrder,
// null is meaningful here — it clears the notebook's own setting — so it
// has to travel, while undefined must stay away and change nothing.
@@ -752,6 +1045,7 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
id,
name: updates.name,
cover: updates.cover,
coverPattern: updates.coverPattern,
sortOrder: updates.sortOrder,
maxVersions: updates.maxVersions,
sortMode: updates.sortMode,
@@ -810,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(() => {});
}
}
@@ -867,11 +1186,16 @@ export async function loadNoteContent(
return shapeContent(await db.getNoteContent(id));
}
export async function createNote(folderId: string | null): Promise<NoteMeta> {
/** A note made while a chapter is open belongs to that chapter. */
export async function createNote(
folderId: string | null,
chapterId: string | null = null
): Promise<NoteMeta> {
const ts = new Date().toISOString();
const base = {
title: "Untitled Note",
folderId,
chapterId,
starred: false,
trashed: false,
createdAt: ts,
@@ -883,6 +1207,7 @@ export async function createNote(folderId: string | null): Promise<NoteMeta> {
const r = await api.post<{ id: string }>("/notes", {
title: base.title,
notebookId: folderId,
chapterId,
content: "",
});
const note: NoteMeta = { id: r.id, ...base };
@@ -899,12 +1224,43 @@ export async function createNote(folderId: string | null): Promise<NoteMeta> {
const note: NoteMeta = { id: lid, ...base, _pendingCreate: true };
await db.putNote(note);
await db.putNoteContent(lid, "", null);
await enqueue({ action: "createNote", data: { localId: lid, title: base.title, folderId } });
await enqueue({ action: "createNote", data: { localId: lid, title: base.title, folderId, chapterId } });
notifyData("note", note);
notifyStatus("pending");
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;
@@ -999,17 +1355,29 @@ export async function toggleNoteStar(id: string): Promise<boolean> {
return updated.starred;
}
export async function moveNote(id: string, folderId: string | null): Promise<void> {
/**
* Files a note in a notebook, and optionally in a chapter of it.
*
* Moving to another notebook clears the chapter unless a new one is named: a
* chapter belongs to the book it is in, so keeping it would file the note
* somewhere that cannot be reached from either.
*/
export async function moveNote(
id: string,
folderId: string | null,
chapterId: string | null = null
): Promise<void> {
const note = await db.getNote(id);
if (!note) return;
const updated: NoteMeta = { ...note, folderId, updatedAt: new Date().toISOString() };
const updated: NoteMeta = { ...note, folderId, chapterId, updatedAt: new Date().toISOString() };
await db.putNote(updated);
notifyData("note", updated);
const updates: NoteUpdate = { notebookId: folderId, chapterId };
if (_online && api.token && !updated._pendingCreate) {
api.put(`/notes/${id}`, { notebookId: folderId }).catch(() => {});
api.put(`/notes/${id}`, updates).catch(() => {});
} else {
await enqueue({ action: "updateNote", data: { id, updates: { notebookId: folderId } } });
await enqueue({ action: "updateNote", data: { id, updates } });
notifyStatus("pending");
}
}
@@ -1096,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
@@ -1127,6 +1527,22 @@ export async function loadPdfBytes(attachmentId: string): Promise<Uint8Array> {
return new Uint8Array(await blob.arrayBuffer());
}
/* ─── The text inside a PDF ───
*
* Read on a device, stored with the file, handed to every other device. The
* server never opens a PDF; it keeps this the way it keeps a note body —
* encrypted, and unread by it. */
/** The stored text of a PDF, page by page, or null if nobody has read it yet. */
export async function fetchPdfText(attachmentId: string): Promise<string[] | null> {
const r = await api.get<{ pages: string[] | null }>(`/attachments/${attachmentId}/text`);
return r.pages ?? null;
}
export async function storePdfText(attachmentId: string, pages: string[]): Promise<void> {
await api.put(`/attachments/${attachmentId}/text`, { pages });
}
/** Creates a note that holds a PDF. */
export async function createPdfNote(
folderId: string | null,
+80
View File
@@ -45,6 +45,11 @@ export interface NoteMeta {
/** Attachment holding the PDF, for notes of type "pdf". */
pdfAttachmentId?: string | null;
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;
@@ -80,16 +85,61 @@ 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;
order: number;
/**
* The list it was drawn in: a chapter of the notebook, or null for the
* notebook itself. `order` counts the notes before it, which only means
* 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.
*
* A note is in exactly one chapter, or in none — which means the notebook
* itself. Deleting a chapter never deletes its notes; they come back to the
* notebook, where they can be seen and filed again.
*/
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;
}
export interface Notebook {
id: string;
name: string;
cover: number;
/** Pattern laid over the colour, chosen separately from it. */
coverPattern?: number;
sortOrder: number;
/** Versions kept per note here; null means the server's default. */
maxVersions?: number | null;
@@ -103,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 {
@@ -115,6 +177,24 @@ 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.
*
* Sent by the server rather than written into the interface: it is set with
* `MAX_VERSIONS_PER_NOTE`, and a dialog that said "50" would start lying the
* day that is changed.
*/
defaultMaxVersions?: number;
}
/** A user row as the admin panel sees it. */
+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={{
+215 -92
View File
@@ -1,4 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
/** Below this the screen is too narrow for a menu to fly out beside another. */
const NARROW = 620;
export function ContextMenu({ x, y, items, onClose, dark }) {
const T = useMemo(
@@ -7,25 +10,56 @@ 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]
);
const ref = useRef(null);
/* Placed after measuring, not from a guess.
*
* The corner used to be pinned with `innerWidth - 220`, a stand-in for a width
* nobody had measured — which put the menu in the wrong place whenever it was
* not 220 wide, and left a submenu opening off the side of a phone. */
const [pos, setPos] = useState({ left: x, top: y, ready: false });
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const { width, height } = el.getBoundingClientRect();
const M = 8;
setPos({
left: Math.max(M, Math.min(x, window.innerWidth - width - M)),
top: Math.max(M, Math.min(y, window.innerHeight - height - M)),
ready: true,
});
}, [x, y, items]);
useEffect(() => {
const h = () => onClose();
const onKey = (e) => e.key === "Escape" && onClose();
window.addEventListener("click", h);
window.addEventListener("contextmenu", h);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("click", h);
window.removeEventListener("contextmenu", h);
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
return (
<div
ref={ref}
data-context-menu
style={{
position: "fixed",
left: Math.min(x, window.innerWidth - 220),
top: Math.min(y, window.innerHeight - 200),
left: pos.left,
top: pos.top,
// Hidden for the one frame it takes to measure, so it is never seen in
// the wrong place first.
visibility: pos.ready ? "visible" : "hidden",
zIndex: 3000,
background: T.bg,
border: `1px solid ${T.border}`,
@@ -33,64 +67,200 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
padding: 4,
minWidth: 190,
maxWidth: "calc(100vw - 16px)",
/* No `overflow` here. A submenu flies out sideways, and any scrolling
ancestor clips it away — which is exactly what happened: the list of
notebooks was cut off and every click on it landed on the note list
behind. The submenu does its own scrolling instead, and these menus
are half a dozen items long. */
fontFamily: "'DM Sans',sans-serif",
}}
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} />
) : (
<button
key={i}
onClick={() => {
item.action();
onClose();
}}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "8px 12px",
borderRadius: 8,
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
background: "transparent",
color: item.danger ? T.danger : T.text,
textAlign: "left",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
{item.label}
</button>
)
)}
{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>
);
}
function MenuButton({ T, item, onClose, small }) {
return (
<button
onClick={() => {
item.action();
onClose();
}}
style={{
display: "flex",
alignItems: "center",
gap: small ? 8 : 10,
width: "100%",
// A finger needs a target; 8px of padding gives a 30px row, which is
// under every published minimum and was being missed on a phone.
padding: small ? "10px 12px" : "11px 12px",
borderRadius: 8,
border: "none",
cursor: "pointer",
fontSize: small ? 12.5 : 13,
fontWeight: 500,
background: "transparent",
color: item.danger ? T.danger : T.text,
textAlign: "left",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
{item.colorDot && (
<div style={{ width: 12, height: 8, borderRadius: 2, background: item.colorDot, flexShrink: 0 }} />
)}
{/* 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>
);
}
/**
* A menu inside a menu — "Move to", and the notebooks under it.
*
* Three things it has to get right, all of which it used to get wrong:
*
* - **It opens on a tap**, not only on hover. There is no hover on a phone, so
* the entry could not be opened there at all.
* - **On a narrow screen it unfolds in place** rather than flying out beside the
* menu. A phone has no room beside anything.
* - **On a wide one it flips to the left** when the right would put it off the
* edge. It used to be pinned to `left: 100%` whatever was there, which is how
* a list of notebooks ended up somewhere nobody could see it.
*/
function SubMenu({ item, T, onClose }) {
const [open, setOpen] = useState(false);
const [side, setSide] = useState("right");
const rowRef = useRef(null);
const [inline, setInline] = useState(() => window.innerWidth < NARROW);
useLayoutEffect(() => {
if (!open) return;
const narrow = window.innerWidth < NARROW;
setInline(narrow);
if (narrow) return;
const row = rowRef.current?.getBoundingClientRect();
if (!row) return;
// 180 is this submenu's minimum width; going off the right edge means it
// belongs on the other side.
// `left: 100%` places it to the right of the row, and `right: 100%` to the
// left — so the property is the opposite of the side it ends up on.
setSide(row.right + 180 > window.innerWidth - 8 ? "right" : "left");
}, [open]);
const panel = (
<div
data-submenu
style={
inline
? { margin: "2px 0 4px 10px", borderLeft: `1px solid ${T.border}`, paddingLeft: 6 }
: {
position: "absolute",
[side]: "100%",
top: 0,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 10,
boxShadow: "0 8px 20px rgba(0,0,0,0.2)",
padding: 4,
minWidth: 180,
maxHeight: "60vh",
overflowY: "auto",
zIndex: 10,
}
}
>
{renderItems(item.submenu, T, onClose, true)}
</div>
);
return (
<div
style={{ position: "relative" }}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onMouseEnter={() => window.innerWidth >= NARROW && setOpen(true)}
onMouseLeave={() => window.innerWidth >= NARROW && setOpen(false)}
>
<div
ref={rowRef}
data-submenu-toggle
/* Narrow: a tap opens it and a second one folds it away again. Wide:
hover already governs, so a click only ever opens — toggling there
would fight the pointer that is sitting on the row. */
onClick={() => setOpen((v) => (window.innerWidth < NARROW ? !v : true))}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "8px 12px",
padding: "11px 12px",
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
@@ -101,58 +271,11 @@ function SubMenu({ item, T, onClose }) {
>
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
{item.label}
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>▸</span>
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>
{inline && open ? "▾" : side === "right" ? "◂" : "▸"}
</span>
</div>
{open && (
<div
style={{
position: "absolute",
left: "100%",
top: 0,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 10,
boxShadow: "0 8px 20px rgba(0,0,0,0.2)",
padding: 4,
minWidth: 160,
zIndex: 10,
}}
>
{item.submenu.map((sub, j) => (
<button
key={j}
onClick={() => {
sub.action();
onClose();
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "7px 12px",
borderRadius: 6,
border: "none",
cursor: "pointer",
fontSize: 12,
fontWeight: 500,
background: "transparent",
color: T.text,
textAlign: "left",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
{sub.colorDot && (
<div
style={{ width: 12, height: 8, borderRadius: 2, background: sub.colorDot, flexShrink: 0 }}
/>
)}
{sub.label}
</button>
))}
</div>
)}
{open && panel}
</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}
+108 -30
View File
@@ -1,5 +1,105 @@
import { useState } from "react";
import { COVERS } from "./constants.jsx";
import { coverBackground, coverEmblem, coverStyle } from "./constants.jsx";
/**
* The mark in the middle of a cover.
*
* Every notebook used to wear the same pair of rings, whatever cover was
* chosen — which is most of why a shelf of them looked alike. It belongs to the
* design now, and the designs whose pattern already fills the face wear nothing.
*
* Drawn with borders rather than with an image, so it takes the cover's own
* accent colour and stays sharp at any size.
*/
function Emblem({ kind, accent }) {
if (kind === "none") return null;
const box = {
position: "absolute",
top: 28,
left: "50%",
transform: "translateX(-50%)",
width: 50,
height: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
const line = `${accent}55`;
const faint = `${accent}33`;
if (kind === "ring") {
return (
<div style={{ ...box, borderRadius: "50%", border: `1.5px solid ${accent}44` }}>
<div style={{ width: 30, height: 30, borderRadius: "50%", border: `1px solid ${faint}` }} />
</div>
);
}
if (kind === "square") {
return (
<div style={{ ...box, border: `1.5px solid ${accent}44`, borderRadius: 3 }}>
<div style={{ width: 26, height: 26, border: `1px solid ${faint}`, borderRadius: 2 }} />
</div>
);
}
if (kind === "diamond") {
return (
<div style={box}>
<div
style={{
width: 34,
height: 34,
border: `1.5px solid ${accent}44`,
transform: "rotate(45deg)",
borderRadius: 3,
}}
/>
</div>
);
}
if (kind === "dot") {
return (
<div style={box}>
<div style={{ width: 16, height: 16, borderRadius: "50%", background: line }} />
</div>
);
}
if (kind === "cross") {
return (
<div style={{ ...box, flexDirection: "column", gap: 0 }}>
<div style={{ width: 40, height: 1.5, background: line }} />
<div style={{ width: 1.5, height: 40, background: line, marginTop: -20.75, marginBottom: -20.75 }} />
</div>
);
}
if (kind === "arc") {
return (
<div style={box}>
<div
style={{
width: 44,
height: 44,
borderRadius: "50%",
borderTop: `1.5px solid ${accent}55`,
borderRight: `1.5px solid ${accent}22`,
borderBottom: "1.5px solid transparent",
borderLeft: `1.5px solid ${accent}22`,
}}
/>
</div>
);
}
if (kind === "bars") {
return (
<div style={{ ...box, gap: 5 }}>
{[18, 30, 22].map((h, i) => (
<div key={i} style={{ width: 2, height: h, borderRadius: 1, background: line }} />
))}
</div>
);
}
return null;
}
/* ═══════ NOTEBOOK COVER ═══════ */
export function NotebookCover({
@@ -15,7 +115,11 @@ export function NotebookCover({
onDragOver,
onDrop,
}) {
const cover = COVERS[notebook.cover % COVERS.length];
// Colour and pattern are two choices; the spine and accent come from the
// colour, the face from both.
const cover = coverStyle(notebook.cover, notebook.coverPattern);
const face = coverBackground(notebook.cover, notebook.coverPattern);
const emblem = coverEmblem(notebook.cover, notebook.coverPattern);
const [hovered, setHovered] = useState(false);
return (
<button
@@ -114,7 +218,7 @@ export function NotebookCover({
top: 0,
right: 0,
bottom: 0,
background: cover.bg,
background: face,
borderRadius: "2px 8px 8px 2px",
boxShadow: isActive
? `0 4px 20px rgba(0,0,0,0.3),0 0 0 3px ${cover.accent}88`
@@ -123,14 +227,6 @@ export function NotebookCover({
zIndex: 3,
}}
>
<div
style={{
position: "absolute",
inset: 0,
background:
"repeating-linear-gradient(45deg,transparent,transparent 30px,rgba(255,255,255,0.015) 30px,rgba(255,255,255,0.015) 60px)",
}}
/>
<div
style={{
position: "absolute",
@@ -142,25 +238,7 @@ export function NotebookCover({
opacity: 0.7,
}}
/>
<div
style={{
position: "absolute",
top: 28,
left: "50%",
transform: "translateX(-50%)",
width: 50,
height: 50,
borderRadius: "50%",
border: `1.5px solid ${cover.accent}44`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{ width: 30, height: 30, borderRadius: "50%", border: `1px solid ${cover.accent}33` }}
/>
</div>
<Emblem kind={emblem} accent={cover.accent} />
<div
style={{
position: "absolute",
+108 -62
View File
@@ -1,9 +1,89 @@
import { useMemo, useState } from "react";
import { COVERS } from "./constants.jsx";
import { COVERS, PATTERNS, coverBackground, coverParts } from "./constants.jsx";
import { useBackdropClose } from "./backdrop";
export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
/**
* Choosing a cover: its colour, then the pattern over it.
*
* Two decisions rather than one list. Offering every combination as its own
* entry meant fifteen colours times a dozen patterns in a grid nobody could take
* in — and only the handful someone had thought to write out. Each pattern is
* previewed over the colour that is currently chosen, so what is on screen is
* the cover as it will be, not a sample of the pattern on its own.
*/
function CoverChooser({ T, cover, pattern, onCover, onPattern }) {
const swatch = (selected) => ({
width: "100%",
aspectRatio: "3/4",
borderRadius: 8,
border: selected ? `3px solid ${T.active}` : "2px solid transparent",
cursor: "pointer",
position: "relative",
overflow: "hidden",
});
return (
<div style={{ marginBottom: 22 }}>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Cover colour</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(5,1fr)",
gap: 8,
maxHeight: 200,
overflowY: "auto",
paddingRight: 4,
}}
>
{COVERS.map((c, i) => (
<button
key={i}
data-cover-option={i}
onClick={() => onCover(i)}
style={{ ...swatch(coverParts(cover, pattern).cover === i), background: c.bg }}
>
<div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }} />
</button>
))}
</div>
<div style={{ fontSize: 12, color: T.muted, margin: "14px 0 8px" }}>Pattern</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(5,1fr)",
gap: 8,
maxHeight: 200,
overflowY: "auto",
paddingRight: 4,
}}
>
{PATTERNS.map((p, i) => (
<button
key={p.id}
data-pattern-option={i}
title={p.label}
onClick={() => onPattern(i)}
style={{
...swatch(coverParts(cover, pattern).pattern === i),
// Previewed over the colour in hand, because that is the cover.
background: coverBackground(cover, i),
}}
/>
))}
</div>
</div>
);
}
export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxVersions }) {
const backdrop = useBackdropClose(onClose);
const [name, setName] = useState(notebook.name);
const [sel, setSel] = useState(notebook.cover);
/* 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
it was drawn as rather than repainting itself. */
const [sel, setSel] = useState(() => coverParts(notebook.cover, notebook.coverPattern).cover);
const [pat, setPat] = useState(() => coverParts(notebook.cover, notebook.coverPattern).pattern);
// Empty means "use the server's default", which is why it is a string here and
// not a number with a stand-in value.
const [versions, setVersions] = useState(notebook.maxVersions == null ? "" : String(notebook.maxVersions));
@@ -11,7 +91,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
const save = () => {
if (!name.trim()) return;
const n = versions.trim() === "" ? null : Math.max(1, Math.round(Number(versions)));
onSave({ name: name.trim(), cover: sel, maxVersions: Number.isFinite(n) ? n : null });
onSave({ name: name.trim(), cover: sel, coverPattern: pat, maxVersions: Number.isFinite(n) ? n : null });
};
const T = useMemo(
() => ({
@@ -35,7 +115,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
@@ -83,14 +163,14 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
/>
<div style={{ marginBottom: 18 }}>
<label style={{ fontSize: 12, color: T.muted, display: "block", marginBottom: 5 }}>
Versionen pro Notiz
Versions per note
</label>
<input
type="number"
min="1"
value={versions}
onChange={(e) => setVersions(e.target.value)}
placeholder="Vorgabe"
placeholder={defaultMaxVersions ? String(defaultMaxVersions) : "Default"}
aria-label="Versions per note"
style={{
width: 110,
@@ -108,35 +188,17 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
}}
/>
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
Older versions are only deleted once newer ones push them out — never by age. Leave empty for the
server's default.
</div>
</div>
<div style={{ marginBottom: 22 }}>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Cover style</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 8 }}>
{COVERS.map((c, i) => (
<button
key={i}
onClick={() => setSel(i)}
style={{
width: "100%",
aspectRatio: "3/4",
borderRadius: 8,
background: c.bg,
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
cursor: "pointer",
position: "relative",
overflow: "hidden",
}}
>
<div
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
/>
</button>
))}
Older versions are only deleted once newer ones push them out — never by age. Leave it empty
for the server's default{defaultMaxVersions ? `, which is ${defaultMaxVersions}` : ""}.
</div>
</div>
<CoverChooser
T={T}
cover={sel}
pattern={pat}
onCover={setSel}
onPattern={setPat}
/>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onClose}
@@ -177,8 +239,10 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
}
export function CreateNotebookModal({ onClose, onCreate, dark }) {
const backdrop = useBackdropClose(onClose);
const [name, setName] = useState("");
const [sel, setSel] = useState(0);
const [pat, setPat] = useState(0);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
@@ -201,7 +265,7 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
@@ -245,34 +309,16 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim()) onCreate({ name: name.trim(), cover: sel });
if (e.key === "Enter" && name.trim()) onCreate({ name: name.trim(), cover: sel, coverPattern: pat });
}}
/>
<div style={{ marginBottom: 22 }}>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Choose cover style</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 8 }}>
{COVERS.map((c, i) => (
<button
key={i}
onClick={() => setSel(i)}
style={{
width: "100%",
aspectRatio: "3/4",
borderRadius: 8,
background: c.bg,
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
cursor: "pointer",
position: "relative",
overflow: "hidden",
}}
>
<div
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
/>
</button>
))}
</div>
</div>
<CoverChooser
T={T}
cover={sel}
pattern={pat}
onCover={setSel}
onPattern={setPat}
/>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onClose}
@@ -291,7 +337,7 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
</button>
<button
onClick={() => {
if (name.trim()) onCreate({ name: name.trim(), cover: sel });
if (name.trim()) onCreate({ name: name.trim(), cover: sel, coverPattern: pat });
}}
style={{
padding: "8px 20px",
+5 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { COVERS } from "./constants.jsx";
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()}
@@ -141,7 +143,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", on
height: 10,
borderRadius: 3,
flexShrink: 0,
background: COVERS[nb.cover % COVERS.length].bg,
background: coverBackground(nb.cover, nb.coverPattern),
}}
/>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
+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>
);
}
+93 -33
View File
@@ -1,5 +1,8 @@
import { COVERS, DEFAULT_FOLDERS } from "./constants.jsx";
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 = {
@@ -60,10 +63,73 @@ const countBadge = (T, color) => ({
* On a phone it becomes a full-screen overlay; `onClose` is what the backdrop
* and every navigation action call so it does not stay in the way.
*/
/**
* Installing the app, and why it cannot be.
*
* This used to be a button that appeared only when the browser had offered a
* prompt. When installing was impossible there was nothing on screen at all —
* no button, no reason — and the way to find out was to know how PWAs work.
*
* The three cases that are left are all things the person holding the phone can
* act on, which is the whole reason for saying them out loud.
*/
function InstallRow({ T, canInstall, reason, onInstall }) {
const [why, setWhy] = useState(false);
if (reason === "installed") return null;
const message =
reason === "insecure"
? "This browser will only install a site served over HTTPS. Reach InkNote at an https:// address and the offer appears — the offline cache needs the same thing, so both come back together."
: "Your browser has not offered to install it. Open its menu and look for “Install app” or “Add to Home screen”.";
return (
<div style={{ marginBottom: 8 }}>
<button
data-install-app={canInstall ? "ready" : reason}
onClick={() => (canInstall ? onInstall() : setWhy((v) => !v))}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "8px 12px",
borderRadius: 10,
border: `1px solid ${canInstall ? T.accent + "44" : T.border}`,
background: canInstall ? T.accentSoft : "transparent",
cursor: "pointer",
color: canInstall ? T.accent : T.muted,
fontSize: 12,
fontWeight: 600,
textAlign: "left",
}}
>
{I.download}
Install App
</button>
{!canInstall && why && (
<div
data-install-why
style={{
marginTop: 6,
padding: "8px 10px",
borderRadius: 8,
background: T.hover || "transparent",
border: `1px solid ${T.borderLight}`,
color: T.muted,
fontSize: 11,
lineHeight: 1.5,
}}
>
{message}
</div>
)}
</div>
);
}
export function Sidebar({
T,
dark,
onToggleDark,
user,
isMobile,
open,
onClose,
@@ -77,6 +143,7 @@ export function Sidebar({
onNotebookContextMenu,
dnd,
canInstall,
installReason,
onInstall,
syncStatus,
online,
@@ -85,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",
@@ -146,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>
@@ -285,7 +367,7 @@ export function Sidebar({
width: 20,
height: 14,
borderRadius: 3,
background: COVERS[nb.cover % COVERS.length].bg,
background: coverBackground(nb.cover, nb.coverPattern),
flexShrink: 0,
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
@@ -298,29 +380,7 @@ export function Sidebar({
</div>
<div style={{ marginTop: "auto", padding: "12px 14px", borderTop: `1px solid ${T.borderLight}` }}>
{canInstall && (
<button
onClick={onInstall}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "8px 12px",
borderRadius: 10,
border: `1px solid ${T.accent}44`,
background: T.accentSoft,
cursor: "pointer",
color: T.accent,
fontSize: 12,
fontWeight: 600,
marginBottom: 8,
}}
>
{I.download}
Install App
</button>
)}
<InstallRow T={T} canInstall={canInstall} reason={installReason} onInstall={onInstall} />
<div style={{ display: "flex", alignItems: "center", gap: 6, color: T.muted, fontSize: 12 }}>
<span style={{ color: syncColor(syncStatus, T) }}>●</span>
<span>{SYNC_LABELS[syncStatus] || "Sync error"}</span>
+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 };
}
+156
View File
@@ -39,7 +39,40 @@ export const COVERS = [
{ bg: "linear-gradient(135deg,#aa4b6b,#6b6b83,#3b8d99)", spine: "#7a3550", accent: "#f48fb1" },
{ bg: "linear-gradient(135deg,#1f1c2c,#928dab)", spine: "#15131f", accent: "#b39ddb" },
{ 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. */
@@ -48,3 +81,126 @@ export const DEFAULT_FOLDERS = [
{ id: "starred", name: "Starred", icon: "star" },
{ id: "trash", name: "Trash", icon: "trash" },
];
/**
* A pattern laid over a cover, chosen separately from its colour.
*
* Two decisions rather than one list: a cover is a colour *and* a texture, and
* offering every combination as its own entry would mean fifteen gradients times
* a dozen patterns in a grid nobody could take in. Picking each in turn is both
* shorter and gives every combination, not the handful someone wrote out.
*
* `layers` are CSS background layers laid **over** the gradient — no image
* files, nothing to upload, crisp at any size and offline like the rest of the
* app. They are deliberately faint: a cover is read at the size of a thumb with
* a title across it, and anything louder fights the title. One idea each, too —
* two crossed diagonals meant as a chevron came out as noisy plaid at that size.
*/
export const PATTERNS = [
{ emblem: "ring", id: "plain", label: "Plain", layers: "" },
{
id: "ruled",
emblem: "none",
label: "Ruled",
layers: "repeating-linear-gradient(0deg,rgba(255,255,255,.12) 0 1px,transparent 1px 17px)",
},
{
id: "grid",
emblem: "square",
label: "Grid",
layers:
"repeating-linear-gradient(0deg,rgba(255,255,255,.10) 0 1px,transparent 1px 20px), repeating-linear-gradient(90deg,rgba(255,255,255,.10) 0 1px,transparent 1px 20px)",
},
{
id: "pinstripe",
emblem: "diamond",
label: "Pinstripe",
layers: "repeating-linear-gradient(45deg,rgba(255,255,255,.09) 0 6px,transparent 6px 14px)",
},
{
id: "bands",
emblem: "none",
label: "Bands",
layers: "repeating-linear-gradient(0deg,rgba(0,0,0,.18) 0 13px,transparent 13px 26px)",
},
{
id: "dots",
emblem: "dot",
label: "Dots",
layers: "radial-gradient(rgba(255,255,255,.18) 1.4px,transparent 1.5px) 0 0/13px 13px",
},
{
id: "hatch",
emblem: "cross",
label: "Cross-hatch",
layers:
"repeating-linear-gradient(45deg,rgba(255,255,255,.08) 0 1px,transparent 1px 11px), repeating-linear-gradient(-45deg,rgba(255,255,255,.08) 0 1px,transparent 1px 11px)",
},
{
id: "linen",
emblem: "arc",
label: "Linen",
layers:
"repeating-linear-gradient(0deg,rgba(255,255,255,.07) 0 1px,transparent 1px 4px), repeating-linear-gradient(90deg,rgba(0,0,0,.07) 0 1px,transparent 1px 4px)",
},
{
id: "ledger",
emblem: "bars",
label: "Ledger",
layers: "repeating-linear-gradient(90deg,rgba(255,255,255,.09) 0 1px,transparent 1px 26px)",
},
{
id: "glow",
emblem: "none",
label: "Corner light",
layers: "radial-gradient(120% 85% at 18% 0%,rgba(255,255,255,.20),transparent 62%)",
},
{
id: "foot",
emblem: "square",
label: "Foot band",
layers: "linear-gradient(0deg,rgba(0,0,0,.34) 0 22%,transparent 22%)",
},
];
/**
* Covers written before patterns were their own choice.
*
* Twelve patterned entries were appended to `COVERS` for a day, so a notebook
* may still hold an index into them. Rather than let those repaint themselves,
* each is read back as the colour-and-pattern pair it was drawn as.
*/
const LEGACY_COVER = {
15: [11, 1], 16: [2, 2], 17: [3, 3], 18: [9, 4], 19: [5, 5],
20: [8, 6], 21: [12, 7], 22: [4, 4], 23: [8, 9], 24: [0, 9],
25: [2, 10], 26: [8, 8],
};
/** The colour and pattern a notebook is actually drawn with. */
export function coverParts(cover, pattern) {
const legacy = LEGACY_COVER[cover];
if (legacy) return { cover: legacy[0], pattern: legacy[1] };
return { cover: ((cover % COVERS.length) + COVERS.length) % COVERS.length, pattern: pattern || 0 };
}
/** The CSS `background` for a cover: its pattern laid over its colour. */
export function coverBackground(cover, pattern) {
const parts = coverParts(cover, pattern);
const base = COVERS[parts.cover];
const layers = PATTERNS[parts.pattern]?.layers;
return layers ? `${layers}, ${base.bg}` : base.bg;
}
/**
* The mark in the middle of a cover.
*
* It used to be the same pair of rings on every notebook, whatever cover was
* chosen — which, with the same title frame and the same three dots, is why a
* shelf of them all looked alike. It belongs to the design now: some wear a
* ring, some a diamond, and the ones whose pattern already fills the face wear
* nothing at all.
*/
export const coverEmblem = (cover, pattern) => PATTERNS[coverParts(cover, pattern).pattern]?.emblem || "none";
/** The rest of a cover's colours — the spine, and the accent used beside it. */
export const coverStyle = (cover, pattern) => COVERS[coverParts(cover, pattern).cover];
+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}` }),
}}
/>
);
}
+829 -44
View File
File diff suppressed because it is too large. Load diff