Set the note left, count its pages, choose its typeface, align its text
The column sat in the middle, so it moved as the window was resized and the start of every line landed somewhere new. It is against the margin now. The toolbar counts pages in the words the PDF reader uses, with a hairline where each ends. Pages are drawn, never stored: the note stays one document, Markdown has no page, and a check asserts none reaches the stored body. The current page is the one under the middle of the window — measuring from the top claimed "page 1" while you stood at the foot of a two-page note, because a window is shorter than a page. The typeface is a per-account setting rather than a per-browser one, because a choice about how your own notes are set should follow you to the phone. The server takes serif, sans or mono and drops anything else; that column is written by a browser, so it is an input like any other. Alignment is a small extension of our own — one attribute, a parse rule, a render rule — so the schema every stored note is validated against stays in files this project owns. Markdown has no alignment: what CommonMark allows is embedded HTML, so non-left blocks travel as `<div align="…">`, the one form most readers still honour, and the importer puts it back on the paragraphs. Left is never written. The full run caught a crash I had put in: PdfWorkspace used `noteFont` without taking it as a prop, which the build cannot see and the type checker does not reach in .jsx. It killed the notes panel beside a PDF, and `offline` failed with it because the whole app was broken. 224 unit tests, 263 browser checks across twelve suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
830af6532a
commit
fed53772aa
21 files changed
+637
-9
No files matched your search
@@ -269,13 +269,31 @@ function normalizeUsername(raw) {
|
||||
}
|
||||
|
||||
/** The fields of a user row that may leave the server. Never the password hash. */
|
||||
/** What a client is allowed to know about a user. */
|
||||
const publicUser = (row) => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
isAdmin: !!row.is_admin,
|
||||
preferences: readPreferences(row.preferences),
|
||||
});
|
||||
|
||||
/* 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"];
|
||||
|
||||
function readPreferences(raw) {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
const font = parsed?.noteFont;
|
||||
return { noteFont: NOTE_FONTS.includes(font) ? font : "serif" };
|
||||
}
|
||||
|
||||
// Login used to be e-mail based. Rebuild the table with a username column and
|
||||
// derive each existing user's username from the local part of their address.
|
||||
(function migrateEmailToUsername() {
|
||||
@@ -364,6 +382,15 @@ if (!tableColumns("users").includes("is_admin")) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
/* Per-account interface settings, as JSON.
|
||||
*
|
||||
* On the account rather than in the browser's storage: a choice about how your
|
||||
* own notes are set should follow you to the phone, not be made again on every
|
||||
* device. Only presentation lives here — nothing the server acts on. */
|
||||
if (!tableColumns("users").includes("preferences")) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN preferences TEXT");
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure exactly one way in exists.
|
||||
*
|
||||
@@ -624,6 +651,20 @@ app.put("/api/auth/profile", authMiddleware, (req, res) => {
|
||||
res.json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId)));
|
||||
});
|
||||
|
||||
app.put("/api/auth/preferences", authMiddleware, (req, res) => {
|
||||
const { noteFont } = req.body || {};
|
||||
if (noteFont !== undefined && !NOTE_FONTS.includes(noteFont)) {
|
||||
return res.status(400).json({ error: `noteFont must be one of ${NOTE_FONTS.join(", ")}` });
|
||||
}
|
||||
const current = readPreferences(
|
||||
db.prepare("SELECT preferences FROM users WHERE id = ?").get(req.userId)?.preferences
|
||||
);
|
||||
const next = { ...current, ...(noteFont !== undefined ? { noteFont } : {}) };
|
||||
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)));
|
||||
});
|
||||
|
||||
app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
|
||||
try {
|
||||
const { newUsername, password } = req.body;
|
||||
|
||||
+121
@@ -3,6 +3,7 @@
|
||||
* freshly typed content. */
|
||||
import {
|
||||
OUT,
|
||||
api,
|
||||
check,
|
||||
createNote,
|
||||
createNotebook,
|
||||
@@ -130,6 +131,126 @@ try {
|
||||
);
|
||||
check((await p.locator(".ProseMirror").innerText()).includes("Grundtext"), "plain text survived a reload");
|
||||
await p.screenshot({ path: `${OUT}/editor-after-reload.png` });
|
||||
/* ── 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
|
||||
// uses, so "page 2" means the same thing writing or drawing.
|
||||
await createNote(p);
|
||||
await setTitle(p, "Langer Text");
|
||||
check(
|
||||
(await p.locator("[data-note-position]").innerText()).includes("of 1"),
|
||||
"a fresh note is one page",
|
||||
await p.locator("[data-note-position]").innerText()
|
||||
);
|
||||
|
||||
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");
|
||||
}
|
||||
await p.waitForTimeout(3500);
|
||||
|
||||
const position = () => p.locator("[data-note-position]").innerText();
|
||||
const scrollTo = async (fraction) => {
|
||||
await p.evaluate((f) => {
|
||||
const pm = document.querySelector(".ProseMirror");
|
||||
let el = pm.parentElement;
|
||||
while (el && getComputedStyle(el).overflowY !== "auto") el = el.parentElement;
|
||||
el.scrollTop = (el.scrollHeight - el.clientHeight) * f;
|
||||
}, fraction);
|
||||
await p.waitForTimeout(900);
|
||||
return position();
|
||||
};
|
||||
|
||||
const total = Number((await position()).match(/of (\d+)/)?.[1] || 0);
|
||||
check(total > 2, "writing past the end of a page makes more of them", await position());
|
||||
check(
|
||||
(await p.locator("[data-note-position]").count()) === 1,
|
||||
"and the count is shown the way the PDF reader shows it"
|
||||
);
|
||||
|
||||
check((await scrollTo(0)).startsWith("Page 1"), "at the top it says page one", await position());
|
||||
const nearEnd = await scrollTo(0.9);
|
||||
check(
|
||||
Number(nearEnd.match(/Page (\d+)/)[1]) > 1,
|
||||
"scrolling down moves the count on",
|
||||
nearEnd
|
||||
);
|
||||
|
||||
/* 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");
|
||||
|
||||
/* ── alignment ── */
|
||||
// Left is the default and is never written into the document, so an untouched
|
||||
// paragraph reads as left without carrying an attribute for it.
|
||||
await createNote(p);
|
||||
await setTitle(p, "Ausrichtung");
|
||||
await typeInEditor(p, "Diese Zeile wandert.");
|
||||
check(
|
||||
(await p.locator('[data-align="left"]').count()) === 1,
|
||||
"the toolbar offers alignment"
|
||||
);
|
||||
|
||||
await p.locator('[data-align="center"]').click();
|
||||
await p.waitForTimeout(1800);
|
||||
check(
|
||||
(await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "center",
|
||||
"choosing centre centres the paragraph"
|
||||
);
|
||||
|
||||
await reopenNote(p, "Ausrichtung");
|
||||
check(
|
||||
(await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "center",
|
||||
"and it survives a reload, so it is in the document and not on the screen"
|
||||
);
|
||||
|
||||
const alignedBody = (await api(p, "GET", "/notes")).body;
|
||||
const alignedNote = alignedBody.find((n) => n.title === "Ausrichtung");
|
||||
const alignedContent = (await api(p, "GET", `/notes/${alignedNote.id}`)).body.content;
|
||||
check(/"textAlign"\s*:\s*"center"/.test(alignedContent), "stored on the paragraph itself");
|
||||
|
||||
await p.locator('[data-align="left"]').click();
|
||||
await p.waitForTimeout(1800);
|
||||
check(
|
||||
(await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "start",
|
||||
"and going back to left leaves no alignment behind",
|
||||
await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)
|
||||
);
|
||||
|
||||
/* ── the typeface follows the account ── */
|
||||
await p.getByTitle("Account").click();
|
||||
await p.waitForTimeout(800);
|
||||
await p.locator('[data-note-font="mono"]').click();
|
||||
await p.waitForTimeout(1500);
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(1200);
|
||||
check(
|
||||
/mono/i.test(await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)),
|
||||
"choosing a typeface changes the note text",
|
||||
await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)
|
||||
);
|
||||
|
||||
// On the account, not in this browser — so it is there on the next device.
|
||||
const me = await api(p, "GET", "/auth/me");
|
||||
check(me.body?.preferences?.noteFont === "mono", "and is stored on the account", JSON.stringify(me.body?.preferences));
|
||||
|
||||
await p.reload({ waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(3000);
|
||||
// A reload lands on the dashboard, so the notebook has to be opened again.
|
||||
await p.locator("button").filter({ hasText: "Editor Book" }).first().click();
|
||||
await p.waitForTimeout(1500);
|
||||
await p.locator("[data-note-row]").first().click();
|
||||
await p.waitForTimeout(2500);
|
||||
check(
|
||||
/mono/i.test(await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)),
|
||||
"and survives a reload"
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
|
||||
@@ -84,6 +84,9 @@ export default function App() {
|
||||
const { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView } = useResponsive();
|
||||
const { canInstall, promptInstall } = usePwaInstall();
|
||||
|
||||
// Presentation only, and it lives on the account so it follows to the phone.
|
||||
const noteFont = user?.preferences?.noteFont || "serif";
|
||||
|
||||
const data = useNoteData(user, activeNote);
|
||||
const { notes, notebooks, noteContents, backlinks } = data;
|
||||
const search = useSearchIndex(user, notes, searchQuery);
|
||||
@@ -661,6 +664,7 @@ export default function App() {
|
||||
<EditorPane
|
||||
T={T}
|
||||
dark={dark}
|
||||
noteFont={noteFont}
|
||||
isMobile={isMobile}
|
||||
note={openNoteData}
|
||||
isTrashView={isTrashView}
|
||||
|
||||
@@ -289,6 +289,29 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
|
||||
* There were fourteen controls in a row that scrolled sideways, so the
|
||||
* ones at the end could only be reached by dragging the bar — and nothing
|
||||
* said they were there. A menu is one tap and shows what it holds. */}
|
||||
{/* Left is the default, so its button reads active on an untouched
|
||||
paragraph — the state is the document's, not a mode we remember. */}
|
||||
{!compact && (
|
||||
<>
|
||||
{divider}
|
||||
{[
|
||||
["left", icons.alignLeft, "Align left"],
|
||||
["center", icons.alignCenter, "Centre"],
|
||||
["right", icons.alignRight, "Align right"],
|
||||
].map(([value, icon, title]) => (
|
||||
<button
|
||||
key={value}
|
||||
data-align={value}
|
||||
style={btn(editor.isActive({ textAlign: value }))}
|
||||
title={title}
|
||||
onClick={() => editor.chain().focus().setTextAlign(value).run()}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{compact ? (
|
||||
<div style={{ position: "relative" }} data-more-menu>
|
||||
<button
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
* properties the editor container sets per theme. */
|
||||
|
||||
.note-editor {
|
||||
--ne-font: "Newsreader", Georgia, serif;
|
||||
--ne-text: #1f2328;
|
||||
}
|
||||
.note-editor[data-font="sans"] {
|
||||
--ne-font: "DM Sans", system-ui, sans-serif;
|
||||
}
|
||||
.note-editor[data-font="mono"] {
|
||||
--ne-font: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
|
||||
}
|
||||
.note-editor {
|
||||
--ne-muted: #656d76;
|
||||
--ne-faint: #afb8c1;
|
||||
--ne-border: #d0d7de;
|
||||
@@ -29,11 +38,16 @@
|
||||
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;
|
||||
margin-left: auto;
|
||||
/* 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;
|
||||
margin-right: auto;
|
||||
outline: none;
|
||||
color: var(--ne-text);
|
||||
font-family: "Newsreader", Georgia, serif;
|
||||
/* 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;
|
||||
padding: 20px 24px;
|
||||
|
||||
@@ -12,6 +12,7 @@ import TableHeader from "@tiptap/extension-table-header";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
|
||||
import { NoteLink } from "./noteLink.js";
|
||||
import { TextAlign } from "./textAlign.js";
|
||||
import { AttachmentImage } from "./attachmentImage.js";
|
||||
|
||||
export function buildExtensions({ search = () => [], placeholder = "Start writing..." } = {}) {
|
||||
@@ -38,6 +39,7 @@ export function buildExtensions({ search = () => [], placeholder = "Start writin
|
||||
TableHeader,
|
||||
TableCell,
|
||||
NoteLink.configure({ search }),
|
||||
TextAlign.configure({ types: ["paragraph", "heading"] }),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
/**
|
||||
* How a paragraph or heading is aligned.
|
||||
*
|
||||
* Written out rather than pulled in. The official extension does the same
|
||||
* handful of things — one attribute, a parse rule, a render rule, two commands —
|
||||
* and this way the schema stays in files this project owns, which matters
|
||||
* because every stored note is validated against it.
|
||||
*
|
||||
* `left` is the default and is never written into the document: an attribute on
|
||||
* every paragraph in every note would be pure weight, and a note that has never
|
||||
* been aligned should not look aligned when read by anything else.
|
||||
*/
|
||||
|
||||
export const ALIGNMENTS = ["left", "center", "right", "justify"];
|
||||
export const DEFAULT_ALIGN = "left";
|
||||
|
||||
export const TextAlign = Extension.create({
|
||||
name: "textAlign",
|
||||
|
||||
addOptions() {
|
||||
return { types: ["paragraph", "heading"] };
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
textAlign: {
|
||||
default: DEFAULT_ALIGN,
|
||||
parseHTML: (el) => {
|
||||
const value = el.style.textAlign || el.getAttribute("align") || DEFAULT_ALIGN;
|
||||
return ALIGNMENTS.includes(value) ? value : DEFAULT_ALIGN;
|
||||
},
|
||||
renderHTML: (attrs) =>
|
||||
attrs.textAlign && attrs.textAlign !== DEFAULT_ALIGN
|
||||
? { style: `text-align: ${attrs.textAlign}` }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setTextAlign:
|
||||
(alignment) =>
|
||||
({ commands }) => {
|
||||
if (!ALIGNMENTS.includes(alignment)) return false;
|
||||
return this.options.types
|
||||
.map((type) => commands.updateAttributes(type, { textAlign: alignment }))
|
||||
.some(Boolean);
|
||||
},
|
||||
unsetTextAlign:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
this.options.types.map((type) => commands.resetAttributes(type, "textAlign")).some(Boolean),
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Shift-l": () => this.editor.commands.setTextAlign("left"),
|
||||
"Mod-Shift-e": () => this.editor.commands.setTextAlign("center"),
|
||||
"Mod-Shift-r": () => this.editor.commands.setTextAlign("right"),
|
||||
"Mod-Shift-j": () => this.editor.commands.setTextAlign("justify"),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default TextAlign;
|
||||
@@ -106,7 +106,7 @@ export function useNoteEditor({ noteId, content, editable = true, onChange, sear
|
||||
}
|
||||
|
||||
/** The editable page itself. */
|
||||
export function EditorSurface({ editor, dark, minHeight, onOpenNote, resolveLinkLabel }) {
|
||||
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel }) {
|
||||
/* Note links carry the label they were created with, so renaming a note would
|
||||
* leave every reference to it showing the old title.
|
||||
*
|
||||
@@ -161,6 +161,7 @@ export function EditorSurface({ editor, dark, minHeight, onOpenNote, resolveLink
|
||||
<div
|
||||
className="note-editor"
|
||||
data-theme={dark ? "dark" : "light"}
|
||||
data-font={font || "serif"}
|
||||
onClick={handleClick}
|
||||
style={{ minHeight }}
|
||||
>
|
||||
|
||||
@@ -99,16 +99,34 @@ function inlineNode(n: DocNode, o: MarkdownOptions): string {
|
||||
|
||||
/* ── blocks ── */
|
||||
|
||||
/**
|
||||
* Markdown has no alignment.
|
||||
*
|
||||
* It is often said to "basically be HTML"; it is not. What CommonMark allows is
|
||||
* *embedded* HTML, so a centred paragraph can only travel as a tag. `align` is
|
||||
* used rather than a style attribute because that is the one form GitHub and
|
||||
* most other readers still honour — and a reader that does not simply shows the
|
||||
* text, which is the right way to fail.
|
||||
*
|
||||
* Left-aligned blocks are written plainly. Wrapping every paragraph in a div
|
||||
* would turn an ordinary note into markup for the sake of a default.
|
||||
*/
|
||||
function withAlignment(n: DocNode, lines: string[]): string[] {
|
||||
const value = n.attrs?.textAlign;
|
||||
if (!value || value === "left" || lines.every((l) => !l.trim())) return lines;
|
||||
return [`<div align="${value}">`, "", ...lines, "", "</div>"];
|
||||
}
|
||||
|
||||
function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
|
||||
switch (n.type) {
|
||||
case "paragraph": {
|
||||
const text = inline(n.content, o);
|
||||
return text.trim() ? [escapeLineStart(indent + text)] : [""];
|
||||
return text.trim() ? withAlignment(n, [escapeLineStart(indent + text)]) : [""];
|
||||
}
|
||||
|
||||
case "heading": {
|
||||
const level = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
|
||||
return [`${"#".repeat(level)} ${inline(n.content, o)}`];
|
||||
return withAlignment(n, [`${"#".repeat(level)} ${inline(n.content, o)}`]);
|
||||
}
|
||||
|
||||
case "blockquote":
|
||||
|
||||
@@ -54,16 +54,23 @@ function blocks(nodes: DocNode[] | undefined, resolveImage: (id: string) => stri
|
||||
return nodes.map((n) => blockHtml(n, resolveImage)).join("\n");
|
||||
}
|
||||
|
||||
/** `style="text-align: …"`, or nothing at all when the block is left aligned. */
|
||||
function alignAttr(n: DocNode): string {
|
||||
const value = n.attrs?.textAlign;
|
||||
return value && value !== "left" ? ` style="text-align: ${esc(value)}"` : "";
|
||||
}
|
||||
|
||||
function blockHtml(n: DocNode, img: (id: string) => string | null): string {
|
||||
const kids = () => blocks(n.content, img);
|
||||
switch (n.type) {
|
||||
case "paragraph": {
|
||||
const body = inline(n.content, img);
|
||||
return body.trim() ? `<p>${body}</p>` : "<p> </p>";
|
||||
const a = alignAttr(n);
|
||||
return body.trim() ? `<p${a}>${body}</p>` : "<p> </p>";
|
||||
}
|
||||
case "heading": {
|
||||
const l = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
|
||||
return `<h${l}>${inline(n.content, img)}</h${l}>`;
|
||||
return `<h${l}${alignAttr(n)}>${inline(n.content, img)}</h${l}>`;
|
||||
}
|
||||
case "blockquote":
|
||||
return `<blockquote>${kids()}</blockquote>`;
|
||||
|
||||
@@ -137,6 +137,7 @@ function TrashedNote({ T, isMobile, note, onBack, onRestore, onPermanentDelete }
|
||||
export function EditorPane({
|
||||
T,
|
||||
dark,
|
||||
noteFont,
|
||||
isMobile,
|
||||
note,
|
||||
isTrashView,
|
||||
@@ -241,6 +242,7 @@ export function EditorPane({
|
||||
<PdfWorkspace
|
||||
note={note}
|
||||
dark={dark}
|
||||
noteFont={noteFont}
|
||||
onUpdate={onUpdate}
|
||||
onOpenNote={linkProps.openLinkTarget}
|
||||
searchLinkTargets={linkProps.searchLinkTargets}
|
||||
@@ -252,6 +254,7 @@ export function EditorPane({
|
||||
note={note}
|
||||
onUpdate={onUpdate}
|
||||
dark={dark}
|
||||
noteFont={noteFont}
|
||||
onOpenNote={linkProps.openLinkTarget}
|
||||
searchLinkTargets={linkProps.searchLinkTargets}
|
||||
resolveLinkLabel={linkProps.resolveLinkLabel}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function NoteEditor({
|
||||
note,
|
||||
onUpdate,
|
||||
dark,
|
||||
noteFont,
|
||||
onOpenNote,
|
||||
searchLinkTargets,
|
||||
resolveLinkLabel,
|
||||
@@ -52,6 +53,14 @@ export function NoteEditor({
|
||||
const dirtyRef = useRef(false);
|
||||
const SAVE_DEBOUNCE_MS = 600;
|
||||
const [stageSize, setStageSize] = useState({ w: CANON_W, h: CANON_H });
|
||||
/* How many pages the writing fills, and which one is being looked at.
|
||||
*
|
||||
* Pages here are a reading aid, not a change to the document: the note stays
|
||||
* one continuous body, and Markdown has no concept of a page anyway. The
|
||||
* height of one is the same 3:4 the canvas uses, so "page 2" means the same
|
||||
* thing whether you are writing or drawing. */
|
||||
const [pages, setPages] = useState({ count: 1, current: 1 });
|
||||
const scrollerRef = useRef(null);
|
||||
|
||||
/* ── DRAWING STATE ──
|
||||
* strokesRef is what the pointer handlers read and write synchronously;
|
||||
@@ -203,6 +212,40 @@ export function NoteEditor({
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
/* Recounted whenever the text could have changed height — on every editor
|
||||
* transaction, on resize, and once the fonts have actually loaded, since a
|
||||
* fallback face measures differently from the real one. */
|
||||
useEffect(() => {
|
||||
const surface = () => scrollerRef.current?.querySelector(".ProseMirror");
|
||||
const recount = () => {
|
||||
const el = surface();
|
||||
const pageH = stageSize.h;
|
||||
if (!el || !pageH) return;
|
||||
const height = el.scrollHeight;
|
||||
const scroller = scrollerRef.current;
|
||||
const count = Math.max(1, Math.ceil(height / pageH));
|
||||
/* 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));
|
||||
setPages((p) => (p.count === count && p.current === current ? p : { count, current }));
|
||||
};
|
||||
|
||||
recount();
|
||||
const scroller = scrollerRef.current;
|
||||
scroller?.addEventListener("scroll", recount, { passive: true });
|
||||
document.fonts?.ready?.then(recount).catch(() => {});
|
||||
|
||||
if (!textEditor || textEditor.isDestroyed) return () => scroller?.removeEventListener("scroll", recount);
|
||||
textEditor.on("transaction", recount);
|
||||
return () => {
|
||||
scroller?.removeEventListener("scroll", recount);
|
||||
textEditor.off("transaction", recount);
|
||||
};
|
||||
}, [textEditor, stageSize.h, mode]);
|
||||
|
||||
/* ── DRAWING: painting ── */
|
||||
const renderFrame = () => {
|
||||
const c = canvasRef.current;
|
||||
@@ -477,6 +520,13 @@ export function NoteEditor({
|
||||
>
|
||||
{mb("text", I.text, "Write")}
|
||||
{mb("draw", I.draw, "Draw")}
|
||||
{/* Same wording as the PDF reader, because it means the same thing. */}
|
||||
<span
|
||||
data-note-position
|
||||
style={{ fontSize: 11.5, color: T.muted, whiteSpace: "nowrap", flexShrink: 0, marginLeft: 2 }}
|
||||
>
|
||||
Page {pages.current} of {pages.count}
|
||||
</span>
|
||||
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px", flexShrink: 0 }} />
|
||||
{mode === "text" ? (
|
||||
<EditorToolbar
|
||||
@@ -656,6 +706,7 @@ export function NoteEditor({
|
||||
style={{ flex: 1, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
@@ -681,6 +732,22 @@ export function NoteEditor({
|
||||
boxShadow: needsPage && !isMobileViewport ? "0 16px 36px rgba(0,0,0,0.14)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* A hairline where each page ends, so the count in the toolbar
|
||||
corresponds to something you can see. Drawn behind the text and
|
||||
deaf to the pointer — it marks the paper, it is not part of it. */}
|
||||
{pages.count > 1 && mode === "text" && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
backgroundImage: `linear-gradient(to bottom, transparent calc(100% - 1px), ${T.border} 100%)`,
|
||||
backgroundSize: `100% ${stageSize.h}px`,
|
||||
opacity: 0.55,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
minHeight: needsPage ? stageSize.h : undefined,
|
||||
@@ -691,6 +758,7 @@ export function NoteEditor({
|
||||
<EditorSurface
|
||||
editor={textEditor}
|
||||
dark={dark}
|
||||
font={noteFont}
|
||||
minHeight={needsPage ? stageSize.h : 0}
|
||||
onOpenNote={onOpenNote}
|
||||
resolveLinkLabel={resolveLinkLabel}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FONT_UI } from "../../ui/theme";
|
||||
export function PdfNotesPanel({
|
||||
note,
|
||||
dark,
|
||||
noteFont,
|
||||
onUpdate,
|
||||
onOpenNote,
|
||||
searchLinkTargets,
|
||||
@@ -113,6 +114,7 @@ export function PdfNotesPanel({
|
||||
<EditorSurface
|
||||
editor={editor}
|
||||
dark={dark}
|
||||
font={noteFont}
|
||||
minHeight={200}
|
||||
onOpenNote={onOpenNote}
|
||||
resolveLinkLabel={resolveLinkLabel}
|
||||
|
||||
@@ -19,7 +19,15 @@ const MOBILE_BREAKPOINT = 900;
|
||||
* height on every screen and put one of the document's controls somewhere none
|
||||
* of the others were.
|
||||
*/
|
||||
export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
|
||||
export function PdfWorkspace({
|
||||
note,
|
||||
dark,
|
||||
noteFont,
|
||||
onUpdate,
|
||||
onOpenNote,
|
||||
searchLinkTargets,
|
||||
resolveLinkLabel,
|
||||
}) {
|
||||
const [showNotes, setShowNotes] = useState(false);
|
||||
// Set when a link in the notes points at a marker; the viewer clears it once
|
||||
// it has scrolled there.
|
||||
@@ -142,6 +150,7 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
|
||||
<PdfNotesPanel
|
||||
note={note}
|
||||
dark={dark}
|
||||
noteFont={noteFont}
|
||||
onUpdate={onUpdate}
|
||||
onOpenNote={openTarget}
|
||||
searchLinkTargets={searchTargets}
|
||||
|
||||
@@ -292,3 +292,59 @@ describe("round trip", () => {
|
||||
expect(markdownToNote(md).imagePaths).toEqual(["bilder/abc123.png"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("alignment", () => {
|
||||
/* Markdown has no alignment of its own — it is often said to "basically be
|
||||
* HTML", which it is not. What CommonMark allows is embedded HTML, so a
|
||||
* centred paragraph can only travel as a tag. */
|
||||
const centred = (text: string): DocNode => ({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", attrs: { textAlign: "center" }, content: [{ type: "text", text }] }],
|
||||
});
|
||||
|
||||
it("is written as the one wrapper most readers still honour", () => {
|
||||
const md = noteToMarkdown(centred("Mitte"));
|
||||
expect(md).toContain('<div align="center">');
|
||||
expect(md).toContain("Mitte");
|
||||
});
|
||||
|
||||
it("is not written at all for left, which is the default", () => {
|
||||
const md = noteToMarkdown({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", attrs: { textAlign: "left" }, content: [{ type: "text", text: "x" }] }],
|
||||
});
|
||||
expect(md).not.toContain("<div");
|
||||
});
|
||||
|
||||
it("comes back on the paragraph, not on a container", () => {
|
||||
const back = markdownToNote(noteToMarkdown(centred("Mitte"))).doc;
|
||||
expect(back.content![0]).toMatchObject({ type: "paragraph", attrs: { textAlign: "center" } });
|
||||
expect(plain(back)).toBe("Mitte");
|
||||
});
|
||||
|
||||
it("survives on a heading too", () => {
|
||||
const source: DocNode = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 2, textAlign: "right" },
|
||||
content: [{ type: "text", text: "Kopf" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const back = markdownToNote(noteToMarkdown(source)).doc;
|
||||
expect(back.content![0]).toMatchObject({ type: "heading", attrs: { textAlign: "right" } });
|
||||
});
|
||||
|
||||
it("carries several blocks out of one wrapper", () => {
|
||||
const parsed = markdownToNote('<div align="center">\n\nEins\n\nZwei\n\n</div>').doc;
|
||||
expect(parsed.content).toHaveLength(2);
|
||||
expect(parsed.content!.every((n) => n.attrs?.textAlign === "center")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves an unknown alignment alone rather than storing it", () => {
|
||||
const parsed = markdownToNote('<div align="diagonal">\n\nText\n\n</div>').doc;
|
||||
expect(parsed.content!.some((n) => n.attrs?.textAlign)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -223,6 +223,10 @@ function tableCells(line: string): string[] {
|
||||
return cells.map((c) => c.trim());
|
||||
}
|
||||
|
||||
/** `<div align="center">` — the only way alignment can travel in a .md file. */
|
||||
const ALIGN_OPEN = /^\s*<div\s+align=["']?(left|center|right|justify)["']?\s*>\s*$/i;
|
||||
const ALIGN_CLOSE = /^\s*<\/div>\s*$/i;
|
||||
|
||||
function parseBlocks(lines: string[], images: string[]): DocNode[] {
|
||||
const out: DocNode[] = [];
|
||||
let i = 0;
|
||||
@@ -235,6 +239,30 @@ function parseBlocks(lines: string[], images: string[]): DocNode[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* An alignment wrapper. Its contents are ordinary blocks; the alignment is
|
||||
* applied to each of them afterwards, which is what the editor stores — the
|
||||
* attribute lives on the paragraph, not on a container. */
|
||||
const align = ALIGN_OPEN.exec(line);
|
||||
if (align) {
|
||||
const inner: string[] = [];
|
||||
i++;
|
||||
let depth = 1;
|
||||
while (i < lines.length) {
|
||||
if (ALIGN_OPEN.test(lines[i]!)) depth++;
|
||||
else if (ALIGN_CLOSE.test(lines[i]!)) {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
inner.push(lines[i]!);
|
||||
i++;
|
||||
}
|
||||
i++; // the closing tag
|
||||
for (const node of parseBlocks(inner, images)) {
|
||||
out.push({ ...node, attrs: { ...(node.attrs ?? {}), textAlign: align[1]!.toLowerCase() } });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* fenced code — taken verbatim, including anything that looks like markup */
|
||||
const fence = FENCE.exec(line);
|
||||
if (fence) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
StrokeDoc,
|
||||
SyncStatus,
|
||||
User,
|
||||
UserPreferences,
|
||||
AttachmentInfo,
|
||||
} from "./types";
|
||||
|
||||
@@ -368,6 +369,10 @@ export async function logout(): Promise<void> {
|
||||
}
|
||||
|
||||
/* ─── Account ─── */
|
||||
export async function updatePreferences(preferences: Partial<UserPreferences>): Promise<User> {
|
||||
return api.put<User>("/auth/preferences", preferences);
|
||||
}
|
||||
|
||||
export async function updateProfile(displayName: string): Promise<User> {
|
||||
const u = await api.put<User>("/auth/profile", { displayName });
|
||||
await db.setMeta("user", u);
|
||||
|
||||
@@ -100,12 +100,21 @@ export interface Notebook {
|
||||
_pendingCreate?: boolean;
|
||||
}
|
||||
|
||||
/** How the note body is set. Presentation only — the export is unaffected. */
|
||||
export type NoteFont = "serif" | "sans" | "mono";
|
||||
|
||||
export interface UserPreferences {
|
||||
noteFont: NoteFont;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
/** Admins may create, delete and reset the password of other accounts. */
|
||||
isAdmin?: boolean;
|
||||
/** Interface settings, kept on the account so they follow you to the phone. */
|
||||
preferences?: UserPreferences;
|
||||
}
|
||||
|
||||
/** A user row as the admin panel sees it. */
|
||||
|
||||
@@ -4,6 +4,13 @@ import { I } from "./icons.jsx";
|
||||
|
||||
/* ═══════ ACCOUNT MENU ═══════ */
|
||||
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
|
||||
const chooseFont = async (noteFont) => {
|
||||
try {
|
||||
onUserUpdate(await sync.updatePreferences({ noteFont }));
|
||||
} catch {
|
||||
/* the choice is presentation; failing to store it must not block the panel */
|
||||
}
|
||||
};
|
||||
const [tab, setTab] = useState("profile"); // profile | username | password
|
||||
const [displayName, setDisplayName] = useState(user?.displayName || "");
|
||||
const [newUsername, setNewUsername] = useState(user?.username || "");
|
||||
@@ -260,6 +267,44 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
|
||||
{/* 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
|
||||
is presentation and nothing else. */}
|
||||
<div style={{ marginTop: 22 }}>
|
||||
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
|
||||
Note typeface
|
||||
</label>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||
{[
|
||||
["serif", "Serif", "'Newsreader',Georgia,serif"],
|
||||
["sans", "Sans", "'DM Sans',system-ui,sans-serif"],
|
||||
["mono", "Mono", "ui-monospace,Menlo,Consolas,monospace"],
|
||||
].map(([value, label, stack]) => {
|
||||
const active = (user.preferences?.noteFont || "serif") === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
data-note-font={value}
|
||||
onClick={() => chooseFont(value)}
|
||||
style={{
|
||||
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",
|
||||
fontFamily: stack,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -561,6 +561,66 @@ export const I = {
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
),
|
||||
alignLeft: (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="14" y2="12" />
|
||||
<line x1="3" y1="18" x2="18" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
alignCenter: (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="7" y1="12" x2="17" y2="12" />
|
||||
<line x1="5" y1="18" x2="19" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
alignRight: (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="10" y1="12" x2="21" y2="12" />
|
||||
<line x1="6" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
alignJustify: (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
dots: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="5" cy="12" r="1.7" />
|
||||
|
||||
@@ -328,7 +328,7 @@ 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` | 17 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
|
||||
| `editor` | 31 | 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` | 7 | strokes survive the poller, and match across four viewport widths |
|
||||
@@ -369,6 +369,44 @@ 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.
|
||||
|
||||
**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 —
|
||||
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
|
||||
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.
|
||||
|
||||
The drawing still covers only the first page. On a PDF the strokes are kept per
|
||||
page; on a note there is one sheet.
|
||||
|
||||
### Alignment
|
||||
|
||||
Markdown is often said to "basically be HTML". It is not — it allows *embedded*
|
||||
HTML, and has no alignment of its own. So alignment works fully in the app, in
|
||||
print and in the PDF, and in a `.md` file it can only travel as a tag:
|
||||
|
||||
- Non-left blocks are wrapped in `<div align="…">`, the one form GitHub and most
|
||||
other readers still honour. A reader that does not know it shows the text,
|
||||
which is the right way to fail.
|
||||
- Left is never written. It is the default, and wrapping every paragraph in a div
|
||||
would turn an ordinary note into markup for nothing.
|
||||
- The importer reads the wrapper back and puts the alignment **on the
|
||||
paragraphs**, which is where the editor keeps it — not on a container.
|
||||
|
||||
## Interface language
|
||||
|
||||
English throughout. It had drifted into two languages — the frame in English,
|
||||
|
||||
Reference in new issue
Block a user