Compare commits
58
Commits
b8c5c42217
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4013e959d6 | ||
|
|
9507761bce | ||
|
|
c9148540a8 | ||
|
|
580eed8992 | ||
|
|
8f8fecfb96 | ||
|
|
7c1bbc8176 | ||
|
|
00b1d4469f | ||
|
|
2ea448f091 | ||
|
|
ead51b9fa0 | ||
|
|
5163f87356 | ||
|
|
1e2375f190 | ||
|
|
a2b3f91e00 | ||
|
|
40bdf81aa2 | ||
|
|
cd70a8c8bb | ||
|
|
26a48af620 | ||
|
|
43a98b0972 | ||
|
|
c86c63922a | ||
|
|
c0523f3ba4 | ||
|
|
641acaae40 | ||
|
|
1c0f2184e6 | ||
|
|
3b7958b713 | ||
|
|
ea1bca5d25 | ||
|
|
f01522aa6f | ||
|
|
312c4e190e | ||
|
|
a9391cdcf2 | ||
|
|
2ffa357dfc | ||
|
|
d68b0775c2 | ||
|
|
59844046c4 | ||
|
|
f1d3e38f8f | ||
|
|
0c312e53c2 | ||
|
|
c96885d5b3 | ||
|
|
cc0b58dd2f | ||
|
|
165ccf3cf7 | ||
|
|
ebf1f1bc62 | ||
|
|
b2a5f051fe | ||
|
|
4bb86bc5ac | ||
|
|
d74fea4905 | ||
|
|
6cad632a0c | ||
|
|
632ea5dcd9 | ||
|
|
9d5ac1db27 | ||
|
|
1757ec9af3 | ||
|
|
e037f01dc2 | ||
|
|
83b76b95a0 | ||
|
|
7cb26898aa | ||
|
|
8ca1ed6e06 | ||
|
|
0d8d1180de | ||
|
|
608da16ac2 | ||
|
|
6eecf57247 | ||
|
|
9e4677c70b | ||
|
|
635a9632d1 | ||
|
|
d82e72d358 | ||
|
|
816cc8e68b | ||
|
|
97a3764d92 | ||
|
|
3ce867c6d0 | ||
|
|
fcdd29ba2b | ||
|
|
230e55903e | ||
|
|
8e38404a69 | ||
|
|
228552d453 |
No files matched your search
@@ -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
|
||||
+466
-82
@@ -1,124 +1,508 @@
|
||||
# Changelog
|
||||
|
||||
What changed, newest first. One entry per round that went out as an image, with
|
||||
the digests, so it stays possible to tell what is actually running on the server.
|
||||
What changed, newest first — and the digests of every image that went out, so it
|
||||
stays possible to tell what is actually running on the server. Digests are read
|
||||
back from the registry (`docker buildx imagetools inspect`), never taken from the
|
||||
output of `docker push`.
|
||||
|
||||
Digests are recorded from the registry (`docker buildx imagetools inspect`),
|
||||
not from the output of `docker push`.
|
||||
Kept short on purpose. A day's work usually went out as several images, and a
|
||||
round that repaired what an earlier round the same day had broken says nothing
|
||||
worth keeping on its own — so each day is one entry, describing where it ended
|
||||
up, with every digest of that day listed. Where the server image is named as
|
||||
unchanged in substance, the digest still moved: `build.sh` builds both images and
|
||||
a rebuild is not bit-identical.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — The writing column
|
||||
## 2026-08-26 — The same page on every screen
|
||||
|
||||
**Images** — web `sha256:ed11f034…`, server unchanged at `sha256:1648498f…`
|
||||
**Images** — web `sha256:e475c080…`, server `sha256:49a98c66…`
|
||||
|
||||
The server image carries no change of its own; `build.sh` builds both.
|
||||
|
||||
**Changed**
|
||||
- The note column is centred again. It had been pinned to the left margin so the
|
||||
start of every line would always be in the same place; on a wide window that
|
||||
left the whole right half of the pane as one dead block.
|
||||
- A page surface was drawn behind the text to give that block a meaning, and
|
||||
reverted the same day: it made the sheet sit hard against the left edge, which
|
||||
was worse than the problem it solved.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — Pages, typefaces, alignment
|
||||
|
||||
**Images** — web `sha256:3b040d99…`, server `sha256:1648498f…`
|
||||
|
||||
**Added**
|
||||
- **Page count while writing.** The toolbar says *Page 2 of 4*, in the same words
|
||||
the PDF reader uses. Pages are counted, never stored: the note stays one
|
||||
document, and a test asserts no page ever reaches the stored body.
|
||||
- **The typeface is a per-account setting** — serif, sans or mono, chosen under
|
||||
Account → Profile. On the account rather than in the browser, so it follows you
|
||||
to the phone.
|
||||
- **Text alignment**, with its own TipTap extension rather than the official one,
|
||||
so the schema stays in files this project owns. Left is the default and is
|
||||
never written into the document. In Markdown, non-left blocks travel as
|
||||
`<div align="…">` — the one form most readers still honour — and the importer
|
||||
reads it back onto the paragraphs.
|
||||
- **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**
|
||||
- The Markdown exporter did not put a blank line between top-level blocks, so
|
||||
consecutive paragraphs ran together in other readers.
|
||||
- **"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
|
||||
|
||||
## 2026-08-11 — English throughout, and the PDF toolbar
|
||||
**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**
|
||||
- **Search inside a notebook**, from the note list. The sidebar's global search
|
||||
was removed: searching from inside a notebook should mean searching *that*
|
||||
notebook.
|
||||
- **New note / Upload PDF / Import Markdown** all live under the one `+` button.
|
||||
- **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 interface is English throughout. It had drifted into two languages.
|
||||
- The two visibility switches — drawing and margin notes — sit together at the
|
||||
far right of the PDF toolbar, because they mean the same kind of thing.
|
||||
- The drawing tools appear next to the *Draw* button, not past everything else.
|
||||
- A separator is renamed and removed by right-click, with a confirmation for the
|
||||
removal.
|
||||
- **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**
|
||||
- Hiding the drawing also hid the margin notes. Each switch now hides only its
|
||||
own thing.
|
||||
- **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
|
||||
|
||||
**Added**
|
||||
- **Pinned notes in a PDF.** A remark can be attached to a passage, and is shown
|
||||
in the margin beside the page — not written on top of it. On a phone, where
|
||||
there is no margin, the same notes open as a bottom sheet.
|
||||
- **Margin notes can be linked from the notes panel**, so a pinned remark can be
|
||||
written about at length and still lead back to the passage it came from.
|
||||
|
||||
**Fixed**
|
||||
- A grey drag indicator stayed visible in the note list after a drag. It was a
|
||||
`borderTop` sitting beside a `border` shorthand in one style object.
|
||||
- The server stored an overlay holding only pinned notes as `NULL`, throwing
|
||||
every one of them away, because it counted strokes and nothing else.
|
||||
- **Pinned notes in a PDF.** A remark is attached to a passage and shown in the
|
||||
margin beside the page, never written on top of it; on a phone it opens as a
|
||||
bottom sheet. Such a remark can be linked from the notes panel and written about
|
||||
at length.
|
||||
- The server stored an overlay holding only pinned notes as `NULL`, throwing every
|
||||
one of them away, because it counted strokes and nothing else.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-09 — Arranging a notebook
|
||||
|
||||
**Added**
|
||||
- **Sorting per notebook**, remembered on the notebook itself, including an
|
||||
arrangement made by hand — which comes back intact after filtering.
|
||||
- **Separators** to group notes inside a hand-made arrangement.
|
||||
- **Markdown import**, single files or a ZIP this app exported.
|
||||
- **Moving notes between notebooks.**
|
||||
|
||||
**Fixed**
|
||||
- Switching to Draw shrank the page from 733px to 460px. The editor was a flex
|
||||
item at the default `flex: 0 1 auto`, so it was only as wide as its widest row
|
||||
— the toolbar, which loses a dozen buttons in Draw.
|
||||
- **Sorting per notebook**, remembered on the notebook, including an arrangement
|
||||
made by hand — which comes back intact after filtering.
|
||||
- **Separators**, **Markdown import** (single files or a ZIP this app exported),
|
||||
and **moving notes between notebooks**.
|
||||
- Switching to Draw shrank the page from 733px to 460px: the editor was a flex
|
||||
item at `flex: 0 1 auto`, so it was only as wide as its widest row — the
|
||||
toolbar, which loses a dozen buttons in Draw.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-08 — PDFs
|
||||
|
||||
**Added**
|
||||
- Import a PDF, read it, select its text, draw on each page, and export it with
|
||||
the drawings written in.
|
||||
- Notes written beside a PDF.
|
||||
the drawings written in. Notes written beside a PDF.
|
||||
- Uploaded files are encrypted at rest, like note bodies already were, and stored
|
||||
as deduplicated, content-addressed attachments.
|
||||
- Unused attachments are freed; versions are kept by count rather than by age.
|
||||
as deduplicated, content-addressed attachments; unused ones are freed, and
|
||||
versions are kept by count rather than by age.
|
||||
- Export as Markdown or PDF; the browser suites moved into the repository.
|
||||
|
||||
**Changed**
|
||||
- Vector clocks were never read. Replaced with a conflict check that works.
|
||||
- Public sign-up removed — an administrator creates the accounts.
|
||||
- Vector clocks were never read — replaced with a conflict check that works.
|
||||
- Public sign-up removed: an administrator creates the accounts.
|
||||
|
||||
---
|
||||
|
||||
## Earlier
|
||||
|
||||
TipTap editor, version history, full-text search and attachments (2026-08-07);
|
||||
drawing sync with WebP/JPEG support (2026-06-21); the first syncing alpha and PWA
|
||||
(2026-02-18).
|
||||
The interface became English throughout, and search inside a notebook replaced
|
||||
the sidebar's global search (2026-08-11). Before that: the TipTap editor, version
|
||||
history, full-text search and attachments (2026-08-07); drawing sync with
|
||||
WebP/JPEG support (2026-06-21); the first syncing alpha and PWA (2026-02-18).
|
||||
@@ -22,6 +22,11 @@ Diese Punkte sind Entscheidungen von Marc, keine offenen Fragen:
|
||||
- **Keine öffentliche Registrierung.** Konten legt ein Administrator an. Ein
|
||||
Admin darf anlegen, löschen und Passwörter zurücksetzen – aber **keine**
|
||||
weiteren Admins ernennen und **keine** fremden Konten umbenennen.
|
||||
- **OIDC ist die zweite Anmeldeart, nicht die einzige.** Wer sich beim Anbieter
|
||||
anmelden darf, bekommt hier ein Konto (Marcs Entscheidung, 18.08.2026) – aber
|
||||
**nie** Admin-Rechte, die werden weiter nur in der App vergeben. Der
|
||||
Passwort-Login bleibt immer erreichbar, sonst sperrt ein kaputter Anbieter
|
||||
alle aus. Ohne `INKNOTE_OIDC_*` in der `.env` ist davon nichts zu sehen.
|
||||
- **Bundle-Größe bleibt, wie sie ist.** Bewusst so entschieden.
|
||||
- **Dashboard und Seitenleiste sind vertagt.** Die offenen Befunde dazu sind
|
||||
bekannt; nicht ungefragt damit anfangen.
|
||||
@@ -31,10 +36,10 @@ Diese Punkte sind Entscheidungen von Marc, keine offenen Fragen:
|
||||
Beides muss vor einem Push grün sein.
|
||||
|
||||
```bash
|
||||
# Unit (224 Tests, ~4 s)
|
||||
# Unit (~4 s)
|
||||
docker run --rm -v "$PWD/frontend:/w" -w /w node:20-alpine npx vitest run
|
||||
|
||||
# Browser, zwölf Suiten (~263 Checks) – braucht den Dev-Stack
|
||||
# Browser, vierzehn Suiten – braucht den Dev-Stack
|
||||
./e2e/run.sh # alle
|
||||
./e2e/run.sh editor drawing # einzelne
|
||||
```
|
||||
@@ -70,6 +75,19 @@ 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
|
||||
|
||||
+431
@@ -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
@@ -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 ─── */
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
+21
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
+119
-20
@@ -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,11 +50,15 @@
|
||||
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;
|
||||
/* Centred. 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 — which is worse to look at than a column that moves. Centred,
|
||||
the same leftover space reads as two margins. */
|
||||
/* 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;
|
||||
@@ -49,8 +66,8 @@
|
||||
/* Set per account — see UserPreferences. The stack is the fallback chain, so
|
||||
an unset preference still reads as the serif it always was. */
|
||||
font-family: var(--ne-font, "Newsreader", Georgia, serif);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
font-size: var(--ne-size, 15px);
|
||||
line-height: var(--ne-line);
|
||||
padding: 20px 24px;
|
||||
min-height: 100%;
|
||||
word-break: break-word;
|
||||
@@ -58,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;
|
||||
}
|
||||
@@ -91,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);
|
||||
@@ -145,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;
|
||||
@@ -162,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: "";
|
||||
@@ -173,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;
|
||||
@@ -197,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 {
|
||||
|
||||
@@ -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 }),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) }),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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```");
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
@@ -112,6 +158,7 @@ export function NoteEditor({
|
||||
content: note.content,
|
||||
editable: mode === "text",
|
||||
searchLinkTargets,
|
||||
pageGeometry,
|
||||
onChange: ({ content, links }) => onUpdate({ content, links }),
|
||||
onError: (msg) => {
|
||||
setEditorNotice(msg);
|
||||
@@ -164,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;
|
||||
@@ -198,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;
|
||||
@@ -219,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 }));
|
||||
};
|
||||
|
||||
@@ -244,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);
|
||||
@@ -264,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
|
||||
@@ -297,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();
|
||||
@@ -305,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) };
|
||||
};
|
||||
@@ -321,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();
|
||||
@@ -332,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],
|
||||
@@ -351,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();
|
||||
};
|
||||
|
||||
@@ -388,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();
|
||||
@@ -402,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. */
|
||||
@@ -482,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={{
|
||||
@@ -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>
|
||||
</>
|
||||
@@ -719,50 +827,108 @@ export function NoteEditor({
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
// Without a page the column is as wide as it needs to be to read
|
||||
// comfortably, and centred; with one it is the canvas's page.
|
||||
// The text caps its own width in the stylesheet, so this is only
|
||||
// the page when there is one.
|
||||
width: needsPage ? stageSize.w : "100%",
|
||||
minHeight: needsPage ? stageSize.h : undefined,
|
||||
width: stageSize.w,
|
||||
maxWidth: "100%",
|
||||
// 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",
|
||||
background: needsPage ? T.bg : "transparent",
|
||||
border: needsPage && !isMobileViewport ? `1px solid ${T.border}` : "none",
|
||||
borderRadius: needsPage && !isMobileViewport ? 14 : 0,
|
||||
boxShadow: needsPage && !isMobileViewport ? "0 16px 36px rgba(0,0,0,0.14)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* A hairline where each page ends, so the count in the toolbar
|
||||
corresponds to something you can see. Drawn behind the text and
|
||||
deaf to the pointer — it marks the paper, it is not part of it. */}
|
||||
{pages.count > 1 && mode === "text" && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
backgroundImage: `linear-gradient(to bottom, transparent calc(100% - 1px), ${T.border} 100%)`,
|
||||
backgroundSize: `100% ${stageSize.h}px`,
|
||||
opacity: 0.55,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 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",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: i * pitch,
|
||||
height: stageSize.h,
|
||||
pointerEvents: "none",
|
||||
background: spare ? "transparent" : T.bg,
|
||||
/* On a phone the sheet used to be drawn with no edge at all:
|
||||
full width, no border, no shadow — which left nothing to
|
||||
see where one page ended and the next began. The paper
|
||||
still runs to the edges there, but it has an outline, so
|
||||
the division is visible. */
|
||||
borderStyle: spare ? "dashed" : "solid",
|
||||
borderWidth: 1,
|
||||
borderColor: T.border,
|
||||
borderRadius: isMobileViewport && !spare ? 0 : 4,
|
||||
boxShadow: isMobileViewport || spare ? "none" : "0 2px 10px rgba(0,0,0,0.22)",
|
||||
opacity: spare ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{spare && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.5,
|
||||
color: T.faint,
|
||||
}}
|
||||
>
|
||||
Draw here to add a page
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
style={{
|
||||
minHeight: needsPage ? stageSize.h : undefined,
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
minHeight: stageSize.h,
|
||||
width: "100%",
|
||||
pointerEvents: mode === "text" ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<EditorSurface
|
||||
editor={textEditor}
|
||||
dark={dark}
|
||||
font={noteFont}
|
||||
minHeight={needsPage ? stageSize.h : 0}
|
||||
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 && (
|
||||
@@ -784,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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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": "" },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 === "[") {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}` }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user