Compare commits
54
Commits
fcdd29ba2b
..
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 |
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
|
||||
+465
-113
@@ -1,156 +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 — Drawing on every page, and PDFs findable everywhere
|
||||
## 2026-08-26 — The same page on every screen
|
||||
|
||||
**Images** — web `sha256:dc804f7b…`, server `sha256:0832ee2b…`
|
||||
**Images** — web `sha256:e475c080…`, server `sha256:49a98c66…`
|
||||
|
||||
The server image changed this round: it takes a new column and two routes.
|
||||
`docker compose pull && docker compose up -d` — the column is added on start.
|
||||
|
||||
**Added**
|
||||
- **A PDF is searchable on every device, not only on the one that opened it.**
|
||||
The device that imports the file reads it there and then — the server still has
|
||||
no PDF library and never opens one — and the extracted text is stored beside
|
||||
the file, encrypted, so every other device can find the document without ever
|
||||
downloading it. This is what makes an archive of scanned invoices work: they
|
||||
are filed and then never opened, so waiting for someone to open one meant
|
||||
waiting forever.
|
||||
- **A drawing runs over as many pages as the note has.** The ink used to be stuck
|
||||
on page one while the writing carried on without it. Pages are numbered the way
|
||||
the PDF reader numbers them, and there is always one blank page past the end to
|
||||
start a new one on. The bin clears the page being looked at; undo still works on
|
||||
the drawing as a whole.
|
||||
- A drawing that lives entirely on page one is still stored in the old shape, so
|
||||
an ordinary note is written exactly as it always was and an older client still
|
||||
shows it.
|
||||
|
||||
**Fixed**
|
||||
- **A drawing exported in a ZIP was lost on re-import.** The exporter has written
|
||||
`drawings/<note>.png` since the interface was put into English; the importer
|
||||
was still looking for `zeichnungen/<note>.png` and found nothing. Both names
|
||||
are read now, and so are the further pages of a drawing.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — The writing column
|
||||
|
||||
**Images** — web `sha256:ed11f034…`, server unchanged at `sha256:1648498f…`
|
||||
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.
|
||||
@@ -34,7 +39,7 @@ Beides muss vor einem Push grün sein.
|
||||
# Unit (~4 s)
|
||||
docker run --rm -v "$PWD/frontend:/w" -w /w node:20-alpine npx vitest run
|
||||
|
||||
# Browser, zwölf Suiten – braucht den Dev-Stack
|
||||
# Browser, vierzehn Suiten – braucht den Dev-Stack
|
||||
./e2e/run.sh # alle
|
||||
./e2e/run.sh editor drawing # einzelne
|
||||
```
|
||||
@@ -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,
|
||||
};
|
||||
+615
-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
|
||||
@@ -407,6 +452,97 @@ if (!tableColumns("users").includes("preferences")) {
|
||||
*
|
||||
* 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");
|
||||
}
|
||||
@@ -593,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();
|
||||
@@ -647,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) => {
|
||||
@@ -672,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)));
|
||||
@@ -689,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);
|
||||
@@ -703,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);
|
||||
@@ -835,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);
|
||||
@@ -853,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. */
|
||||
@@ -865,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" });
|
||||
|
||||
@@ -887,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);
|
||||
}
|
||||
@@ -901,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 });
|
||||
});
|
||||
|
||||
@@ -998,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);
|
||||
});
|
||||
|
||||
@@ -1063,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);
|
||||
@@ -1127,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;
|
||||
|
||||
@@ -1159,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);
|
||||
|
||||
@@ -1562,6 +2008,142 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
|
||||
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
|
||||
@@ -1807,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);
|
||||
}
|
||||
@@ -149,6 +149,19 @@ try {
|
||||
(await p.locator('canvas[data-draw-page="2"]').count()) === 1,
|
||||
"there is a blank page past the end to draw on, before anything is written"
|
||||
);
|
||||
/* It has to read as an offer, not as a page that appeared by itself. Drawn
|
||||
solid, a second sheet turned up the moment Draw was pressed while the count
|
||||
still said "of 1", which looks like the app inventing a page. */
|
||||
check(
|
||||
(await p.locator("[data-spare-page]").count()) === 1,
|
||||
"and it is marked as the spare rather than drawn as paper",
|
||||
String(await p.locator("[data-spare-page]").count())
|
||||
);
|
||||
check(
|
||||
(await p.locator("[data-note-position]").innerText()).includes("of 1"),
|
||||
"so the count still says one page until something is on it",
|
||||
await p.locator("[data-note-position]").innerText()
|
||||
);
|
||||
|
||||
await draw(p, 1);
|
||||
const first = await inkGrid(p, 1);
|
||||
@@ -196,6 +209,30 @@ try {
|
||||
`${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);
|
||||
|
||||
+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());
|
||||
}
|
||||
+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;
|
||||
|
||||
+622
-51
@@ -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,12 +26,13 @@ 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";
|
||||
@@ -46,6 +51,44 @@ const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"];
|
||||
|
||||
const EMPTY_BODY = { content: "", drawingData: null, drawingStrokes: null };
|
||||
|
||||
/** What went wrong out there, said in words rather than in the slug. */
|
||||
const SIGN_IN_FAILURES = {
|
||||
provider_refused: "The identity provider did not allow that sign-in.",
|
||||
account_taken: "That identity belongs to a different account here.",
|
||||
state_unknown: "That sign-in took too long. Try again.",
|
||||
provider_unreachable: "The identity provider could not be reached.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Coming back from the identity provider.
|
||||
*
|
||||
* The provider sends the browser to the app with a one-time ticket rather than
|
||||
* with a token — a token in a URL lands in the history, in the proxy's log and
|
||||
* in the referrer of the next request. The ticket is swapped here, and the
|
||||
* address is cleaned either way: reloading the page must not try to redeem a
|
||||
* ticket that is already spent, and a bookmarked address must not carry one.
|
||||
*
|
||||
* Answers with the signed-in user, or null when there was nothing to do.
|
||||
*/
|
||||
async function claimProviderSignIn(reportFailure) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ticket = params.get("oidc");
|
||||
const failure = params.get("oidc_error");
|
||||
if (!ticket && !failure) return null;
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
|
||||
if (failure) {
|
||||
reportFailure(SIGN_IN_FAILURES[failure] || "That sign-in could not be completed. Try again.");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await sync.claimOidcTicket(ticket);
|
||||
} catch {
|
||||
reportFailure("That sign-in could not be completed. Try again.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Root of the application.
|
||||
*
|
||||
@@ -54,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);
|
||||
@@ -77,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();
|
||||
@@ -145,6 +256,8 @@ export default function App() {
|
||||
const openFolder = useCallback(
|
||||
(id) => {
|
||||
setActiveFolder(id);
|
||||
setActiveChapter(null);
|
||||
setInChapter(false);
|
||||
setActiveNote(null);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
@@ -180,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";
|
||||
|
||||
@@ -187,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
|
||||
@@ -205,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();
|
||||
@@ -221,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",
|
||||
@@ -243,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
|
||||
@@ -261,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
|
||||
@@ -286,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)
|
||||
@@ -318,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 ── */
|
||||
@@ -398,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({
|
||||
@@ -497,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) => {
|
||||
@@ -549,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,
|
||||
@@ -560,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";
|
||||
|
||||
@@ -570,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,
|
||||
@@ -595,8 +1035,8 @@ export default function App() {
|
||||
|
||||
<Sidebar
|
||||
T={T}
|
||||
user={user}
|
||||
dark={dark}
|
||||
onToggleDark={() => setDark(!dark)}
|
||||
isMobile={isMobile}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
@@ -610,6 +1050,7 @@ export default function App() {
|
||||
onNotebookContextMenu={notebookContextMenu}
|
||||
dnd={dnd}
|
||||
canInstall={canInstall}
|
||||
installReason={installReason}
|
||||
onInstall={promptInstall}
|
||||
syncStatus={syncStatus}
|
||||
online={sync.isOnline()}
|
||||
@@ -649,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}
|
||||
@@ -666,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}
|
||||
@@ -679,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)}
|
||||
@@ -699,6 +1227,7 @@ export default function App() {
|
||||
notebook={editingNb}
|
||||
onClose={() => setEditingNb(null)}
|
||||
onSave={(u) => updateNotebook(editingNb.id, u)}
|
||||
defaultMaxVersions={user?.defaultMaxVersions}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
@@ -779,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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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";
|
||||
@@ -25,6 +25,31 @@ import {
|
||||
} from "../drawing/strokes.js";
|
||||
import { COLORS } from "../../ui/constants.jsx";
|
||||
|
||||
/* ── the page the text is laid out on ──
|
||||
*
|
||||
* One page, one size, everywhere: the text is set on a sheet of exactly these
|
||||
* units and the whole sheet is then scaled to whatever width the screen offers.
|
||||
* Laying it out in screen pixels instead is what made a phone and a desktop
|
||||
* disagree — the same note broke into different lines and different pages on
|
||||
* each, because a 340-pixel sheet held nothing like what a 760-pixel one did.
|
||||
* Now the scale is the only difference between them.
|
||||
*
|
||||
* The shape is the drawing's own page, so "page 2" means the same sheet whether
|
||||
* you write on it or draw on it.
|
||||
*/
|
||||
const PAGE_W = 760;
|
||||
const PAGE_H = Math.round((PAGE_W * CANON_H) / CANON_W);
|
||||
const PAGE_GAP = 16;
|
||||
|
||||
/* Twenty-one lines to the page, the way Samsung Notes rules its paper — and the
|
||||
* type size follows from that rather than being chosen and hoped for. The two
|
||||
* numbers below are what `editor.css` sets on the writing surface; if either
|
||||
* moves there, the size recomputed here keeps the count. */
|
||||
const PAGE_PAD = 20;
|
||||
const LINE_HEIGHT = 1.7;
|
||||
const LINES_PER_PAGE = 21;
|
||||
const TEXT_SIZE = Math.floor((PAGE_H - PAGE_PAD * 2) / LINES_PER_PAGE / LINE_HEIGHT);
|
||||
|
||||
import { I } from "../../ui/icons.jsx";
|
||||
import { BacklinksPanel } from "../../ui/BacklinksPanel.jsx";
|
||||
|
||||
@@ -37,6 +62,7 @@ export function NoteEditor({
|
||||
onOpenNote,
|
||||
searchLinkTargets,
|
||||
resolveLinkLabel,
|
||||
resolveLinkIcon,
|
||||
backlinks,
|
||||
}) {
|
||||
const [mode, setMode] = useState("text");
|
||||
@@ -84,14 +110,26 @@ export function NoteEditor({
|
||||
// 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.
|
||||
/* How much of a page fits on this screen: the one number that separates a
|
||||
* phone from a desktop. Everything about the writing itself — line breaks,
|
||||
* page breaks, where a paragraph starts — is decided in page units and then
|
||||
* multiplied by this. */
|
||||
const scale = stageSize.w / PAGE_W;
|
||||
|
||||
/* The gap between two sheets, in page units like everything else.
|
||||
*
|
||||
* The 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;
|
||||
* 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;
|
||||
@@ -120,6 +158,7 @@ export function NoteEditor({
|
||||
content: note.content,
|
||||
editable: mode === "text",
|
||||
searchLinkTargets,
|
||||
pageGeometry,
|
||||
onChange: ({ content, links }) => onUpdate({ content, links }),
|
||||
onError: (msg) => {
|
||||
setEditorNotice(msg);
|
||||
@@ -172,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;
|
||||
@@ -227,21 +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;
|
||||
/* 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 — and the fourth would have no canvas to be redrawn on. */
|
||||
const written = 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 }));
|
||||
};
|
||||
|
||||
@@ -256,7 +307,7 @@ export function NoteEditor({
|
||||
scroller?.removeEventListener("scroll", recount);
|
||||
textEditor.off("transaction", recount);
|
||||
};
|
||||
}, [textEditor, stageSize.h, mode, drawnPages]);
|
||||
}, [textEditor, stageSize.h, scale, mode, drawnPages]);
|
||||
|
||||
/* ── DRAWING: painting ──
|
||||
* Only the page under the pointer is repainted while a stroke is being drawn;
|
||||
@@ -776,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 ? canvasPages * 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. */}
|
||||
{canvasPages > 1 && (
|
||||
<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 && (
|
||||
@@ -849,7 +958,19 @@ export function NoteEditor({
|
||||
<div
|
||||
key={page}
|
||||
data-drawn-page={page}
|
||||
style={{ position: "absolute", left: 0, right: 0, top: (page - 1) * stageSize.h, height: stageSize.h }}
|
||||
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)}
|
||||
@@ -875,7 +996,7 @@ export function NoteEditor({
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: (page - 1) * stageSize.h,
|
||||
top: (page - 1) * pitch,
|
||||
width: "100%",
|
||||
height: stageSize.h,
|
||||
touchAction: "none",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,9 +3,20 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as sync from "../syncEngine";
|
||||
import * as searchIndex from "../search";
|
||||
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
||||
import { reindexEntries } from "../features/notes/noteSort";
|
||||
import { placeOnTop, reindexEntries, sortNotes, withoutRow } from "../features/notes/noteSort";
|
||||
import type { Arrangement } from "../features/notes/noteSort";
|
||||
import { extractOnImport } from "../features/pdf/pdfText";
|
||||
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
|
||||
import type {
|
||||
Backlink,
|
||||
Chapter,
|
||||
NoteContent,
|
||||
NoteMeta,
|
||||
Notebook,
|
||||
Separator,
|
||||
SortMode,
|
||||
StrokeDoc,
|
||||
User,
|
||||
} from "../types";
|
||||
|
||||
/** How often the whole account is reconciled with the server. */
|
||||
const FULL_SYNC_MS = 30_000;
|
||||
@@ -36,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;
|
||||
}
|
||||
@@ -67,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[]>([]);
|
||||
|
||||
@@ -74,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([]);
|
||||
}, []);
|
||||
@@ -185,32 +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]);
|
||||
/* Read the document here, now, on the machine that already has it open.
|
||||
*
|
||||
* Waiting until someone opens it means a filed invoice may never be read at
|
||||
* all, and until it is, no device can find it. Not awaited: the note exists
|
||||
* and should appear at once — the text catches up. */
|
||||
void extractOnImport(file, attachment.id);
|
||||
return note;
|
||||
const touch = useCallback((id: string) => {
|
||||
const updatedAt = new Date().toISOString();
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, updatedAt } : n)));
|
||||
}, []);
|
||||
|
||||
const updateNote = useCallback((id: string, edit: NoteEdit) => {
|
||||
@@ -224,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);
|
||||
@@ -266,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);
|
||||
@@ -347,6 +603,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
updateNote,
|
||||
setNoteContent,
|
||||
toggleStar,
|
||||
setNoteIcon,
|
||||
trashNote,
|
||||
restoreNote,
|
||||
permanentDeleteNote,
|
||||
@@ -355,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": "" },
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
+420
-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
|
||||
|
||||
@@ -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}` }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
|
||||
- **Image Attachments** — Pasted images are stored as deduplicated blobs, not inlined into the note
|
||||
- **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes)
|
||||
- **Undo/Redo** — Full history stack for drawing operations
|
||||
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
||||
- **Notebooks & Folders** — Covers in colour or pattern — ruled paper, graph paper, stripes, dots, weave — drawn in CSS, no image files
|
||||
- **Star/Favorite** — Quick access to important notes
|
||||
- **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, pin notes to individual passages, write notes beside it, and export it with or without your marks
|
||||
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
||||
@@ -28,8 +28,9 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
|
||||
- **Conflict-Aware Sync** — A write from a device that missed another's changes keeps the state it replaces as a version, rather than burying it
|
||||
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
|
||||
- **Encrypted at Rest** — AES-256-GCM for note content *and* uploaded files on the server
|
||||
- **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public
|
||||
sign-up: an administrator creates the accounts
|
||||
- **JWT Authentication** — Username + password accounts with bcrypt password hashing, and
|
||||
optionally an identity provider (OpenID Connect). No public sign-up: accounts come from an
|
||||
administrator or from the provider
|
||||
- **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density
|
||||
|
||||
### Architecture
|
||||
@@ -168,6 +169,26 @@ The web UI listens on port `3000`, the API only inside the compose network. If a
|
||||
reverse proxy sits in front, bind the port to localhost (`"127.0.0.1:3000:80"`),
|
||||
otherwise the app is reachable on the host's address without TLS.
|
||||
|
||||
#### The HoneyOS bar
|
||||
|
||||
`HONEYBAR_ORIGIN` puts a strip along the top of every page that leads to
|
||||
the other apps of the same system — it is served by HoneyOS, so what it looks
|
||||
like and which apps it lists are settled there, not here. Empty means no bar at
|
||||
all, and nothing about the app changes.
|
||||
|
||||
The value takes effect on restart: the page asks the server for it
|
||||
(`GET /api/public/config`) and loads the script itself. It is deliberately not
|
||||
built into the image — an image is pulled ready-made, and a service worker keeps
|
||||
whole responses, so anything frozen into `index.html` would outlive the setting.
|
||||
|
||||
`HONEYBAR_KEY` is only needed when HoneyOS sits on a *different site*
|
||||
than InkNote (`honeyos.example.com` vs `notes.example.org`). The browser then
|
||||
withholds the HoneyOS session, and the bar has no other way to ask for the list
|
||||
of apps. Under a shared domain it can stay empty.
|
||||
|
||||
Offline there is no bar — it comes from another server. The app itself is
|
||||
unaffected, which is the right way round for one that is built to work offline.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
@@ -180,9 +201,13 @@ otherwise the app is reachable on the host's address without TLS.
|
||||
| GET | `/api/auth/me` | — | `{id, username, displayName, isAdmin}` |
|
||||
| PUT | `/api/auth/username` | `{newUsername, password}` | updated user |
|
||||
| PUT | `/api/auth/password` | `{currentPassword, newPassword}` | `{success}` |
|
||||
| GET | `/api/auth/methods` | — *(no token needed)* | `{password, oidc:{enabled,label}}` |
|
||||
| GET | `/api/auth/oidc/start` | — | redirect to the provider |
|
||||
| GET | `/api/auth/oidc/callback` | *(the provider's `code` and `state`)* | redirect home with a one-time ticket |
|
||||
| POST | `/api/auth/oidc/claim` | `{ticket}` | `{token, user}` |
|
||||
|
||||
**There is no registration endpoint.** This is a private instance: accounts are created by an
|
||||
administrator, and nothing on the login screen suggests otherwise.
|
||||
administrator or by the identity provider, and nothing on the login screen suggests otherwise.
|
||||
|
||||
Usernames are 3–32 characters of letters, digits, `.`, `_` or `-`, and match case-insensitively.
|
||||
Servers created before this carried e-mail logins; on first start they migrate automatically,
|
||||
@@ -217,6 +242,135 @@ Deleting an account destroys its notes, notebooks, drawings and version history
|
||||
deleted person's session stops working immediately: tokens are signed rather than stored, so
|
||||
`authMiddleware` checks on every request that the account still exists.
|
||||
|
||||
### Signing in through an identity provider
|
||||
|
||||
Optional, and off unless four settings are present. With them, the sign-in screen
|
||||
offers the provider beside the password form; without them nothing about it
|
||||
appears anywhere.
|
||||
|
||||
#### Setting it up, with PocketID
|
||||
|
||||
**1. A client at the provider.** In PocketID that is a new OIDC client; three
|
||||
things about it matter, whatever the fields happen to be called:
|
||||
|
||||
- the **callback URL** is `https://notes.example.com/api/auth/oidc/callback`,
|
||||
with `notes.example.com` replaced by the address you reach InkNote at. The path
|
||||
is fixed and is compared character for character — a mismatch is refused by the
|
||||
provider before InkNote hears about it at all.
|
||||
- it is **not** a public client: InkNote is a server and keeps the secret there.
|
||||
- PKCE may be required; InkNote sends a challenge either way.
|
||||
|
||||
PocketID shows the client id and secret once. Copy both.
|
||||
|
||||
**2. The settings in `.env`** — the first four are what switch it on, the fifth
|
||||
is only the name on the button. Then `docker compose up -d`, which recreates the
|
||||
container with them:
|
||||
|
||||
```bash
|
||||
INKNOTE_PUBLIC_URL=https://notes.example.com # where you reach InkNote
|
||||
INKNOTE_OIDC_ISSUER=https://id.example.com # where PocketID lives, no trailing path
|
||||
INKNOTE_OIDC_CLIENT_ID=<from PocketID>
|
||||
INKNOTE_OIDC_CLIENT_SECRET=<from PocketID>
|
||||
INKNOTE_OIDC_LABEL=PocketID # the button reads "Continue with PocketID"
|
||||
```
|
||||
|
||||
Two more exist and are rarely needed: `INKNOTE_OIDC_SCOPES` (default
|
||||
`openid profile email`) and `INKNOTE_OIDC_REDIRECT_URI`, for the case where the
|
||||
callback is not `<public url>/api/auth/oidc/callback`.
|
||||
|
||||
**3. Read the boot log.** It says which of the two happened, and there is no
|
||||
third:
|
||||
|
||||
```
|
||||
✅ OpenID Connect enabled — https://id.example.com (redirect https://notes.example.com/api/auth/oidc/callback)
|
||||
⚠️ OpenID Connect is half configured and stays off — missing: INKNOTE_OIDC_CLIENT_SECRET
|
||||
```
|
||||
|
||||
Half a configuration is a typo rather than a decision, so it is named rather than
|
||||
quietly ignored. An issuer on plain `http://` is called out too — the client
|
||||
secret and the authorization code travel to it.
|
||||
|
||||
**What goes wrong, and what it looks like**
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| Nothing on the sign-in screen, and the boot log says nothing about OIDC at all | the variables never reached the container. Compose passes only what its `environment:` block names — a `docker-compose.yml` on the server that predates this feature ignores the whole `.env` section. Check with `docker compose exec inknote-server env \| grep INKNOTE_OIDC`; if that is empty, copy the current `docker-compose.yml` over and `docker compose up -d` |
|
||||
| Nothing on the sign-in screen | one of the four is unset — see the boot log |
|
||||
| Still the old sign-in screen after an update | the app is installed as a PWA and its service worker is serving the shell it had. Close every tab (or the installed app) and open it again |
|
||||
| Back at the sign-in screen saying the provider could not be reached | the **server container** cannot resolve or reach the issuer. Your browser reaching it proves nothing: behind a reverse proxy those are different networks. `docker compose exec inknote-server sh -c 'wget -qO- $INKNOTE_OIDC_ISSUER/.well-known/openid-configuration'` |
|
||||
| The provider refuses before InkNote is involved | the callback URL there is not exactly `<public url>/api/auth/oidc/callback` |
|
||||
| "That sign-in took too long" | more than ten minutes between pressing the button and finishing, or the server restarted in between — pending sign-ins are held in memory on purpose |
|
||||
|
||||
#### What it actually does
|
||||
|
||||
**It is a second way to the same token.** The password login mints a signed JWT,
|
||||
and every route — the WebSocket included — knows only that; the provider route
|
||||
ends at the same `jwt.sign`. Nothing downstream had to learn what OIDC is, which
|
||||
is why the whole thing is one file ([`backend/oidc.js`](backend/oidc.js)) with no
|
||||
new dependency: discovery is one document, the exchange is one form post, and
|
||||
checking an ID token is a signature and five claims — Node imports a JWK
|
||||
directly.
|
||||
|
||||
**Authorization Code with PKCE, exchanged on the server.** The client secret
|
||||
never reaches the browser, and neither does the session token: the callback
|
||||
sends the browser home with a **one-time ticket**, good for a minute and consumed
|
||||
on first use, which the app swaps for its token. A token in a redirect URL would
|
||||
land in the history, in the proxy's log and in the referrer of the next request.
|
||||
|
||||
**Accounts.** The provider's `sub` is stored on the account and is the link from
|
||||
then on, so either side may be renamed without breaking it. On a first sign-in
|
||||
the subject is bound to an account of the same name if there is one, and
|
||||
otherwise **a new account is made**. Never an administrator: those rights are
|
||||
granted in this app, and a group at the provider must not be a way around that.
|
||||
|
||||
That first binding trusts the provider's usernames, which is worth being explicit
|
||||
about: whoever can set `preferred_username` over there decides which account here
|
||||
they land in the first time. Where the names are set by the provider's own
|
||||
administrator — PocketID, Authentik, Keycloak as they are normally run — that is
|
||||
exactly the intent. Do not point this at one where people choose their own name
|
||||
freely.
|
||||
|
||||
**Both doors stay open, and passwords become an administrator's business.** An
|
||||
account created by the provider has no password here at all: the login route
|
||||
refuses one exactly as it refuses a wrong password, at the same speed. An
|
||||
administrator can still *set* one — deliberately, since that is the way back in
|
||||
on the day the provider is unreachable — and the button in the list says "Give a
|
||||
password" rather than offering to reset something that does not exist.
|
||||
|
||||
An account that signs in through the provider is offered no password tab of its
|
||||
own, and that includes one which had a password before it was linked. The
|
||||
password it kept is the emergency door, not a second thing for its owner to
|
||||
maintain; the account panel says so, and names the provider it signs in through.
|
||||
The administration list marks every linked account, and says which of them have
|
||||
no password here.
|
||||
|
||||
**The picture comes along.** The server looks for a `picture` claim in the ID
|
||||
token and, when it is not there, asks the provider's `userinfo` endpoint — a
|
||||
provider is free to keep the token lean, and several do, PocketID among them.
|
||||
The answer is used only if its `sub` matches the token whose signature was
|
||||
checked. The picture is then fetched at each sign-in and stored, encrypted, in a
|
||||
table of its own; if it lives at the provider, the access token goes with the
|
||||
request, since avatars are often behind the provider's own login. That token is
|
||||
never sent anywhere but to the provider itself.
|
||||
|
||||
Whatever happens, the server log says so in one line — stored, or exactly why
|
||||
not: no claim, refused with a 401, not an image, too large, unreachable.
|
||||
|
||||
It is served back through the API, so nothing on the page talks to the provider, no third-party cookie is needed for it to appear, and it keeps
|
||||
working when the provider does not. The bytes are fetched once per account and
|
||||
turned into an object URL, since an `<img src>` cannot carry a bearer token; the
|
||||
picture then shows in the sidebar, in the account panel and beside the name in
|
||||
the administration list. It arrives with the *next* sign-in: that is the one
|
||||
moment this app talks to the provider at all.
|
||||
|
||||
**The refresh token is deliberately unused.** The provider is asked once, at
|
||||
sign-in; InkNote's own token carries the session from then on, and nothing here
|
||||
needs the provider to be reachable afterwards. That is what keeps the app working
|
||||
offline, which is most of the point of it.
|
||||
|
||||
On a phone the redirect opens the system browser and returns to the installed
|
||||
app, so a passkey at the provider works as it does anywhere else.
|
||||
|
||||
### Notebooks
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
@@ -253,6 +407,7 @@ Connect to `ws://localhost:4000/ws`, then send:
|
||||
|
||||
Receive real-time events: `note_created`, `note_updated`, `note_deleted`, `notebook_created`, etc.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Sync Engine (Client)
|
||||
@@ -314,6 +469,134 @@ bug they cover actually happened:
|
||||
- `hasPendingWrites` must cover the debounce window, not just requests in flight
|
||||
- v1 note bodies must migrate without losing task state or smuggling in markup
|
||||
|
||||
### Reordering, and menus, with a finger
|
||||
|
||||
Both were built for a mouse and quietly did nothing on a phone.
|
||||
|
||||
**Dragging** went through the HTML5 drag-and-drop API, which mobile browsers
|
||||
never fire from touch — so the arrangement could not be changed at all, with
|
||||
nothing on screen to say why. [`useReorderDrag`](frontend/src/hooks/useReorderDrag.ts)
|
||||
uses pointer events instead, which mouse, pen and finger all produce. The two
|
||||
devices get different starts, because they want different things:
|
||||
|
||||
- **A mouse** begins a drag by pressing anywhere on a row and moving a few pixels.
|
||||
- **A finger** begins one only from the grip. Pressing the row has to go on
|
||||
scrolling the list, and holding it has to go on opening the menu; a drag that
|
||||
started from anywhere would take both.
|
||||
|
||||
The row under the finger is found with `elementFromPoint`, not with enter/leave
|
||||
handlers — a finger generates no `pointerover` on anything it did not press.
|
||||
|
||||
**Two lines in the same gap keep the order they were written in.** A separator's
|
||||
position counts the rows above it, so two lines with nothing between them carry
|
||||
the same number, and only the order they are stored in can tell them apart. That
|
||||
took two fixes for one symptom. The comparator used to say "a separator comes
|
||||
first" without asking what the other one was — claiming both directions at once
|
||||
for that case, and a comparator that contradicts itself has no defined result.
|
||||
And the write was then thrown away: the "did anything change?" test **sorted**
|
||||
the separators before comparing them, to ignore the other chapters' lines riding
|
||||
along in the same field — and a sorted comparison cannot see a sequence. The move
|
||||
was computed correctly, drawn for a frame, and discarded. It compares this list's
|
||||
own lines, in order.
|
||||
|
||||
**A drop lands the row under the one it was released on, and only ever there.**
|
||||
Dropping above a row and dropping below the row before it are the same move
|
||||
described twice, so every gap could be aimed at from two sides — which made a
|
||||
drag hard to aim and harder to read. One direction, one line, drawn under the
|
||||
row it will land under. The two ends of the list have strips of their own: the
|
||||
one below the last row was always there, and there is now one above the first,
|
||||
because that is the single place a drop *under* something cannot reach.
|
||||
|
||||
**Rearranging is a mode**, switched on by the handle button beside the sort menu
|
||||
and off again by itself when the list is left or the order stops being a
|
||||
hand-made one. Only while it is on does a row wear its grip or move at all. Rows
|
||||
are read constantly and dragged rarely, so six dots beside every title was
|
||||
clutter that also made an ordinary press able to carry a row off. Filing a note
|
||||
somewhere else is not part of it: that is one of the things done to a row from
|
||||
the row's own menu, like starring, exporting and discarding.
|
||||
|
||||
**The context menu** was placed with `innerWidth - 220`, a stand-in for a width
|
||||
nobody had measured, and its submenu was pinned to `left: 100%` and opened only
|
||||
on hover. On a phone the parent already sits against the right edge, so the list
|
||||
of notebooks under *Move to…* appeared past the edge of the display — and with no
|
||||
hover, there was no way to open it in the first place. It is now placed by
|
||||
measuring, opens on a tap, unfolds in place on a narrow screen, and flips to the
|
||||
other side on a wide one when the right would not fit.
|
||||
|
||||
*Move to…* holds two different kinds of place, so it **says which is which**:
|
||||
the notebook itself, then *Chapters here*, then *Other notebooks*. The headings
|
||||
are deliberately not shaped like entries — small, faint and set apart — because
|
||||
a heading built like a menu row is a button that does nothing when pressed. Each
|
||||
row carries the mark its chapter was given or the cover its notebook wears, so a
|
||||
place is recognised in the menu the same way it is recognised in the list.
|
||||
|
||||
### Installing it
|
||||
|
||||
The install entry used to appear only when the browser had offered a prompt
|
||||
(`beforeinstallprompt`). Where installing was impossible there was nothing on
|
||||
screen at all — no button, no reason, and no way to find out short of knowing how
|
||||
PWAs work.
|
||||
|
||||
**The prompt itself was being missed.** `beforeinstallprompt` fires once, early
|
||||
— usually before React has mounted — and is never fired again. The listener lived
|
||||
in a component, so on a correctly served site the event arrived, found nobody
|
||||
listening, and was gone for the session. It is caught at the top of `index.html`
|
||||
now and handed over when the app is ready.
|
||||
|
||||
**And the manifest was being fetched without credentials.** A
|
||||
`<link rel="manifest">` is fetched with credentials omitted unless it says
|
||||
otherwise, so behind a reverse proxy that authenticates in front of the app that
|
||||
one request comes back as the proxy's login page. The browser reads it as an
|
||||
invalid manifest and offers no install — while the app itself works perfectly,
|
||||
because its own requests carry the cookie. The link now carries
|
||||
`crossorigin="use-credentials"`, which is harmless without a proxy.
|
||||
|
||||
The service worker had the matching problem: it cached whatever came back. A
|
||||
lapsed session could be stored as the application itself, and the app would then
|
||||
"work offline" by showing a login screen it could never get past — curable only
|
||||
by clearing the site's data. It now caches only responses that are successful and
|
||||
not redirected, and `Cache.put` is guarded, since it rejects on a redirect and
|
||||
that rejection was going unhandled on every navigation.
|
||||
|
||||
Behind such a proxy, three paths have to reach InkNote intact: `/manifest.json`
|
||||
and `/sw.js` must return the file rather than a login page — a **redirect** on
|
||||
`sw.js` fails registration outright, taking offline with it — and `/ws` needs its
|
||||
WebSocket upgrade passed through, or changes from another device only arrive with
|
||||
the 30-second reconciliation.
|
||||
|
||||
The entry is always there, and when there is no prompt it says which of the
|
||||
fixable causes applies. The common one is worth naming: **a browser will only install a
|
||||
site served over HTTPS**, and on plain HTTP the service worker never registers
|
||||
either — so one cause takes the install *and* offline with it, which otherwise
|
||||
looks like two unrelated mysteries.
|
||||
|
||||
### Notebook covers
|
||||
|
||||
**A cover is a colour and a pattern, chosen in turn** — `COVERS` and `PATTERNS`
|
||||
in [`ui/constants.jsx`](frontend/src/ui/constants.jsx), stored as
|
||||
`notebooks.cover_index` and `notebooks.cover_pattern`.
|
||||
|
||||
Two decisions rather than one list. Offering every combination as its own entry
|
||||
would mean fifteen colours times a dozen patterns in a grid nobody could take in
|
||||
— and in practice only the handful someone had thought to write out. Picking each
|
||||
in turn is shorter *and* gives every combination. Each pattern is previewed over
|
||||
the colour currently chosen, so what is on screen is the cover as it will be.
|
||||
|
||||
The patterns are CSS gradient layers laid over the colour — no image files,
|
||||
nothing to upload, nothing to fetch, crisp at any size, 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.
|
||||
|
||||
`COVERS` is an index, so entries are only ever appended: renumbering them would
|
||||
silently repaint every notebook anyone already has. For one day the patterns
|
||||
*were* twelve appended entries, before they became their own axis; `coverParts()`
|
||||
reads those indices back as the colour-and-pattern pair they were drawn as, so
|
||||
nothing that was chosen then looks different now.
|
||||
|
||||
Both grids scroll. Without that the dialog runs past the bottom of a laptop
|
||||
screen and takes the *Create* button with it, which a test checks at 1440×800.
|
||||
|
||||
### Browser suites
|
||||
|
||||
The unit tests cover the data layer; these drive a real browser against a real
|
||||
@@ -330,16 +613,20 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| Suite | Checks | What it holds down |
|
||||
| --- | --- | --- |
|
||||
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
|
||||
| `editor` | 31 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
|
||||
| `shell` | 9 | the frame around the writing: what the dashboard leads with, the sidebar answering a drag from the edge, a tap on blank paper putting the caret there, and the page having a visible edge on a phone |
|
||||
| `oidc` | 40 | the whole redirect journey against a provider that signs real tokens — and the four ways it must fail: a refusal, a replayed ticket, a token signed with an unpublished key, a token addressed to another client |
|
||||
| `editor` | 52 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
|
||||
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
||||
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
||||
| `drawing` | 18 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
|
||||
| `pdf` | 91 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `drawing` | 22 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
|
||||
| `pdf` | 101 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
|
||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
| `organise` | 39 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
|
||||
| `offline` | 12 | a cold page load with the network off still opens notes and searches them |
|
||||
| `export` | 23 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
| `organise` | 75 | a hand-made order survives a search; a new note lands on top and a deleted one takes only itself; one line goes under another and stays there |
|
||||
| `chapters` | 65 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog |
|
||||
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
|
||||
| `tables` | 11 | a long table is broken between its rows, with none left lying across the gap, no page furniture reaches the stored note, and the column edge being dragged is drawn on the cell rather than in it |
|
||||
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
|
||||
|
||||
Each suite creates a throwaway account through the admin API and deletes it
|
||||
@@ -373,9 +660,12 @@ state of their own, so they can be read on their own.
|
||||
|
||||
## Setting a note
|
||||
|
||||
**A centred column, about 73 characters to the line** — measured, not guessed.
|
||||
**A centred column, about 80 characters to the line** — measured, not guessed.
|
||||
Past roughly 84 the eye stops reliably finding the start of the next line, so the
|
||||
column is capped well before the pane runs out.
|
||||
column is capped a little short of that. It leaves roughly 8% of the sheet as
|
||||
margin down either side: a document's margin, not a picture frame. At 73
|
||||
characters it left 117px of empty paper down each edge and the writing looked
|
||||
lost on the page.
|
||||
|
||||
It was pinned to the left margin for a while, on the reasoning that a column
|
||||
which floats in the middle moves as the window is resized. True, but it traded
|
||||
@@ -385,19 +675,148 @@ block a meaning, which only moved the problem — the whole sheet sat hard again
|
||||
the left edge. Centred, the same leftover space reads as two margins and needs no
|
||||
explaining.
|
||||
|
||||
**Twenty-one lines to the page, and the same twenty-one on every screen.** The
|
||||
text is laid out on a page of one fixed size — 760 × 1013 units, the drawing's
|
||||
own proportions — and the whole sheet is then scaled to whatever width the screen
|
||||
has. Set in screen pixels instead, as it was, a phone and a desktop disagreed
|
||||
about everything that matters: a 340-pixel sheet held eighteen lines where a
|
||||
760-pixel one held far more, so the same note broke into different lines on
|
||||
different pages depending on what you read it on. Now the only thing a narrow
|
||||
screen changes is how big the page looks. The type size is not chosen but
|
||||
computed from the page and the line count, so the count survives a change to
|
||||
either. A desktop therefore shows larger writing on a smaller-looking sheet, the
|
||||
way a ruled notebook app does.
|
||||
|
||||
**The dashboard leads with what you were last writing.** The six most recently
|
||||
edited notes come first and the shelf of notebooks second, because coming back to
|
||||
a note is the common case and choosing a notebook is the occasional one. "Recent"
|
||||
is sorted by time here whatever order the notebooks themselves are in — it is a
|
||||
question about time, and a hand-made arrangement is the wrong answer to it. The
|
||||
time it sorts by is stamped on every write, not only on a rename: the store
|
||||
behind the sync engine always did that, but the lists on screen read the app's
|
||||
own copy of the notes, and writing *in* a note used to leave that copy untouched
|
||||
— so "Recent" showed the order from the last reload.
|
||||
|
||||
**Tapping the blank part of a page starts writing there.** The text is only as
|
||||
tall as what has been written, so most of a fresh page is *beside* the editable
|
||||
area rather than in it; a tap down there used to land on nothing at all — no
|
||||
caret, and on a phone no keyboard. The caret goes to the position nearest the
|
||||
tap, not simply to the end, because on a page with several paragraphs "nearest"
|
||||
is what the tap meant.
|
||||
|
||||
**Dragging in from the left edge shows the sidebar** on a phone or tablet, and
|
||||
dragging it back hides it. The button that opens it sits in the top corner, which
|
||||
is the one part of a tall screen a thumb cannot reach. The gesture has to start
|
||||
within 28px of the edge, travel 60px, and be more sideways than downwards —
|
||||
without the first rule every sideways drag inside a note opens it, and without
|
||||
the third, scrolling with a thumb near the edge does.
|
||||
|
||||
**The typeface is a per-account setting** (Account → Profile): serif, sans or
|
||||
mono. On the account rather than in the browser, because a choice about how your
|
||||
own notes are set should follow you to the phone rather than be made again on
|
||||
every device. The server accepts those three values and drops anything else —
|
||||
mono. How a chapter opens is chosen there too, and light or dark, for the same
|
||||
reason — the sun used to sit in the corner of the sidebar, where it was reached
|
||||
for by accident, forgotten by the next device and lost on every reload. The
|
||||
browser keeps its own copy of the theme so a reload does not flash the other one
|
||||
while the account is being fetched; the account is what decides. On the account
|
||||
rather than in the browser, because a choice about how your own notes are set
|
||||
should follow you to the phone rather than be made again on every device. The
|
||||
server accepts only the values it knows for each of them and drops anything else:
|
||||
that column is written by a browser, so it is an input like any other.
|
||||
|
||||
**You write on paper.** Each page is a sheet of its own, centred, with a gap
|
||||
after it — the same way the PDF reader draws its pages, because a page in a note
|
||||
and a page in a document should read as the same kind of thing. It also answers
|
||||
what a bare column of text could not: the space either side of the writing is the
|
||||
sheet's margin, rather than an unexplained void.
|
||||
|
||||
The sheet used to appear only once you started drawing, which made the writing
|
||||
mode look like text floating in an empty pane.
|
||||
|
||||
**The sheet is measured before the first paint**, not after. It starts at the
|
||||
canonical page the drawing lives in — 1200 × 1600 — and is fitted to the pane by
|
||||
a measurement; as an ordinary effect that ran a frame late, so every note opened
|
||||
by drawing one frame of a sheet sixteen hundred pixels tall and then righting
|
||||
itself. A `useLayoutEffect` runs between the DOM being built and the browser
|
||||
painting it, which is exactly the gap that flash lived in. A test samples the
|
||||
sheet's height every frame across a note switch, because one frame is all it was.
|
||||
|
||||
**The page has an edge on a phone too.** The paper there runs to the edges of the
|
||||
screen — a phone has no room for margins around a margin — but it used to be
|
||||
drawn with no border and no shadow at all, which left nothing to see where one
|
||||
page ended and the next began. It has an outline now; the shadow stays a desktop
|
||||
thing.
|
||||
|
||||
**A quote holds whole blocks.** A heading, a list and a closing line inside one
|
||||
are ordinary blocks of the document, and they are spaced like ordinary blocks:
|
||||
the rule that gives the writing its rhythm reaches only the editor's own
|
||||
children, so inside a quote everything stood jammed against its neighbour — with
|
||||
the items of a list further apart than the blocks around them, which read as the
|
||||
quote being broken. The rule for a quote's children is written *after* the ones
|
||||
for paragraphs and headings on purpose: they are exactly as specific, so earlier
|
||||
it would lose. A paragraph inside a list item is more specific still and keeps
|
||||
its zero.
|
||||
|
||||
**Pages are counted, not stored.** The toolbar says *Page 2 of 4* in the same
|
||||
words the PDF reader uses, and a dashed rule marks where each one ends. A page is
|
||||
the same 3:4 the canvas uses, so "page 2" means the same thing writing or
|
||||
drawing. The note itself stays one continuous document — Markdown has no page,
|
||||
and a test asserts none ever reaches the stored body. The current page is the one
|
||||
under the *middle* of the window: measuring from the top said "page 1" while you
|
||||
stood at the foot of a two-page note, because a window is shorter than a page.
|
||||
words the PDF reader uses. A page is the same 3:4 the canvas uses, so "page 2"
|
||||
means the same thing writing or drawing. The note itself stays one continuous
|
||||
document — Markdown has no page, and a test asserts none ever reaches the stored
|
||||
body. The current page is the one under the *middle* of the window: measuring
|
||||
from the top said "page 1" while you stood at the foot of a two-page note,
|
||||
because a window is shorter than a page.
|
||||
|
||||
### Holding the gap open
|
||||
|
||||
The browser knows nothing about the sheets. Left alone it lays the text out as
|
||||
one continuous flow and puts a line straight across the gap, cut in half by it.
|
||||
|
||||
So [`editor/pagination.js`](frontend/src/editor/pagination.js) measures the flow
|
||||
and pushes any block that would straddle a gap onto the next page, by inserting a
|
||||
spacer of exactly the right height in front of it. The spacers are **decorations,
|
||||
not content**: they never enter the document, never reach the server and never
|
||||
appear in an export. Two tests hold that down — one asks whether any block sits
|
||||
in a gap, the other reads the stored body back and looks for them.
|
||||
|
||||
The unit is a whole block. A paragraph that would be split moves down entire,
|
||||
which is what a page break means on paper.
|
||||
|
||||
**A table is the exception, and has to be.** Moved whole it either jumps to the
|
||||
next sheet leaving the rest of this one blank, or — being longer than a sheet —
|
||||
has nowhere to jump to and lies straight across the gap with a row cut in half.
|
||||
So a table is broken **between its rows**, which is where a table breaks on paper
|
||||
too. The spacer there is a row of its own: a `div` inside a `tbody` is not a
|
||||
thing the browser will keep, while a `tr` with one borderless cell pushes the
|
||||
rows after it down exactly as far as needed. Its own spacer rows are subtracted
|
||||
again on the next measurement, and counted into the shift every block below it
|
||||
inherits — miss that and everything under a broken table is measured a page too
|
||||
high.
|
||||
|
||||
Anything else too tall to fit anywhere — a full-page image — is left where it is
|
||||
and runs over the gap: moving it down only puts the same problem one sheet
|
||||
further on.
|
||||
|
||||
**The column edge you drag is drawn on the cell, not in it.** The editor marks
|
||||
the column under the pointer by putting a small div in each of its cells, and the
|
||||
obvious thing to do is position that div along the cell's right edge. It works,
|
||||
and it paints grey letters down the bar: an absolutely positioned child inside a
|
||||
cell of a `border-collapse: collapse` table sends Chromium down another painting
|
||||
path, and the cell's own text comes out a second time, squeezed into the two
|
||||
pixels under the bar, one letter to a line. It appeared with the bar and went
|
||||
with it — which named the culprit, since the DOM at that spot held nothing but
|
||||
three empty markers. The marker is `display: none` now and only says which cells
|
||||
the column is made of; the line is an inset shadow on those cells, matched with
|
||||
`:has()`. Nothing is positioned, so nothing paints twice, and the line looks the
|
||||
same — continuous down the column, because each cell carries its own piece.
|
||||
|
||||
Two details that are easy to get wrong and were:
|
||||
|
||||
- **The spacer's height is not the distance the text moves.** Margins that used
|
||||
to collapse into each other no longer do once something sits between them, so
|
||||
the layout gains `min(marginBottom, marginTop)` for free and the spacer is made
|
||||
exactly that much shorter. The spacer carries the intended shift in a data
|
||||
attribute, because the next measurement has to subtract it again to recover
|
||||
where the text would sit with no spacers at all.
|
||||
- **Heights change without a transaction to announce it** — a webfont finishing
|
||||
loading, an image arriving, the window resizing. A `ResizeObserver` on the
|
||||
editor element catches all three.
|
||||
|
||||
Each page can be drawn on — see [One drawing, as many pages as the
|
||||
note](#one-drawing-as-many-pages-as-the-note).
|
||||
@@ -448,6 +867,25 @@ to survive `document.execCommand`. Notes written before this were
|
||||
opened and rewritten in the new format on the next save, so nothing needs
|
||||
migrating up front.
|
||||
|
||||
### Colour, of two kinds
|
||||
|
||||
A marker pen and a colour for the letters, both written as marks in
|
||||
[`editor/textMarks.js`](frontend/src/editor/textMarks.js) rather than installed —
|
||||
the schema of a note decides what can be stored in it forever, and that belongs
|
||||
in files this project owns, the same reason the alignment extension is here.
|
||||
|
||||
A highlight is a `<mark>`: the element that *means* highlighted, so a reader that
|
||||
drops the colour still shows the word as marked. Marked text is painted with dark
|
||||
ink whatever the theme, the way a real marker leaves the writing alone — a
|
||||
light-grey word on pale yellow would be unreadable on the dark one. An explicit
|
||||
text colour is an inline style and still wins.
|
||||
|
||||
Both travel as HTML in Markdown, like underline, since Markdown has no colour,
|
||||
and both are read back — including a bare `<mark>` with no colour, which is what
|
||||
most other writers emit. Neither keeps its palette in the schema: what the
|
||||
toolbar offers is an interface question, while the document only has to hold what
|
||||
was chosen, including a colour picked before the palette last changed.
|
||||
|
||||
### Note links and backlinks
|
||||
|
||||
Typing `@` opens a search over notes and notebooks and inserts a `noteLink`
|
||||
@@ -473,6 +911,11 @@ fresh device with nothing to search. After login a background pass backfills the
|
||||
missing ones into IndexedDB, slowly enough to stay out of the way, indexing each
|
||||
as it lands. Searching therefore also works offline.
|
||||
|
||||
The box sits above the note list, always, with nothing to open first. It was
|
||||
behind a magnifying glass, which cost a click and having to remember that
|
||||
searching was possible at all; a search box is one line and says for itself what
|
||||
it does.
|
||||
|
||||
PDFs come after the notes in that pass. Their text is not derived from a note
|
||||
body but stored with the file, by the device that imported it — see [The reading
|
||||
happens on a device, never on the
|
||||
@@ -499,6 +942,30 @@ page is expensive to draw and cheap to hold, so scrolling back up repaints
|
||||
nothing. Canvases are sized in device pixels and scaled down by CSS, which is
|
||||
what keeps text sharp on a high-density screen.
|
||||
|
||||
**Full screen means the whole window, not the whole screen.** The workspace is
|
||||
laid out as an overlay over the app: the document takes the browser window, and
|
||||
the sidebar, the note list and the title bar go. The browser's own full screen is
|
||||
deliberately not asked for — it swallows the tabs and the address bar too, which
|
||||
is more than was wanted; this is about giving the page to the document, not the
|
||||
screen. Escape leaves, and so does the button, which sits beside the zoom while
|
||||
full: both answer "how much room does the document get", and a way out that is
|
||||
only known and not visible is not a way out.
|
||||
|
||||
**Zooming holds something still.** A zoom changes how tall the document is, so
|
||||
the scroll position means something different afterwards — left alone, every step
|
||||
drifts and three presses of + leave you somewhere else in the file. What should
|
||||
stay put depends on how the zoom was asked for, and the two answers are
|
||||
different: from the buttons it is **the page you are reading**, because the
|
||||
request is "show me this page larger"; from **Ctrl + the wheel** it is the point
|
||||
under the pointer, because you are pointing at a word and asking for that to
|
||||
grow. The wheel listener is registered by hand rather than through `onWheel`:
|
||||
React attaches wheel listeners passively, a passive listener may not call
|
||||
`preventDefault`, and without that call the *browser* zooms the whole application
|
||||
— toolbar, sidebar and all. A trackpad pinch arrives as the same event and needs
|
||||
nothing further. The suite checks all three: the step, that the default really
|
||||
was prevented, and that a point picked on the page is still under the pointer
|
||||
afterwards.
|
||||
|
||||
**pdf.js is not in the main bundle.** It is larger than the rest of the
|
||||
application put together, so it sits behind a dynamic import: a session that
|
||||
never opens a PDF never downloads it. The worker is bundled too, not fetched
|
||||
@@ -741,9 +1208,28 @@ on every keystroke. They are now separate blobs; the body only keeps an id.
|
||||
| POST | `/api/attachments` | Upload a base64 data URL |
|
||||
| GET | `/api/attachments/:id` | Fetch the bytes (owner only) |
|
||||
|
||||
> Removing an image from a note does not yet delete its blob: the body is
|
||||
> encrypted, so the server cannot tell what is still referenced. Unused blobs
|
||||
> cost disk space, nothing else.
|
||||
### Where it all actually sits
|
||||
|
||||
Two directories, both bind-mounted from the host so that whatever backs up the
|
||||
host backs up InkNote:
|
||||
|
||||
```
|
||||
/srv/inknote/db/inknote.db every note, encrypted body and all
|
||||
/srv/inknote/uploads/attachments/<sha> one file per distinct upload, encrypted
|
||||
/srv/inknote/uploads/drawing_<id>.png legacy raster drawings
|
||||
```
|
||||
|
||||
**There is no folder per user, and there is deliberately nothing on disk to read.**
|
||||
Who owns what is a column in the database (`attachments.user_id`), not a path.
|
||||
The file name is the SHA-256 of the *plaintext* — encryption uses a fresh IV each
|
||||
time, so hashing the ciphertext would store the same photo twice — and the bytes
|
||||
beside it are AES-256-GCM. So a file name tells you that two people uploaded the
|
||||
same document, and nothing else: not who, not what, not which note.
|
||||
|
||||
Sorting uploads into per-user folders would name the owner in the clear on a disk
|
||||
where nothing else is readable, and it would break the sharing that keeps one copy
|
||||
of a file two accounts happen to have. Deleting stays safe without it: a blob goes
|
||||
only when **no** row in `attachments` points at it any more, whoever owned them.
|
||||
|
||||
---
|
||||
|
||||
@@ -801,6 +1287,99 @@ rows behind.
|
||||
|
||||
---
|
||||
|
||||
## Chapters
|
||||
|
||||
Folders inside a notebook. A note is in exactly one chapter, or in none — which
|
||||
means the notebook itself. Chapters are listed above the loose notes; tapping one
|
||||
opens its own list, and the menu button becomes a way back out, which on a phone
|
||||
is the only way, since the list fills the screen.
|
||||
|
||||
**A chapter is not a wastebasket.** Removing one never removes what it held: the
|
||||
notes come back to the notebook, where they can be seen and filed again. That is
|
||||
`ON DELETE SET NULL` on `notes.chapter_id`, and it is also written out explicitly
|
||||
in the delete route — it is the promise the whole feature rests on, and it should
|
||||
be visible where it happens rather than inferred from a schema. The dialog says
|
||||
so too, with the number of notes affected.
|
||||
|
||||
Three rules that keep the two levels honest:
|
||||
|
||||
- **A note filed into a chapter leaves the notebook's own list.** Showing it in
|
||||
both would mean filing it appeared to do nothing.
|
||||
- **A chapter belongs to its notebook.** Filing a note into a chapter of a book
|
||||
it is not in is refused by the server; moving a note to another notebook clears
|
||||
its chapter, since the old one is in the book it just left.
|
||||
- **Separators belong to the list they were drawn in.** A separator's `order`
|
||||
counts the notes before it, which only means anything inside one list, so each
|
||||
carries the chapter it belongs to — `null` for the notebook itself. The
|
||||
notebook still stores one list of them, and a change made in one chapter writes
|
||||
the others back untouched.
|
||||
|
||||
A note made while a chapter is open is filed there without being moved. A chapter
|
||||
is **named, marked and coloured in one dialog** — the same dialog whether it is
|
||||
being made or changed, with the row drawn at the top as the list will draw it.
|
||||
Asked one at a time from three menu entries, getting a new chapter to look right
|
||||
took three visits. Its menu therefore holds one *Edit* and one *Remove*.
|
||||
|
||||
**A row is put into a chapter by dragging it onto it.** While rearranging is on,
|
||||
a chapter row takes what is dropped on it and lights up to say so, rather than
|
||||
growing a drop line: a chapter is a place, not a neighbour. Notes and separators
|
||||
both go in this way.
|
||||
|
||||
But it is a neighbour as well, and taking the *whole* row as "into" quietly took
|
||||
that away — a chapter could no longer be moved among its neighbours, and nothing
|
||||
could be put in the gap under one. So the **bottom edge of a chapter's header is
|
||||
the gap** and the rest of it is the chapter. The header, not the row: an open
|
||||
chapter carries its notes inside that row, and holding over those plainly means
|
||||
the chapter.
|
||||
|
||||
A chapter shows even when the notebook holds nothing else. "Nothing here" is
|
||||
asked of every row, not of the notes alone — asked of the notes, a notebook whose
|
||||
only contents were chapters showed the empty-list panel with the chapters hidden
|
||||
behind it, and making a chapter in a fresh notebook appeared to do nothing.
|
||||
|
||||
### How a chapter opens
|
||||
|
||||
Two ways, chosen per account under Account → Profile, beside the typeface and for
|
||||
the same reason: it is a choice about how your own notes are laid out, and it
|
||||
should follow you to the phone.
|
||||
|
||||
- **Beside the list** — its notes in a further column, the way OneNote lays out
|
||||
its pages. The notebook's own list carries on to the left, chapter rows and
|
||||
all.
|
||||
- **Under the chapter** — its notes unfold in place, indented against a rule in
|
||||
the chapter's colour.
|
||||
|
||||
A phone has room for neither. There the chapter fills the list and the header
|
||||
button becomes a way back out, which is what it did before there was a choice.
|
||||
|
||||
**Which list is on screen is not the same as which chapter is open.** Beside and
|
||||
below, the middle column still shows the notebook; only the phone's fallback
|
||||
replaces it. The two were one value for a while, and a separator drawn in a
|
||||
notebook vanished the moment a chapter was opened — the list still showed the
|
||||
notebook, but everything around it had switched to the chapter, so the notebook's
|
||||
own separators were filtered out and a drag in that list wrote nothing at all.
|
||||
|
||||
**Which of the two you are in is a separate question again**, because both are on
|
||||
screen at once. Opening a chapter puts you in it; pressing in the notebook's own
|
||||
list puts you back in the notebook without closing the chapter. Whichever it is
|
||||
wears a line along its top, and the + button names the place it would make the
|
||||
note in. Before, a new note went into the chapter however far away you had since
|
||||
clicked, and nothing on screen said so.
|
||||
|
||||
### A chapter is a row like any other
|
||||
|
||||
Chapters sit at the same level as notes and separators: same grip, same drag,
|
||||
same place in a hand-made arrangement. That takes **one counter for both** in
|
||||
`toArrangement` — numbering notes and chapters separately would leave two
|
||||
sequences that cannot be interleaved again, and a chapter dragged between two
|
||||
notes would spring back to the top.
|
||||
|
||||
Positions therefore have to be *named* rather than taken from an array index,
|
||||
which is why `POST /notes/reorder` accepts `positions: [{id, order}]` beside the
|
||||
older `ids`, and why chapters have a reorder route of their own.
|
||||
|
||||
---
|
||||
|
||||
## Sorting a notebook
|
||||
|
||||
Each notebook remembers how its notes are ordered, and remembers it on the
|
||||
@@ -809,10 +1388,60 @@ finding one sorted differently on the phone than on the desktop is its own small
|
||||
annoyance. A reference book can stay alphabetical while a journal stays
|
||||
newest-first.
|
||||
|
||||
**A notebook starts in the hand-made order.** That is where a notebook's own
|
||||
arrangement lives: separators show, rows wear their handles once rearranging is
|
||||
switched on, and nothing slides about by itself. A notebook nobody has arranged
|
||||
still reads newest-first — untouched rows all carry position 0 and fall back to
|
||||
it — so nothing is lost by starting there, while starting in "last changed" made
|
||||
both separators and dragging look broken until the sort menu was found.
|
||||
|
||||
**A chapter orders its own notes.** The notebook's setting governs the
|
||||
notebook's own list; a chapter is a place someone made and filled, so it carries
|
||||
a mode of its own — set in the same dialog as its name, mark and colour, because
|
||||
they are one decision about one thing. It starts in the hand-made order, which is
|
||||
what filing a note into a chapter means: it stays where it was put. A chapter in
|
||||
one of the other orders loses its lines and its handles, exactly as the notebook
|
||||
does, because neither means anything against an order the app is choosing.
|
||||
|
||||
While it *is* in the hand-made order, its rows are dragged with the **same
|
||||
switch** as the list beside it — two switches for two lists looked at together is
|
||||
one too many. The two lists keep out of each other's way by scope: a chapter
|
||||
unfolded under its row holds rows *inside* a row of the notebook's list, so "the
|
||||
nearest row above the pointer" cannot say which list a drag belongs to. The two
|
||||
strips at the ends of a chapter's list appear only while something is being
|
||||
carried; standing open for the whole of the mode they added half a finger of
|
||||
empty space to every open chapter.
|
||||
|
||||
**A row leaving is the same problem as a row arriving, in reverse.** Deleting a
|
||||
note above a line left the line counting rows that were no longer there, so
|
||||
everything under it appeared to jump up past it — which is what made deleting
|
||||
feel as though it scrambled the notebook. `withoutRow` renumbers the list the row
|
||||
is leaving, and every place a row leaves one says so: the bin, a move to another
|
||||
notebook or chapter, and a chapter being removed. Restoring from the bin and
|
||||
moving into a list are arrivals, and arrive at the top like anything else — an
|
||||
old position belongs to an arrangement that has moved on. Removing a chapter is
|
||||
both at once: its row leaves the count, and the notes it held arrive at the top
|
||||
in the order they had inside it.
|
||||
|
||||
**A new note is put at the very top, and the list is renumbered to say so.**
|
||||
Left to the tie-break it landed near the top by age, which is not the same thing:
|
||||
a separator's position counts the rows above it, so a row appearing without a
|
||||
renumber left every line one row too high, and the arrangement came apart a
|
||||
little more with each new note. `placeOnTop` builds the order from the list
|
||||
*without* the new note — measuring the room after the furniture has moved would
|
||||
write the misreading back as the truth — and puts the note in front of it. Every
|
||||
other row keeps its place, and the rows that had never been dragged stop sliding
|
||||
about, because now they carry a position of their own.
|
||||
|
||||
The views are different: "All Notes", "Starred" and the bin are questions asked
|
||||
of every notebook at once, so there is nothing for an arrangement to belong to
|
||||
and nowhere to keep it. They start in "last changed" and are not offered the
|
||||
hand-made order at all.
|
||||
|
||||
| Mode | Order |
|
||||
| --- | --- |
|
||||
| `manual` | the user's own arrangement, dragged into place — notebooks only |
|
||||
| `updated` | most recently changed first *(the default)* |
|
||||
| `manual` | the user's own arrangement, dragged into place — notebooks only, *and what a notebook starts in* |
|
||||
| `updated` | most recently changed first *(the default for the views: All Notes, Starred, the bin)* |
|
||||
| `oldest` | least recently changed first |
|
||||
| `created` | most recently created first |
|
||||
| `title` | A–Z, case- and umlaut-aware, with numbers read as numbers |
|
||||
@@ -832,6 +1461,35 @@ never re-derived from what is on screen, so two rules hold it up:
|
||||
questions asked of every notebook at once, so there is nothing for an
|
||||
arrangement to belong to and nowhere to keep it.
|
||||
|
||||
**Icons.** A note and a chapter can each wear a mark of their own beside the
|
||||
name — chosen from a short searchable list rather than the system emoji keyboard,
|
||||
which is a different thing on every machine and offers three thousand pictures
|
||||
for a decision that is "which of these is the invoice notebook". The list is
|
||||
searched by what a thing is *for* ("invoice", "house", "travel"), because nobody
|
||||
hunts a pictogram grid by shape. The same glyph shows in the list, above the
|
||||
editor **and in every link pointing at the note**, so it is recognised by one sign
|
||||
everywhere. A note's mark takes a
|
||||
colour too; a chapter's uses the colour the chapter already has.
|
||||
|
||||
Marks get in two ways. **Anything can be typed into the search box** — it is a
|
||||
text field, which on a phone is exactly where the system emoji keyboard lives, so
|
||||
a mark the list never thought of is offered as a tile and used like any other.
|
||||
Eight code points at most, the same limit the server keeps, so one glyph with its
|
||||
modifiers fits and a pasted sentence does not; letters and digits mean a word was
|
||||
typed and a search is meant.
|
||||
|
||||
The list itself lives in one place,
|
||||
[`ui/IconChooser.jsx`](frontend/src/ui/IconChooser.jsx),
|
||||
and **adding one is a single line** — a `{ char, words }` in whichever group it
|
||||
belongs to. Nothing else has to be touched: what is stored on a note is the
|
||||
character itself and not a number into the list, so entries may be added, moved
|
||||
or dropped without disturbing anything already chosen. The words are the whole
|
||||
point of the entry, so give it the ones somebody would actually type — what the
|
||||
thing is *for*, not what it depicts. Both dialogs that offer a mark render that
|
||||
same component; there were two copies of the list once, and only one of them
|
||||
ever grew. The groups are headings, not filters — a search looks through all of
|
||||
them at once, and a group with no match is not drawn.
|
||||
|
||||
**Separators** are lines drawn between notes, optionally named, stored on the
|
||||
notebook beside the sort mode. Renaming and removing are both right-click, which
|
||||
is how everything else in that list is acted on: a left-click on the label used
|
||||
@@ -840,9 +1498,24 @@ edit, and the × for removal sat a few pixels from that same target. Removal ask
|
||||
first, and says the notes are not affected. They move by dragging like a note does — a
|
||||
divider that cannot move with what it divides is worse than none — and their
|
||||
position is recorded as "how many notes come before me", so they stay put as the
|
||||
notes around them are renamed, edited or opened. They are shown only in the
|
||||
manual order: in "newest first" the notes would slide past the line on every
|
||||
edit.
|
||||
notes around them are renamed, edited or opened.
|
||||
|
||||
One press draws the line. Everything about it — name, colour, and one of five
|
||||
shapes — is then set in **one dialog** reached by right-click, which draws the
|
||||
line as it will look and draws each shape beside its name. It used to be three
|
||||
menu entries and an input inside the row: three shapes of interaction for one
|
||||
small thing, none of them showing the result. The preview is built from the very
|
||||
rules the list draws with, because a sample that can disagree with the result is
|
||||
worse than none.
|
||||
|
||||
**A chapter can be divided the same way**, and a line is put into one by dragging
|
||||
it there — there is no second "+ Separator" inside a chapter, because a second
|
||||
way to make the same thing in a second place is a second thing to keep straight.
|
||||
|
||||
They are shown only in the manual order: in "newest first" the notes would slide
|
||||
past the line on every edit — and **the button that draws one is only there in
|
||||
that order too**. Standing in the others it could do nothing at all when pressed,
|
||||
which reads exactly like a button that is broken.
|
||||
|
||||
Reordering also has its own route, `POST /api/notes/reorder`. Going through
|
||||
`PUT /api/notes/:id` would bump `updated_at` and snapshot a version — so
|
||||
@@ -856,7 +1529,7 @@ its history on a change that is not to the note at all.
|
||||
The panel beside a PDF is for notes about the document. A note that belongs to
|
||||
*one paragraph* is a different thing — and a note that has to begin "on page 14,
|
||||
third paragraph" has already lost its place. So a note can be pinned to a spot:
|
||||
click **Randnotiz**, click the passage, write.
|
||||
click **Pin note**, click the passage, write.
|
||||
|
||||
**The note is written beside the page, not on it.** Only a small numbered marker
|
||||
sits on the document, with a hairline out to a card in the margin, level with the
|
||||
@@ -865,8 +1538,12 @@ is the one thing it must not do. Cards keep one height, so two notes close
|
||||
together are laid out by pushing the second down until they no longer overlap;
|
||||
the one being written in grows and rises above the rest.
|
||||
|
||||
The column is 288px wide and a card holds about five lines. It was 208 by 74 —
|
||||
three short lines, which is a caption, not a remark — while the space beside the
|
||||
page sat mostly empty.
|
||||
|
||||
**On a phone there is no margin.** A page narrow enough to read leaves no white
|
||||
space beside it, and taking 200px for a column would make the document itself
|
||||
space beside it, and taking a column that wide would make the document itself
|
||||
unreadable. There, tapping a marker brings the note up from the bottom — over the
|
||||
page, but only while it is open, within reach of a thumb, and with the passage
|
||||
still visible above it.
|
||||
@@ -950,7 +1627,14 @@ Writing the exporter also turned up a real bug in it: blocks were emitted
|
||||
without a blank line between them, so a list written straight after another list
|
||||
became more items of the first, and a quote or code fence on the next line was
|
||||
read as part of the item above. That is not one reader's quirk — it is what the
|
||||
format says — so the file rendered wrongly everywhere, not only here.
|
||||
format says — so the file rendered wrongly everywhere, not only here. The same
|
||||
rule was missing again one level down, inside a quote, until quotes were made to
|
||||
carry more than a sentence.
|
||||
|
||||
Coming back the other way, a quote is read as a unit across the `>` lines that
|
||||
divide it: a quoted blank line separates the blocks inside the quote, it does not
|
||||
end it. Everything the exporter can put in a quote — headings, lists, tables,
|
||||
code, further quotes — is read back out of one.
|
||||
|
||||
---
|
||||
|
||||
@@ -974,7 +1658,12 @@ A note without a drawing skips the first question instead of showing it with two
|
||||
thirds greyed out.
|
||||
|
||||
**Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code,
|
||||
tables and task state all have equivalents and are carried over; a link to
|
||||
tables and task state all have equivalents and are carried over. A quote may
|
||||
hold whole blocks — a heading, a list, a closing line — and inside it they are
|
||||
divided by a **quoted** blank line (`>` on its own), the same way blocks are
|
||||
divided everywhere else: written on consecutive lines, the sentence after a
|
||||
quoted list is read as more of the last bullet by every other reader, and a
|
||||
truly empty line would end the quote instead of dividing it. A link to
|
||||
another exported note becomes a relative link to its file, and a link to a note
|
||||
outside the export degrades to its plain title rather than a dead reference.
|
||||
A note with images or a drawing arrives as a ZIP so the files travel with it,
|
||||
@@ -1058,6 +1747,30 @@ out as a ZIP rather than as its first page alone.
|
||||
|
||||
---
|
||||
|
||||
## Made offline, and what it is called
|
||||
|
||||
Something created with no connection carries an id this device invented —
|
||||
`local_x9k2…`. When its create finally reaches the server, the server names it
|
||||
something else, and the local database is repointed at the new name.
|
||||
|
||||
**The queue has to be repointed too**, and for a long time it was not. The create
|
||||
arrived; every edit still queued behind it went on addressing the name only this
|
||||
device ever knew; the server answered 404; the entry was retried five times and
|
||||
thrown away. A note written and drawn on offline reached the server *empty and
|
||||
untitled*, with nothing to say that anything had been lost. The same held for a
|
||||
notebook made offline and everything filed into it.
|
||||
|
||||
`repointQueue(oldId, newId)` in [`offlineStore.ts`](frontend/src/offlineStore.ts)
|
||||
now rewrites the waiting entries alongside the database. It replaces the id as a
|
||||
plain string across the whole queued payload rather than in a list of named
|
||||
fields — 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.
|
||||
|
||||
The `offline` suite makes a note with the network off, titles it, writes in it,
|
||||
draws on it, reconnects, and then asks the *server* for all three.
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
Two devices can edit the same note without knowing about each other — a phone
|
||||
|
||||
Reference in new issue
Block a user