Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73cc28696f | ||
|
|
dcc42bb8e0 | ||
|
|
558c15f2c9 |
No files matched your search
+127
@@ -0,0 +1,127 @@
|
||||
# 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.
|
||||
|
||||
Digests are recorded from the registry (`docker buildx imagetools inspect`),
|
||||
not from the output of `docker push`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — The writing column
|
||||
|
||||
**Images**
|
||||
- `inknote-web` — see the entry below this one; the web image was rebuilt after
|
||||
the revert, and the digest is written in when it is pushed
|
||||
- `inknote-server` — unchanged, `sha256:1648498f…`
|
||||
|
||||
**Changed**
|
||||
- The note column is centred again. It had been pinned to the left margin so the
|
||||
start of every line would always be in the same place; on a wide window that
|
||||
left the whole right half of the pane as one dead block.
|
||||
- A page surface was drawn behind the text to give that block a meaning, and
|
||||
reverted the same day: it made the sheet sit hard against the left edge, which
|
||||
was worse than the problem it solved.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — Pages, typefaces, alignment
|
||||
|
||||
**Images** — web `sha256:3b040d99…`, server `sha256:1648498f…`
|
||||
|
||||
**Added**
|
||||
- **Page count while writing.** The toolbar says *Page 2 of 4*, in the same words
|
||||
the PDF reader uses. Pages are counted, never stored: the note stays one
|
||||
document, and a test asserts no page ever reaches the stored body.
|
||||
- **The typeface is a per-account setting** — serif, sans or mono, chosen under
|
||||
Account → Profile. On the account rather than in the browser, so it follows you
|
||||
to the phone.
|
||||
- **Text alignment**, with its own TipTap extension rather than the official one,
|
||||
so the schema stays in files this project owns. Left is the default and is
|
||||
never written into the document. In Markdown, non-left blocks travel as
|
||||
`<div align="…">` — the one form most readers still honour — and the importer
|
||||
reads it back onto the paragraphs.
|
||||
|
||||
**Fixed**
|
||||
- The Markdown exporter did not put a blank line between top-level blocks, so
|
||||
consecutive paragraphs ran together in other readers.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — English throughout, and the PDF toolbar
|
||||
|
||||
**Added**
|
||||
- **Search inside a notebook**, from the note list. The sidebar's global search
|
||||
was removed: searching from inside a notebook should mean searching *that*
|
||||
notebook.
|
||||
- **New note / Upload PDF / Import Markdown** all live under the one `+` button.
|
||||
|
||||
**Changed**
|
||||
- The interface is English throughout. It had drifted into two languages.
|
||||
- The two visibility switches — drawing and margin notes — sit together at the
|
||||
far right of the PDF toolbar, because they mean the same kind of thing.
|
||||
- The drawing tools appear next to the *Draw* button, not past everything else.
|
||||
- A separator is renamed and removed by right-click, with a confirmation for the
|
||||
removal.
|
||||
|
||||
**Fixed**
|
||||
- Hiding the drawing also hid the margin notes. Each switch now hides only its
|
||||
own thing.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,77 @@
|
||||
# InkNote – Projektkonventionen für Claude Code
|
||||
|
||||
Selbst gehostete Notiz-App, privat produktiv im Einsatz. Änderungen laufen auf
|
||||
echten Daten – im Zweifel erst fragen, nicht erst ausprobieren.
|
||||
|
||||
Die ausführliche Beschreibung steht im [readme.md](readme.md); diese Datei hält
|
||||
nur fest, was beim Arbeiten gilt und sich nicht aus dem Code ergibt.
|
||||
|
||||
## Feste Regeln
|
||||
|
||||
Diese Punkte sind Entscheidungen von Marc, keine offenen Fragen:
|
||||
|
||||
- **Backend immer mit Docker starten**, nie mit `go run` o. Ä.
|
||||
`docker compose -f docker-compose.dev.yml up -d --build`
|
||||
- **Direkt auf `main` pushen.** Keine Feature-Branches, kein PR-Umweg.
|
||||
- **Kein Backup-Feature bauen.** Die Daten liegen über Bind-Mounts auf dem Host
|
||||
(`INKNOTE_DATA_DIR`, Standard `/srv/inknote`), und was den Host sichert –
|
||||
Proxmox Backup – sichert damit auch InkNote.
|
||||
- **Die Verschlüsselung bleibt.** Notizinhalte, Zeichnungen und hochgeladene
|
||||
Dateien liegen AES-256-GCM-verschlüsselt auf dem Server. Nichts davon
|
||||
entfernen, um etwas anderes einfacher zu machen.
|
||||
- **Keine öffentliche Registrierung.** Konten legt ein Administrator an. Ein
|
||||
Admin darf anlegen, löschen und Passwörter zurücksetzen – aber **keine**
|
||||
weiteren Admins ernennen und **keine** fremden Konten umbenennen.
|
||||
- **Bundle-Größe bleibt, wie sie ist.** Bewusst so entschieden.
|
||||
- **Dashboard und Seitenleiste sind vertagt.** Die offenen Befunde dazu sind
|
||||
bekannt; nicht ungefragt damit anfangen.
|
||||
|
||||
## Tests
|
||||
|
||||
Beides muss vor einem Push grün sein.
|
||||
|
||||
```bash
|
||||
# Unit (224 Tests, ~4 s)
|
||||
docker run --rm -v "$PWD/frontend:/w" -w /w node:20-alpine npx vitest run
|
||||
|
||||
# Browser, zwölf Suiten (~263 Checks) – braucht den Dev-Stack
|
||||
./e2e/run.sh # alle
|
||||
./e2e/run.sh editor drawing # einzelne
|
||||
```
|
||||
|
||||
Die Browser-Suiten melden sich mit Marcs Admin-Konto an (`INKNOTE_ADMIN` /
|
||||
`INKNOTE_ADMIN_PASS`, Standard `marc`/`geheim123`) und legen sich damit
|
||||
Wegwerf-Nutzer an. Sie laufen gegen `./data`, nicht gegen `/srv/inknote`.
|
||||
Screenshots landen in `e2e/shots/` und sind gitignored.
|
||||
|
||||
`attachments-lifecycle` ruft `POST /admin/housekeeping` auf – das schreibt auf
|
||||
echten Daten. Nicht gegen die Produktionsinstanz laufen lassen.
|
||||
|
||||
## Ausliefern
|
||||
|
||||
```bash
|
||||
./build.sh # baut beide Images und schiebt sie ins Gitea-Registry
|
||||
```
|
||||
|
||||
Nur das Image bauen, das sich geändert hat. Nach dem Push den Digest bei der
|
||||
Registry gegenprüfen, nicht nur der Ausgabe von `docker push` glauben:
|
||||
|
||||
```bash
|
||||
docker buildx imagetools inspect gitea.honeyy.duckdns.org/docker/inknote-web:latest
|
||||
```
|
||||
|
||||
Auf dem Server: `docker compose pull && docker compose up -d`.
|
||||
|
||||
## Änderungen festhalten
|
||||
|
||||
Jede Runde, die als Image rausgeht, bekommt einen Eintrag in
|
||||
[CHANGELOG.md](CHANGELOG.md) – mit den Image-Digests, damit später nachvollziehbar
|
||||
ist, was auf dem Server läuft. Der `readme.md` beschreibt den *jetzigen* Stand
|
||||
und wird mitgeändert, wenn sich Verhalten ändert; das Changelog beschreibt den
|
||||
Weg dahin.
|
||||
|
||||
## Stil
|
||||
|
||||
Der Code erklärt in Kommentaren das *Warum*, nicht das *Was* – oft mit dem
|
||||
Fehler, den die Zeile verhindert. Neue Kommentare in dem Ton, englisch wie der
|
||||
Rest. Die Oberfläche ist durchgehend englisch, das Gespräch mit Marc deutsch.
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Throwaway: one wide screenshot of the writing surface, to look at it. */
|
||||
import { OUT, launch, withFreshUser, createNotebook, createNote, typeInEditor } from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
const session = await withFreshUser(browser, "shot");
|
||||
const p = session.page;
|
||||
await p.setViewportSize({ width: 1680, height: 900 });
|
||||
await createNotebook(p, "Look");
|
||||
await createNote(p);
|
||||
await typeInEditor(p, "askjdnasjkdnkjasndjknkjaskjdnasjkdskajndjkanHelkl HALLLO");
|
||||
await p.keyboard.press("Enter");
|
||||
await p.keyboard.type("sdfsdfsdfsdfasddddd");
|
||||
await p.waitForTimeout(1500);
|
||||
await p.screenshot({ path: `${OUT}/look.png` });
|
||||
const box = await p.evaluate(() => {
|
||||
const pm = document.querySelector(".ProseMirror").getBoundingClientRect();
|
||||
const pane = document.querySelector(".note-editor").parentElement.getBoundingClientRect();
|
||||
return { pm: { x: Math.round(pm.x), w: Math.round(pm.width) }, pane: { x: Math.round(pane.x), w: Math.round(pane.width) } };
|
||||
});
|
||||
console.log(JSON.stringify(box));
|
||||
await session.cleanup();
|
||||
await browser.close();
|
||||
@@ -38,10 +38,11 @@
|
||||
point where the eye reliably finds the start of the next line. Capped here
|
||||
rather than on the container so it holds on a page as well as off one. */
|
||||
max-width: 527px;
|
||||
/* Left, not centred. A column that floats in the middle moves as the window
|
||||
is resized, so the start of every line lands somewhere new; against the
|
||||
margin it is always in the same place. */
|
||||
margin-left: 0;
|
||||
/* 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. */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
outline: none;
|
||||
color: var(--ne-text);
|
||||
|
||||
@@ -100,10 +100,6 @@ export function NoteEditor({
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
active: dark ? "#388bfd" : "#0969da",
|
||||
faint: dark ? "#484f58" : "#afb8c1",
|
||||
/* A shade apart from the desk it lies on — enough to read as a surface,
|
||||
not so much that it glares in a dark room. */
|
||||
pageBg: dark ? "#131922" : "#ffffff",
|
||||
desk: dark ? "#0b0f14" : "#e9ecef",
|
||||
}),
|
||||
[dark]
|
||||
);
|
||||
@@ -502,7 +498,11 @@ export function NoteEditor({
|
||||
* width a property of the window, which is what it always looked like. */
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
background: isMobileViewport ? T.pageBg : T.desk,
|
||||
background: isMobileViewport
|
||||
? T.bg
|
||||
: dark
|
||||
? "linear-gradient(180deg,#0f141b 0%,#0d1117 60%)"
|
||||
: "linear-gradient(180deg,#f8fafd 0%,#eef3f8 70%)",
|
||||
}}
|
||||
>
|
||||
{/* Main toolbar */}
|
||||
@@ -716,65 +716,41 @@ export function NoteEditor({
|
||||
padding: isMobileViewport ? "10px 0 18px" : "18px 0 28px",
|
||||
}}
|
||||
>
|
||||
{/* The page is a surface, always.
|
||||
With the text capped at a readable measure and nothing behind it,
|
||||
the right 40% of the pane was simply void. A page gives that space
|
||||
a meaning — it is the margin — and it is what lets "page 2" show as
|
||||
a page rather than as a line across an empty field. */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: stageSize.w,
|
||||
maxWidth: "100%",
|
||||
minHeight: stageSize.h,
|
||||
margin: isMobileViewport ? "0 auto" : "0 0 0 28px",
|
||||
background: T.pageBg,
|
||||
border: isMobileViewport ? "none" : `1px solid ${T.border}`,
|
||||
borderRadius: isMobileViewport ? 0 : 6,
|
||||
boxShadow: isMobileViewport ? "none" : "0 6px 20px rgba(0,0,0,0.16)",
|
||||
// Without a page the column is as wide as it needs to be to read
|
||||
// comfortably, and centred; with one it is the canvas's page.
|
||||
// The text caps its own width in the stylesheet, so this is only
|
||||
// the page when there is one.
|
||||
width: needsPage ? stageSize.w : "100%",
|
||||
minHeight: needsPage ? stageSize.h : undefined,
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{/* Where one page ends and the next begins.
|
||||
A band rather than a hairline, with the new page's number set in
|
||||
the margin — the count in the toolbar should correspond to
|
||||
something that reads as a page, not to a line across a field.
|
||||
The text flows on through: the note is one document, and a real
|
||||
page break would mean laying the text out ourselves. */}
|
||||
{Array.from({ length: Math.max(0, pages.count - 1) }, (_, i) => (
|
||||
{/* A hairline where each page ends, so the count in the toolbar
|
||||
corresponds to something you can see. Drawn behind the text and
|
||||
deaf to the pointer — it marks the paper, it is not part of it. */}
|
||||
{pages.count > 1 && mode === "text" && (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden
|
||||
data-page-break={i + 2}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: stageSize.h * (i + 1),
|
||||
height: 0,
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
borderTop: `1px dashed ${T.border}`,
|
||||
backgroundImage: `linear-gradient(to bottom, transparent calc(100% - 1px), ${T.border} 100%)`,
|
||||
backgroundSize: `100% ${stageSize.h}px`,
|
||||
opacity: 0.55,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
top: 8,
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 0.5,
|
||||
color: T.faint,
|
||||
background: T.pageBg,
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
Page {i + 2}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
minHeight: stageSize.h,
|
||||
minHeight: needsPage ? stageSize.h : undefined,
|
||||
width: "100%",
|
||||
pointerEvents: mode === "text" ? "auto" : "none",
|
||||
}}
|
||||
@@ -783,7 +759,7 @@ export function NoteEditor({
|
||||
editor={textEditor}
|
||||
dark={dark}
|
||||
font={noteFont}
|
||||
minHeight={stageSize.h}
|
||||
minHeight={needsPage ? stageSize.h : 0}
|
||||
onOpenNote={onOpenNote}
|
||||
resolveLinkLabel={resolveLinkLabel}
|
||||
/>
|
||||
|
||||
@@ -47,6 +47,8 @@ inknote/
|
||||
├── docker-compose.dev.yml # Build from source for local work
|
||||
├── .env.example # Copy to .env — Compose reads it from here
|
||||
├── build.sh # Build & push images to Gitea
|
||||
├── CHANGELOG.md # What changed per released image, with digests
|
||||
├── CLAUDE.md # Conventions and standing decisions for this repo
|
||||
├── backend/
|
||||
│ ├── server.js # Express API + WebSocket server
|
||||
│ ├── sync-client.js # Client-side sync engine library
|
||||
@@ -371,10 +373,17 @@ state of their own, so they can be read on their own.
|
||||
|
||||
## Setting a note
|
||||
|
||||
**Left-aligned, with the column against the margin.** A column that floats in the
|
||||
middle moves as the window is resized, so the start of every line lands somewhere
|
||||
new; against the margin it is always in the same place. About 73 characters to
|
||||
the line — measured, not guessed.
|
||||
**A centred column, about 73 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.
|
||||
|
||||
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
|
||||
that for something worse: on a wide window the entire right half of the pane
|
||||
became one dead block. A page surface was then drawn behind the text to give that
|
||||
block a meaning, which only moved the problem — the whole sheet sat hard against
|
||||
the left edge. Centred, the same leftover space reads as two margins and needs no
|
||||
explaining.
|
||||
|
||||
**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
|
||||
@@ -383,7 +392,7 @@ every device. The server accepts those three values and drops anything else —
|
||||
that column is written by a browser, so it is an input like any other.
|
||||
|
||||
**Pages are counted, not stored.** The toolbar says *Page 2 of 4* in the same
|
||||
words the PDF reader uses, and a hairline marks where each one ends. A page is
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user