TipTap editor, version history, search, attachments — and a cleanup

The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.

Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
  missing; it refuses to start. The old fallback silently made every stored
  note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
  not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
  already eaten drawing strokes and note links; sync.hasPendingWrites() now
  guards every path that takes a body from the server.

Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
  to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
  into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
  encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
  fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
  the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.

Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
  covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
  unreadable.

Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
  search, layout, history and drag each live behind a hook, and the four
  panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
  the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
  restoring a history entry pushed a duplicate one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-07 17:00:14 +02:00
1 parent d97591b242
commit 92d525b3ab
71 files changed
+14354 -1826

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.env
.env.*
!.env.example
backend/data/
backend/uploads/
/data/
+672 -102
View File
@@ -35,8 +35,36 @@ const http = require("http");
/* ─────────── Config ─────────── */ /* ─────────── Config ─────────── */
const PORT = process.env.PORT || 4000; const PORT = process.env.PORT || 4000;
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-" + crypto.randomBytes(16).toString("hex");
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || crypto.randomBytes(32).toString("hex"); /* Secrets must be supplied. They used to fall back to a fresh random value,
* which meant a restart without them silently made every stored note
* undecryptable — the data was still there, just lost forever. Refuse to boot
* instead, and say exactly how to generate what is missing. */
function requireSecret(name, { hexBytes } = {}) {
const v = process.env[name];
if (!v) {
const how = hexBytes ? `openssl rand -hex ${hexBytes}` : "openssl rand -base64 32";
console.error(`\n ✖ ${name} is not set. Refusing to start.`);
console.error(` Generate one with: ${how}`);
console.error(` Then put it in .env or pass it as an environment variable.\n`);
process.exit(1);
}
if (hexBytes && !new RegExp(`^[0-9a-fA-F]{${hexBytes * 2}}$`).test(v)) {
console.error(`\n ✖ ${name} must be ${hexBytes * 2} hex characters (${hexBytes} bytes).`);
console.error(` Generate one with: openssl rand -hex ${hexBytes}\n`);
process.exit(1);
}
return v;
}
const JWT_SECRET = requireSecret("JWT_SECRET");
const ENCRYPTION_KEY = requireSecret("ENCRYPTION_KEY", { hexBytes: 32 });
const ENCRYPTION_KEY_BUF = Buffer.from(ENCRYPTION_KEY, "hex");
// Which origins may call the API. Same-origin deployments (nginx in front) need
// none; set a comma-separated list only if you serve the UI from another host.
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "")
.split(",").map((s) => s.trim()).filter(Boolean);
const DATA_DIR = path.join(__dirname, "data"); const DATA_DIR = path.join(__dirname, "data");
const UPLOAD_DIR = path.join(__dirname, "uploads"); const UPLOAD_DIR = path.join(__dirname, "uploads");
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -50,7 +78,7 @@ db.pragma("foreign_keys = ON");
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL, username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
display_name TEXT, display_name TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
@@ -110,30 +138,128 @@ db.exec(`
last_sync TEXT DEFAULT (datetime('now')) last_sync TEXT DEFAULT (datetime('now'))
); );
-- Which note references which. Note bodies are encrypted, so the server
-- cannot derive this itself; the client sends the link list when it saves.
CREATE TABLE IF NOT EXISTS note_links (
source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_id TEXT NOT NULL,
target_kind TEXT NOT NULL DEFAULT 'note',
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (source_id, target_id, target_kind)
);
-- Past states of a note, so an accidental overwrite can be undone after the
-- fact. Content is encrypted here exactly as it is in the notes table.
CREATE TABLE IF NOT EXISTS note_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT,
content_encrypted BLOB,
drawing_strokes BLOB,
device_id TEXT,
reason TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- Binary blobs referenced from note bodies. Stored on disk under a content
-- hash, so the same image pasted into ten notes is kept once.
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sha256 TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
filename TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id); CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_owner_hash ON attachments(user_id, sha256);
CREATE INDEX IF NOT EXISTS idx_note_versions ON note_versions(note_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_notes_notebook ON notes(notebook_id); CREATE INDEX IF NOT EXISTS idx_notes_notebook ON notes(notebook_id);
CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_id); CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_id);
CREATE INDEX IF NOT EXISTS idx_sync_user ON sync_log(user_id, timestamp); CREATE INDEX IF NOT EXISTS idx_sync_user ON sync_log(user_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(user_id, target_id);
CREATE INDEX IF NOT EXISTS idx_note_links_source ON note_links(source_id);
`); `);
/* ─────────── Migrations ─────────── */
const tableColumns = (t) => db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/;
function normalizeUsername(raw) {
const v = String(raw ?? "").trim();
return USERNAME_RE.test(v) ? v : null;
}
// 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() {
const cols = tableColumns("users");
if (!cols.includes("email")) return;
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.exec(`
CREATE TABLE users_migrated (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
display_name TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
`);
const insert = db.prepare(
"INSERT INTO users_migrated (id, username, password_hash, display_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
);
const taken = new Set();
for (const u of db.prepare("SELECT * FROM users").all()) {
const seed = String(u.email || u.display_name || "user").split("@")[0];
const base = (seed.replace(/[^a-zA-Z0-9._-]/g, "").slice(0, 32) || "user").padEnd(3, "0");
let name = base;
for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${base.slice(0, 30)}${i}`;
taken.add(name.toLowerCase());
insert.run(u.id, name, u.password_hash, u.display_name || name, u.created_at, u.updated_at);
console.log(` ↪ migrated login: ${u.email} → username "${name}"`);
}
db.exec("DROP TABLE users; ALTER TABLE users_migrated RENAME TO users;");
})();
db.pragma("foreign_keys = ON");
console.log(" ✅ users table migrated from e-mail to username login");
})();
// Drawings are stored as resolution-independent vector strokes. The legacy
// raster path (drawing_data_path) stays readable for notes drawn before this.
if (!tableColumns("notes").includes("drawing_strokes")) {
db.exec("ALTER TABLE notes ADD COLUMN drawing_strokes BLOB");
}
/* ─────────── Encryption helpers ─────────── */ /* ─────────── Encryption helpers ─────────── */
/* AES-256-GCM. CBC gave confidentiality but no integrity — a flipped bit in the
* ciphertext decrypted to garbage instead of failing loudly. GCM authenticates,
* so tampering and corruption are detected. Records written before this are
* still CBC and stay readable; they re-encrypt as GCM on their next write. */
function encrypt(text) { function encrypt(text) {
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(12);
const key = Buffer.from(ENCRYPTION_KEY, "hex"); const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); const enc = Buffer.concat([cipher.update(String(text), "utf8"), cipher.final()]);
let encrypted = cipher.update(text, "utf8", "hex"); return `v2:${iv.toString("hex")}:${cipher.getAuthTag().toString("hex")}:${enc.toString("hex")}`;
encrypted += cipher.final("hex");
return iv.toString("hex") + ":" + encrypted;
} }
function decrypt(data) { function decrypt(data) {
const [ivHex, encryptedHex] = data.split(":"); const s = String(data);
const iv = Buffer.from(ivHex, "hex"); if (s.startsWith("v2:")) {
const key = Buffer.from(ENCRYPTION_KEY, "hex"); const [, ivHex, tagHex, encHex] = s.split(":");
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, Buffer.from(ivHex, "hex"));
let decrypted = decipher.update(encryptedHex, "hex", "utf8"); decipher.setAuthTag(Buffer.from(tagHex, "hex"));
decrypted += decipher.final("utf8"); return decipher.update(encHex, "hex", "utf8") + decipher.final("utf8");
return decrypted; }
const [ivHex, encryptedHex] = s.split(":"); // legacy AES-256-CBC
const decipher = crypto.createDecipheriv("aes-256-cbc", ENCRYPTION_KEY_BUF, Buffer.from(ivHex, "hex"));
return decipher.update(encryptedHex, "hex", "utf8") + decipher.final("utf8");
} }
function detectImageMime(buffer, filePath = "") { function detectImageMime(buffer, filePath = "") {
@@ -188,9 +314,44 @@ function authMiddleware(req, res, next) {
/* ─────────── Express app ─────────── */ /* ─────────── Express app ─────────── */
const app = express(); const app = express();
app.set("trust proxy", 1); // nginx sits in front; needed for real client IPs
app.use(helmet()); app.use(helmet());
app.use(cors()); app.use(cors(ALLOWED_ORIGINS.length ? { origin: ALLOWED_ORIGINS } : { origin: false }));
app.use(express.json({ limit: "50mb" })); app.use(express.json({ limit: "25mb" }));
/* Password checks are deliberately slow, which makes them both a brute-force
* defence and a denial-of-service lever. Cap attempts per username+IP. */
const RATE_WINDOW_MS = 15 * 60 * 1000;
const RATE_MAX_ATTEMPTS = 10;
const loginAttempts = new Map(); // key -> { count, resetAt }
function rateLimitLogin(req, res, next) {
const key = `${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`;
const now = Date.now();
const entry = loginAttempts.get(key);
if (!entry || now > entry.resetAt) {
loginAttempts.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });
return next();
}
if (entry.count >= RATE_MAX_ATTEMPTS) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
res.set("Retry-After", String(retryAfter));
return res.status(429).json({ error: "Too many attempts. Try again later.", retryAfter });
}
entry.count++;
next();
}
const clearLoginAttempts = (req) => {
loginAttempts.delete(`${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`);
};
setInterval(() => {
const now = Date.now();
for (const [k, v] of loginAttempts) if (now > v.resetAt) loginAttempts.delete(k);
}, RATE_WINDOW_MS).unref();
// A dummy hash keeps "no such user" and "wrong password" equally slow, so the
// response time does not reveal which usernames exist.
const DUMMY_HASH = bcrypt.hashSync("not-a-real-password", 12);
const upload = multer({ const upload = multer({
dest: UPLOAD_DIR, dest: UPLOAD_DIR,
@@ -202,66 +363,88 @@ const upload = multer({
}); });
/* ─── Auth routes ─── */ /* ─── Auth routes ─── */
app.post("/api/auth/register", (req, res) => { app.post("/api/auth/register", async (req, res, next) => {
const { email, password, displayName } = req.body; try {
if (!email || !password) return res.status(400).json({ error: "Email and password required" }); const { username, password, displayName } = req.body;
const name = normalizeUsername(username);
if (!name || !password) return res.status(400).json({ error: "Username and password required" });
if (String(password).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email); const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(name);
if (existing) return res.status(409).json({ error: "Email already registered" }); if (existing) return res.status(409).json({ error: "Username already taken" });
const id = uuid(); const id = uuid();
const hash = bcrypt.hashSync(password, 12); const hash = await bcrypt.hash(password, 12);
db.prepare("INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, email, hash, displayName || email.split("@")[0]); try {
db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, name, hash, displayName || name);
} catch (err) {
// Someone claimed the name between the check and the insert.
if (String(err.message).includes("UNIQUE")) return res.status(409).json({ error: "Username already taken" });
throw err;
}
const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" }); const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" });
res.json({ token, user: { id, email, displayName: displayName || email.split("@")[0] } }); res.json({ token, user: { id, username: name, displayName: displayName || name } });
} catch (err) { next(err); }
}); });
app.post("/api/auth/login", (req, res) => { app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
const { email, password } = req.body; try {
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email); const { username, password } = req.body;
if (!user || !bcrypt.compareSync(password, user.password_hash)) { const name = String(username ?? "").trim();
return res.status(401).json({ error: "Invalid credentials" }); 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 token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" }); const ok = await bcrypt.compare(String(password ?? ""), user ? user.password_hash : DUMMY_HASH);
res.json({ token, user: { id: user.id, email: user.email, displayName: user.display_name } }); if (!user || !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: { id: user.id, username: user.username, displayName: user.display_name } });
} catch (err) { next(err); }
}); });
/* ─── Auth profile routes ─── */ /* ─── Auth profile routes ─── */
app.get("/api/auth/me", authMiddleware, (req, res) => { app.get("/api/auth/me", authMiddleware, (req, res) => {
const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId); const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId);
if (!user) return res.status(404).json({ error: "User not found" }); if (!user) return res.status(404).json({ error: "User not found" });
res.json({ id: user.id, email: user.email, displayName: user.display_name }); res.json({ id: user.id, username: user.username, displayName: user.display_name });
}); });
app.put("/api/auth/profile", authMiddleware, (req, res) => { app.put("/api/auth/profile", authMiddleware, (req, res) => {
const { displayName } = req.body; const { displayName } = req.body;
if (!displayName || !displayName.trim()) return res.status(400).json({ error: "Display name required" }); if (!displayName || !displayName.trim()) return res.status(400).json({ error: "Display name required" });
db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(displayName.trim(), req.userId); db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(displayName.trim(), req.userId);
const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId); const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId);
res.json({ id: user.id, email: user.email, displayName: user.display_name }); res.json({ id: user.id, username: user.username, displayName: user.display_name });
}); });
app.put("/api/auth/email", authMiddleware, (req, res) => { app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
const { newEmail, password } = req.body; try {
if (!newEmail || !password) return res.status(400).json({ error: "Email and password required" }); const { newUsername, password } = req.body;
const name = normalizeUsername(newUsername);
if (!name || !password) return res.status(400).json({ error: "Username and password required" });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
if (!bcrypt.compareSync(password, user.password_hash)) return res.status(401).json({ error: "Wrong password" }); if (!user) return res.status(404).json({ error: "User not found" });
const exists = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(newEmail, req.userId); if (!(await bcrypt.compare(String(password), user.password_hash))) return res.status(401).json({ error: "Wrong password" });
if (exists) return res.status(409).json({ error: "Email already in use" }); const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId);
db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?").run(newEmail, req.userId); if (exists) return res.status(409).json({ error: "Username already taken" });
res.json({ id: user.id, email: newEmail, displayName: user.display_name }); db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(name, req.userId);
res.json({ id: user.id, username: name, displayName: user.display_name });
} catch (err) { next(err); }
}); });
app.put("/api/auth/password", authMiddleware, (req, res) => { app.put("/api/auth/password", authMiddleware, async (req, res, next) => {
const { currentPassword, newPassword } = req.body; try {
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" }); const { currentPassword, newPassword } = req.body;
if (newPassword.length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" }); if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); if (String(newPassword).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
if (!bcrypt.compareSync(currentPassword, user.password_hash)) return res.status(401).json({ error: "Current password is wrong" }); const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
const hash = bcrypt.hashSync(newPassword, 12); if (!user) return res.status(404).json({ error: "User not found" });
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId); if (!(await bcrypt.compare(String(currentPassword), user.password_hash))) return res.status(401).json({ error: "Current password is wrong" });
res.json({ success: true }); const hash = await bcrypt.hash(String(newPassword), 12);
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId);
res.json({ success: true });
} catch (err) { next(err); }
}); });
/* ─── Notebook routes ─── */ /* ─── Notebook routes ─── */
@@ -301,6 +484,61 @@ app.delete("/api/notebooks/:id", authMiddleware, (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
/* ─────────── Version history ───────────
*
* A snapshot records what a note looked like *before* a write, so the previous
* state is always recoverable.
*
* Saves arrive roughly once per second while typing, so snapshotting every one
* would be useless noise. Instead they coalesce: within the window below, the
* existing newest version already represents "before this editing session".
* A write that shrinks a note sharply bypasses the window, because that is
* exactly the case worth being able to undo. */
const num = (name, fallback) => {
const v = Number(process.env[name]);
return Number.isFinite(v) && v > 0 ? v : fallback;
};
const VERSION_COALESCE_MS = num("VERSION_COALESCE_MINUTES", 5) * 60 * 1000;
const MAX_VERSIONS_PER_NOTE = num("MAX_VERSIONS_PER_NOTE", 50);
const VERSION_RETENTION_DAYS = num("VERSION_RETENTION_DAYS", 90);
const sqliteTimeToMs = (t) => (t ? Date.parse(`${String(t).replace(" ", "T")}Z`) : 0);
function pruneNoteVersions(noteId) {
db.prepare(`
DELETE FROM note_versions
WHERE note_id = ? AND id NOT IN (
SELECT id FROM note_versions WHERE note_id = ? ORDER BY id DESC LIMIT ?
)`).run(noteId, noteId, MAX_VERSIONS_PER_NOTE);
}
function snapshotNote(userId, noteId, deviceId, { force = false, reason = "edit" } = {}) {
const note = db.prepare(
"SELECT title, content_encrypted, drawing_strokes FROM notes WHERE id = ? AND user_id = ?"
).get(noteId, userId);
if (!note) return;
// An empty note has nothing worth going back to.
if (!note.content_encrypted && !note.drawing_strokes) return;
if (!force) {
const last = db.prepare("SELECT created_at FROM note_versions WHERE note_id = ? ORDER BY id DESC LIMIT 1").get(noteId);
if (last && Date.now() - sqliteTimeToMs(last.created_at) < VERSION_COALESCE_MS) return;
}
db.prepare(`INSERT INTO note_versions (note_id, user_id, title, content_encrypted, drawing_strokes, device_id, reason)
VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(noteId, userId, note.title, note.content_encrypted, note.drawing_strokes, deviceId, reason);
pruneNoteVersions(noteId);
}
/* Ciphertext length tracks plaintext length closely enough to spot a write that
* throws most of a note away, without decrypting to find out. */
function isBigShrink(previous, next) {
const before = previous ? previous.length : 0;
const after = next ? next.length : 0;
return before > 200 && after < before * 0.5;
}
/* ─── Note routes ─── */ /* ─── Note routes ─── */
app.get("/api/notes", authMiddleware, (req, res) => { app.get("/api/notes", authMiddleware, (req, res) => {
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, version, vector_clock 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, type, title, starred, tags, sort_order, created_at, updated_at, version, vector_clock FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
@@ -316,17 +554,33 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => {
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); 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" }); if (!note) return res.status(404).json({ error: "Not found" });
// Decrypt content /* A failed decrypt used to be reported as empty content. The client then
* treated the note as blank and the next edit overwrote the real text — a
* read error turned into permanent data loss. Fail the request instead, so
* the client keeps showing its local copy. */
if (note.content_encrypted) { if (note.content_encrypted) {
try { try {
note.content = decrypt(note.content_encrypted.toString()); note.content = decrypt(note.content_encrypted.toString());
} catch { } catch (err) {
note.content = ""; console.error(`[decrypt] note ${note.id} content unreadable:`, err.message);
return res.status(500).json({ error: "Note content could not be decrypted", id: note.id });
} }
} }
delete note.content_encrypted; delete note.content_encrypted;
// Read drawing data from file if exists // Vector strokes are the source of truth for drawings.
note.drawingStrokes = null;
if (note.drawing_strokes) {
try {
note.drawingStrokes = JSON.parse(decrypt(note.drawing_strokes.toString()));
} catch (err) {
console.error(`[decrypt] note ${note.id} strokes unreadable:`, err.message);
return res.status(500).json({ error: "Note drawing could not be decrypted", id: note.id });
}
}
delete note.drawing_strokes;
// Legacy raster drawing — only for notes created before vector strokes existed.
if (note.drawing_data_path && fs.existsSync(note.drawing_data_path)) { if (note.drawing_data_path && fs.existsSync(note.drawing_data_path)) {
try { try {
const raw = fs.readFileSync(note.drawing_data_path); const raw = fs.readFileSync(note.drawing_data_path);
@@ -359,8 +613,24 @@ app.post("/api/notes", authMiddleware, (req, res) => {
res.json({ id }); res.json({ id });
}); });
/* Replaces a note's outgoing links. Called whenever its body is saved. */
const MAX_LINKS_PER_NOTE = 500;
const replaceNoteLinks = db.transaction((userId, sourceId, links) => {
db.prepare("DELETE FROM note_links WHERE source_id = ?").run(sourceId);
const insert = db.prepare(
"INSERT OR IGNORE INTO note_links (source_id, target_id, target_kind, user_id) VALUES (?, ?, ?, ?)"
);
for (const link of links.slice(0, MAX_LINKS_PER_NOTE)) {
const targetId = typeof link === "string" ? link : link?.id;
const kind = (typeof link === "object" && link?.kind) === "notebook" ? "notebook" : "note";
if (typeof targetId === "string" && targetId && targetId !== sourceId) {
insert.run(sourceId, targetId, kind, userId);
}
}
});
app.put("/api/notes/:id", authMiddleware, (req, res) => { app.put("/api/notes/:id", authMiddleware, (req, res) => {
const { title, content, notebookId, starred, tags, vectorClock } = req.body; const { title, content, notebookId, starred, tags, vectorClock, links } = req.body;
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); 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" }); if (!note) return res.status(404).json({ error: "Not found" });
@@ -381,6 +651,15 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
const encContent = content ? encrypt(content) : note.content_encrypted; const encContent = content ? encrypt(content) : note.content_encrypted;
const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 }; const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 };
// Keep the state we are about to replace.
if (content !== undefined) {
const shrank = isBigShrink(note.content_encrypted, encContent);
snapshotNote(req.userId, req.params.id, req.deviceId, {
force: shrank,
reason: shrank ? "large-deletion" : "edit",
});
}
db.prepare(`UPDATE notes SET db.prepare(`UPDATE notes SET
title = COALESCE(?, title), title = COALESCE(?, title),
content_encrypted = COALESCE(?, content_encrypted), content_encrypted = COALESCE(?, content_encrypted),
@@ -394,10 +673,106 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
WHERE id = ?`) WHERE id = ?`)
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, tags ? JSON.stringify(tags) : null, JSON.stringify(newClock), req.deviceId, req.params.id); .run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, tags ? JSON.stringify(tags) : null, JSON.stringify(newClock), req.deviceId, req.params.id);
if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links);
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId); broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId);
res.json({ success: true, vectorClock: newClock }); res.json({ success: true, vectorClock: newClock });
}); });
/* ─── Version history ─── */
app.get("/api/notes/:id/versions", authMiddleware, (req, res) => {
const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!note) return res.status(404).json({ error: "Not found" });
// Sizes only — sending every past body would be a large, mostly unread payload.
const rows = db.prepare(`
SELECT id, title, device_id, reason, created_at,
LENGTH(content_encrypted) AS content_size,
drawing_strokes IS NOT NULL AS has_drawing
FROM note_versions WHERE note_id = ? ORDER BY id DESC
`).all(req.params.id);
res.json(rows.map((r) => ({ ...r, has_drawing: !!r.has_drawing })));
});
app.get("/api/notes/:id/versions/:versionId", authMiddleware, (req, res) => {
const v = db.prepare("SELECT * FROM note_versions WHERE id = ? AND note_id = ? AND user_id = ?")
.get(req.params.versionId, req.params.id, req.userId);
if (!v) return res.status(404).json({ error: "Not found" });
const out = { id: v.id, title: v.title, created_at: v.created_at, device_id: v.device_id, reason: v.reason };
try {
out.content = v.content_encrypted ? decrypt(v.content_encrypted.toString()) : "";
out.drawingStrokes = v.drawing_strokes ? JSON.parse(decrypt(v.drawing_strokes.toString())) : null;
} catch (err) {
console.error(`[decrypt] version ${v.id} unreadable:`, err.message);
return res.status(500).json({ error: "This version could not be decrypted" });
}
res.json(out);
});
/* Restoring is itself an edit, so the state being replaced is snapshotted first
* — restoring the wrong version is never a one-way door. */
app.post("/api/notes/:id/versions/:versionId/restore", authMiddleware, (req, res) => {
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" });
const v = db.prepare("SELECT * FROM note_versions WHERE id = ? AND note_id = ? AND user_id = ?")
.get(req.params.versionId, req.params.id, req.userId);
if (!v) return res.status(404).json({ error: "Version not found" });
snapshotNote(req.userId, req.params.id, req.deviceId, { force: true, reason: "before-restore" });
db.prepare(`UPDATE notes SET content_encrypted = ?, drawing_strokes = ?,
updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?`)
.run(v.content_encrypted, v.drawing_strokes, req.deviceId, req.params.id);
// Links live in the restored body, so the index has to follow it back.
let links = [];
try {
if (v.content_encrypted) {
const doc = JSON.parse(decrypt(v.content_encrypted.toString()))?.doc;
const found = new Set();
(function walk(n) {
if (!n) return;
if (n.type === "noteLink" && n.attrs?.noteId) found.add(n.attrs.noteId);
if (Array.isArray(n.content)) n.content.forEach(walk);
})(doc);
links = [...found];
}
} catch { /* a v1 body has no links to recover */ }
replaceNoteLinks(req.userId, req.params.id, links);
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)")
.run(req.userId, req.params.id, JSON.stringify({ restored: v.id }), req.deviceId);
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, restored: true } }, req.deviceId);
res.json({ success: true, restoredFrom: v.created_at });
});
/* Notes that reference this one. Trashed sources are excluded — a backlink from
* something in the bin is noise. */
app.get("/api/notes/:id/backlinks", authMiddleware, (req, res) => {
const rows = db.prepare(`
SELECT n.id, n.title, n.notebook_id, n.updated_at, nb.name AS notebook_name
FROM note_links l
JOIN notes n ON n.id = l.source_id
LEFT JOIN notebooks nb ON nb.id = n.notebook_id
WHERE l.target_id = ? AND l.user_id = ? AND n.deleted_at IS NULL
ORDER BY n.updated_at DESC
`).all(req.params.id, req.userId);
res.json(rows);
});
/* Every link in one round trip, so the client can render backlink counts in a
* list without asking per note. */
app.get("/api/links", authMiddleware, (req, res) => {
const rows = db.prepare(`
SELECT l.source_id, l.target_id, l.target_kind
FROM note_links l
JOIN notes n ON n.id = l.source_id
WHERE l.user_id = ? AND n.deleted_at IS NULL
`).all(req.userId);
res.json(rows);
});
/* ─── Trash batch endpoint (MUST be before :id routes) ─── */ /* ─── Trash batch endpoint (MUST be before :id routes) ─── */
app.delete("/api/notes/trash/empty", authMiddleware, (req, res) => { app.delete("/api/notes/trash/empty", authMiddleware, (req, res) => {
db.prepare("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL").run(req.userId); db.prepare("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL").run(req.userId);
@@ -425,30 +800,55 @@ app.delete("/api/notes/:id/permanent", authMiddleware, (req, res) => {
}); });
/* ─── Drawing data route ─── */ /* ─── Drawing data route ─── */
const MAX_STROKES = 20000;
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => { app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
const { drawingData } = req.body; const { drawingStrokes, drawingData } = req.body;
const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!note) return res.status(404).json({ error: "Not found" }); if (!note) return res.status(404).json({ error: "Not found" });
if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingData required" }); if (drawingStrokes !== undefined) {
const m = drawingData.match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/); // Vector strokes in the note's canonical coordinate space — the current format.
if (!m) return res.status(400).json({ error: "Invalid drawing format" }); if (drawingStrokes !== null) {
const mime = m[1]; if (typeof drawingStrokes !== "object" || !Array.isArray(drawingStrokes.strokes))
const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"; return res.status(400).json({ error: "Invalid strokes format" });
const base64Data = m[2]; if (drawingStrokes.strokes.length > MAX_STROKES)
const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${ext}`); return res.status(413).json({ error: "Too many strokes" });
// Remove older file variants from previous format writes.
for (const oldExt of ["png", "jpg", "jpeg", "webp"]) {
const oldPath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${oldExt}`);
if (oldPath !== filePath && fs.existsSync(oldPath)) {
try { fs.unlinkSync(oldPath); } catch {}
} }
const empty = !drawingStrokes || drawingStrokes.strokes.length === 0;
const previous = db.prepare("SELECT drawing_strokes FROM notes WHERE id = ?").get(req.params.id);
// Clearing a drawing is always worth being able to undo.
snapshotNote(req.userId, req.params.id, req.deviceId, {
force: empty && !!previous?.drawing_strokes,
reason: empty ? "drawing-cleared" : "drawing",
});
// A note's legacy raster is left untouched — it renders underneath the strokes
// on the client, so migrating an old drawing never loses anything.
db.prepare("UPDATE notes SET drawing_strokes = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?")
.run(empty ? null : Buffer.from(encrypt(JSON.stringify(drawingStrokes))), req.deviceId, req.params.id);
} else {
// Legacy raster path, kept so older clients keep working.
if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingStrokes or drawingData required" });
const m = drawingData.match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/);
if (!m) return res.status(400).json({ error: "Invalid drawing format" });
const mime = m[1];
const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg";
const base64Data = m[2];
const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${ext}`);
// Remove older file variants from previous format writes.
for (const oldExt of ["png", "jpg", "jpeg", "webp"]) {
const oldPath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${oldExt}`);
if (oldPath !== filePath && fs.existsSync(oldPath)) {
try { fs.unlinkSync(oldPath); } catch {}
}
}
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?")
.run(filePath, req.deviceId, req.params.id);
} }
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?")
.run(filePath, req.deviceId, req.params.id);
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)") db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)")
.run(req.userId, req.params.id, JSON.stringify({ drawingUpdated: true }), req.deviceId); .run(req.userId, req.params.id, JSON.stringify({ drawingUpdated: true }), req.deviceId);
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, drawingUpdated: true } }, req.deviceId); broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, drawingUpdated: true } }, req.deviceId);
@@ -456,6 +856,69 @@ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
/* ─── Attachments ───
*
* Images used to be inlined into the note body as base64 data URIs: a few
* screenshots turned a note into megabytes that were re-encrypted and re-sent on
* every keystroke. They are now separate blobs the body only references.
*
* Files are named by SHA-256 of their bytes, so the same image pasted into
* several notes is stored once. */
const ATTACHMENT_DIR = path.join(UPLOAD_DIR, "attachments");
if (!fs.existsSync(ATTACHMENT_DIR)) fs.mkdirSync(ATTACHMENT_DIR, { recursive: true });
const ALLOWED_ATTACHMENT_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]);
const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
app.post("/api/attachments", authMiddleware, (req, res) => {
const { data, filename } = req.body || {};
if (typeof data !== "string") return res.status(400).json({ error: "data required" });
const m = data.match(/^data:([a-z0-9.+/-]+);base64,(.+)$/i);
if (!m) return res.status(400).json({ error: "Expected a base64 data URL" });
const mime = m[1].toLowerCase();
if (!ALLOWED_ATTACHMENT_MIME.has(mime)) return res.status(415).json({ error: `Unsupported type: ${mime}` });
const buf = Buffer.from(m[2], "base64");
if (buf.length === 0) return res.status(400).json({ error: "Empty file" });
if (buf.length > MAX_ATTACHMENT_BYTES) return res.status(413).json({ error: "File too large" });
// Trust the bytes, not the declared type.
const sniffed = detectImageMime(buf);
if (mime !== "image/gif" && mime !== "image/avif" && sniffed !== mime) {
return res.status(415).json({ error: "File content does not match its type" });
}
const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
const existing = db.prepare("SELECT id, mime, size FROM attachments WHERE user_id = ? AND sha256 = ?").get(req.userId, sha256);
if (existing) return res.json({ id: existing.id, mime: existing.mime, size: existing.size, deduplicated: true });
const filePath = path.join(ATTACHMENT_DIR, sha256);
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, buf);
const id = uuid();
db.prepare("INSERT INTO attachments (id, user_id, sha256, mime, size, filename) VALUES (?, ?, ?, ?, ?, ?)")
.run(id, req.userId, sha256, mime, buf.length, typeof filename === "string" ? filename.slice(0, 200) : null);
res.json({ id, mime, size: buf.length, deduplicated: false });
});
app.get("/api/attachments/:id", authMiddleware, (req, res) => {
const a = db.prepare("SELECT * FROM attachments WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!a) return res.status(404).json({ error: "Not found" });
// The hash comes from our own database, never from the request, so this path
// cannot be steered outside the attachment directory.
const filePath = path.join(ATTACHMENT_DIR, a.sha256);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: "File missing" });
res.setHeader("Content-Type", a.mime);
res.setHeader("Content-Length", a.size);
res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); // content-addressed: never changes
res.setHeader("X-Content-Type-Options", "nosniff");
fs.createReadStream(filePath).pipe(res);
});
/* ─── PDF upload route ─── */ /* ─── PDF upload route ─── */
app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => { app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => {
if (!req.file) return res.status(400).json({ error: "No file uploaded" }); if (!req.file) return res.status(400).json({ error: "No file uploaded" });
@@ -528,42 +991,68 @@ const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: "/ws" }); const wss = new WebSocketServer({ server, path: "/ws" });
const userConnections = new Map(); // userId -> Set<ws> const userConnections = new Map(); // userId -> Set<ws>
wss.on("connection", (ws, req) => { const AUTH_GRACE_MS = 10000;
let userId = null; const HEARTBEAT_MS = 30000;
wss.on("connection", (ws) => {
ws._userId = null;
ws._alive = true;
ws.on("pong", () => { ws._alive = true; });
// Drop sockets that connect and never authenticate.
const authTimer = setTimeout(() => {
if (!ws._userId) ws.close(4001, "auth timeout");
}, AUTH_GRACE_MS);
ws.on("message", (data) => { ws.on("message", (data) => {
try { try {
const msg = JSON.parse(data); const msg = JSON.parse(data);
if (msg.type === "auth") { if (msg.type !== "auth") return;
const payload = jwt.verify(msg.token, JWT_SECRET); const payload = jwt.verify(msg.token, JWT_SECRET);
userId = payload.userId; ws._userId = payload.userId;
const deviceId = msg.deviceId || "unknown"; ws._deviceId = msg.deviceId || "unknown";
clearTimeout(authTimer);
if (!userConnections.has(userId)) userConnections.set(userId, new Map()); // A Set, not a Map keyed by device: two tabs on one machine share a
userConnections.get(userId).set(deviceId, ws); // device id, and keying by it meant the second tab silently replaced the
ws._deviceId = deviceId; // first and the first stopped receiving updates.
if (!userConnections.has(ws._userId)) userConnections.set(ws._userId, new Set());
userConnections.get(ws._userId).add(ws);
ws.send(JSON.stringify({ type: "auth_ok" })); ws.send(JSON.stringify({ type: "auth_ok" }));
}
} catch { } catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); try { ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); } catch {}
} }
}); });
ws.on("close", () => { ws.on("close", () => {
if (userId && userConnections.has(userId)) { clearTimeout(authTimer);
userConnections.get(userId).delete(ws._deviceId); const set = ws._userId && userConnections.get(ws._userId);
if (userConnections.get(userId).size === 0) userConnections.delete(userId); if (!set) return;
} set.delete(ws);
if (set.size === 0) userConnections.delete(ws._userId);
}); });
ws.on("error", () => { try { ws.terminate(); } catch {} });
}); });
// Reap sockets that stopped answering — otherwise half-open connections from
// sleeping phones accumulate forever.
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (!ws._alive) { ws.terminate(); continue; }
ws._alive = false;
try { ws.ping(); } catch {}
}
}, HEARTBEAT_MS);
heartbeat.unref();
function broadcastToUser(userId, message, excludeDeviceId) { function broadcastToUser(userId, message, excludeDeviceId) {
if (!userConnections.has(userId)) return; const conns = userConnections.get(userId);
const connections = userConnections.get(userId); if (!conns) return;
for (const [deviceId, ws] of connections) { const payload = JSON.stringify(message);
if (deviceId !== excludeDeviceId && ws.readyState === 1) { for (const ws of conns) {
ws.send(JSON.stringify(message)); if (ws._deviceId !== excludeDeviceId && ws.readyState === 1) {
try { ws.send(payload); } catch {}
} }
} }
} }
@@ -573,10 +1062,91 @@ app.get("/api/health", (req, res) => {
res.json({ status: "ok", version: "1.0.0", uptime: process.uptime() }); res.json({ status: "ok", version: "1.0.0", uptime: process.uptime() });
}); });
/* ─── Error handling ─── */
app.use((req, res) => res.status(404).json({ error: "Not found" }));
// Without this, a throw inside a route hangs the request until the client times
// out, and the stack trace goes to the client in Express' default handler.
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
console.error("[error]", req.method, req.path, err);
if (res.headersSent) return;
if (err?.type === "entity.too.large") return res.status(413).json({ error: "Payload too large" });
res.status(500).json({ error: "Internal server error" });
});
process.on("unhandledRejection", (err) => console.error("[unhandledRejection]", err));
process.on("uncaughtException", (err) => console.error("[uncaughtException]", err));
/* ─── Housekeeping ─── */
// sync_log is append-only and grew without bound; every note edit added a row.
const SYNC_LOG_RETENTION_DAYS = 30;
function pruneSyncLog() {
try {
const r = db.prepare(`DELETE FROM sync_log WHERE timestamp < datetime('now', '-${SYNC_LOG_RETENTION_DAYS} days')`).run();
if (r.changes) console.log(` 🧹 pruned ${r.changes} sync_log rows older than ${SYNC_LOG_RETENTION_DAYS} days`);
} catch (err) { console.error("[prune] failed:", err.message); }
}
/* Per-note count is capped on write; this only clears out versions of notes
* that stopped being edited long ago. */
function pruneOldVersions() {
try {
const r = db.prepare(`DELETE FROM note_versions WHERE created_at < datetime('now', '-${VERSION_RETENTION_DAYS} days')`).run();
if (r.changes) console.log(` 🧹 pruned ${r.changes} note versions older than ${VERSION_RETENTION_DAYS} days`);
} catch (err) { console.error("[prune] failed:", err.message); }
}
/* Attachment rows cascade away with their owner, but the files they point at do
* not. The database is authoritative: a blob nobody has a row for is garbage.
*
* Only files that have sat untouched for an hour are considered, so an upload
* in flight — written to disk just before its row is inserted — is never hit. */
const ORPHAN_GRACE_MS = 60 * 60 * 1000;
function pruneOrphanAttachments() {
try {
if (!fs.existsSync(ATTACHMENT_DIR)) return;
const referenced = new Set(db.prepare("SELECT sha256 FROM attachments").all().map((r) => r.sha256));
let removed = 0;
for (const name of fs.readdirSync(ATTACHMENT_DIR)) {
if (referenced.has(name)) continue;
const full = path.join(ATTACHMENT_DIR, name);
try {
if (Date.now() - fs.statSync(full).mtimeMs < ORPHAN_GRACE_MS) continue;
fs.unlinkSync(full);
removed++;
} catch { /* leave anything we cannot inspect alone */ }
}
if (removed) console.log(` 🧹 removed ${removed} orphaned attachment file(s)`);
} catch (err) { console.error("[prune] attachments failed:", err.message); }
}
const housekeeping = () => { pruneSyncLog(); pruneOldVersions(); pruneOrphanAttachments(); };
housekeeping();
setInterval(housekeeping, 24 * 60 * 60 * 1000).unref();
/* ─── Start server ─── */ /* ─── Start server ─── */
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`); console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`);
console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`); console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`);
console.log(` 🔒 JWT Secret: ${JWT_SECRET.slice(0, 12)}...`); console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}`);
console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}\n`); console.log(` 🌐 CORS: ${ALLOWED_ORIGINS.length ? ALLOWED_ORIGINS.join(", ") : "same-origin only"}\n`);
}); });
/* ─── Graceful shutdown ─── */
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`\n ${signal} received — shutting down...`);
clearInterval(heartbeat);
for (const ws of wss.clients) { try { ws.close(1001, "server shutting down"); } catch {} }
server.close(() => {
try { db.close(); } catch (err) { console.error("[shutdown] db.close:", err.message); }
console.log(" ✅ closed cleanly\n");
process.exit(0);
});
// Don't hang forever on a stuck connection.
setTimeout(() => { try { db.close(); } catch {} process.exit(1); }, 10000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
Executable → Regular
+1 -1
View File
@@ -2,7 +2,7 @@
set -e set -e
REGISTRY="gitea.honeyy.duckdns.org" REGISTRY="gitea.honeyy.duckdns.org"
OWNER="docker" OWNER="${GITEA_OWNER:-marc}"
TAG="${1:-latest}" TAG="${1:-latest}"
FRONTEND_IMAGE="${REGISTRY}/${OWNER}/inknote-web:${TAG}" FRONTEND_IMAGE="${REGISTRY}/${OWNER}/inknote-web:${TAG}"
+73
View File
@@ -0,0 +1,73 @@
# ─────────────────────────────────────────────
# InkNote — lokales Test-Setup (baut aus dem Quellcode)
#
# Prod-nah (nginx + gebautes Bundle) auf :3000
# docker compose -f docker-compose.dev.yml up -d --build
#
# Mit Hot-Reload-Dev-Server auf :5173 zusätzlich
# docker compose -f docker-compose.dev.yml --profile dev up -d --build
#
# docker compose -f docker-compose.dev.yml logs -f
# docker compose -f docker-compose.dev.yml down
#
# Secrets kommen aus .env (nicht committen!)
# Daten liegen unter ${INKNOTE_DATA_DIR:-./data} auf dem Host.
# ─────────────────────────────────────────────
services:
inknote-server:
build:
context: ./backend
dockerfile: Dockerfile
container_name: inknote-server-dev
restart: unless-stopped
environment:
- PORT=4000
- JWT_SECRET=${JWT_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- NODE_ENV=development
# 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}"
volumes:
- ${INKNOTE_DATA_DIR:-./data}/db:/app/data
- ${INKNOTE_DATA_DIR:-./data}/uploads:/app/uploads
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
inknote-web:
build:
context: ./frontend
dockerfile: dockerfile
container_name: inknote-web-dev
restart: unless-stopped
ports:
- "3000:80"
depends_on:
inknote-server:
condition: service_healthy
# Vite mit Hot-Reload — nur mit "--profile dev"
inknote-vite:
image: node:20-alpine
container_name: inknote-vite-dev
profiles: ["dev"]
working_dir: /app
command: sh -c "npm install && npm run dev"
environment:
- VITE_API_TARGET=http://inknote-server:4000
volumes:
- ./frontend:/app
- inknote-dev-node-modules:/app/node_modules
ports:
- "5173:5173"
depends_on:
inknote-server:
condition: service_healthy
volumes:
inknote-dev-node-modules:
+14 -4
View File
@@ -5,9 +5,15 @@ version: "3.8"
# Pull images from Gitea container registry # Pull images from Gitea container registry
# #
# Usage: # Usage:
# mkdir -p /srv/inknote/db /srv/inknote/uploads
# chown -R 1000:1000 /srv/inknote
# docker login gitea.honeyy.duckdns.org # docker login gitea.honeyy.duckdns.org
# docker-compose pull # docker compose pull
# docker-compose up -d # docker compose up -d
#
# Data lives under INKNOTE_DATA_DIR (default /srv/inknote) on the host.
# Back that path up together with the .env holding ENCRYPTION_KEY — the
# database is encrypted and is unreadable without that key.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
services: services:
@@ -20,9 +26,13 @@ services:
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY} - ENCRYPTION_KEY=${ENCRYPTION_KEY}
- NODE_ENV=production - NODE_ENV=production
# Bind mounts, not named volumes: the data sits in a plain directory on the
# Docker host, so whatever backs up the host also backs up InkNote.
# Create it first: mkdir -p /srv/inknote/{db,uploads} && chown -R 1000:1000 /srv/inknote
user: "${INKNOTE_UID:-1000}:${INKNOTE_GID:-1000}"
volumes: volumes:
- inknote-data:/app/data - ${INKNOTE_DATA_DIR:-/srv/inknote}/db:/app/data
- inknote-uploads:/app/uploads - ${INKNOTE_DATA_DIR:-/srv/inknote}/uploads:/app/uploads
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
interval: 30s interval: 30s
+3
View File
@@ -0,0 +1,3 @@
dist/
node_modules/
package-lock.json
+8
View File
@@ -0,0 +1,8 @@
{
"printWidth": 110,
"singleQuote": false,
"semi": true,
"trailingComma": "es5",
"arrowParens": "always",
"bracketSameLine": false
}
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="description" content="Write & draw — notes that sync everywhere" /> <meta name="description" content="Write & draw — notes that sync everywhere" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="./manifest.json" /> <link rel="manifest" href="./manifest.json" />
<link rel="apple-touch-icon" href="./icon-192.png" /> <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" 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>" /> <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> <title>InkNote</title>
+2 -2
View File
@@ -29,8 +29,8 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# Icons # Icons (icon-*.png and apple-touch-icon.png)
location ~* ^/icon-.*\.png$ { location ~* ^/(icon-.*|apple-touch-icon)\.png$ {
add_header Cache-Control "public, max-age=86400"; add_header Cache-Control "public, max-age=86400";
try_files $uri =404; try_files $uri =404;
} }
+3956
View File
File diff suppressed because it is too large. Load diff
+29 -3
View File
@@ -6,14 +6,40 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css}'"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-placeholder": "^2.27.2",
"@tiptap/extension-table": "^2.27.2",
"@tiptap/extension-table-cell": "^2.27.2",
"@tiptap/extension-table-header": "^2.27.2",
"@tiptap/extension-table-row": "^2.27.2",
"@tiptap/extension-task-item": "^2.27.2",
"@tiptap/extension-task-list": "^2.27.2",
"@tiptap/extension-underline": "^2.27.2",
"@tiptap/html": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"@tiptap/suggestion": "^2.27.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0" "fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1",
"prettier": "^3.9.6",
"typescript": "^5.9.3",
"vite": "^5.4.0",
"vitest": "^2.1.9"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+7 -4
View File
@@ -9,7 +9,10 @@
"background_color": "#0d1117", "background_color": "#0d1117",
"theme_color": "#0d1117", "theme_color": "#0d1117",
"orientation": "any", "orientation": "any",
"categories": ["productivity", "utilities"], "categories": [
"productivity",
"utilities"
],
"icons": [ "icons": [
{ {
"src": "./icon-192.png", "src": "./icon-192.png",
@@ -24,17 +27,17 @@
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "./icon-192.png", "src": "./icon-maskable-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "./icon-512.png", "src": "./icon-maskable-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
} }
], ],
"prefer_related_applications": false "prefer_related_applications": false
} }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'inknote-v6'; const CACHE_NAME = 'inknote-v7';
// Install — don't precache, just activate immediately // Install — don't precache, just activate immediately
self.addEventListener('install', () => { self.addEventListener('install', () => {
+563
View File
@@ -0,0 +1,563 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import * as sync from "./syncEngine";
import { useNoteData } from "./hooks/useNoteData";
import { useSearchIndex } from "./hooks/useSearchIndex";
import { useResponsive } from "./hooks/useResponsive";
import { usePwaInstall } from "./hooks/usePwaInstall";
import { useNotebookDnd } from "./hooks/useNotebookDnd";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { COVERS, DEFAULT_FOLDERS } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
import { AccountMenu } from "./ui/AccountMenu.jsx";
import { AuthScreen } from "./ui/AuthScreen.jsx";
import { Sidebar } from "./ui/Sidebar.jsx";
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
import { NoteList } from "./features/notes/NoteList.jsx";
import { EditorPane } from "./features/notes/EditorPane.jsx";
import { HistoryModal } from "./features/notes/HistoryModal.jsx";
import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx";
import { CANON_W, CANON_H, PREVIEW_DRAWING_W } from "./features/drawing/strokes.js";
/** Views that are not a notebook and therefore cannot hold a new note. */
const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"];
const EMPTY_BODY = { content: "", drawingData: null, drawingStrokes: null };
/**
* Root of the application.
*
* Holds the things that describe *where the user is* — the open notebook, the
* open note, the search box, whichever dialog is up — and hands the data itself
* to `useNoteData`. Rendering is delegated to the three panes below.
*/
export default function App() {
const [dark, setDark] = useState(true);
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);
const [searchQuery, setSearchQuery] = useState("");
const [showNewNb, setShowNewNb] = useState(false);
const [editingNb, setEditingNb] = useState(null);
const [showAccount, setShowAccount] = useState(false);
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = 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();
const data = useNoteData(user, activeNote);
const { notes, notebooks, noteContents, backlinks } = data;
const search = useSearchIndex(user, notes, searchQuery);
const dnd = useNotebookDnd(data.reorderNotebooks);
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
/* ── Session ── */
useEffect(() => {
(async () => {
try {
const u = await sync.restoreSession();
if (u) {
setUser(u);
await data.reload();
try {
await sync.fullSync();
await data.reload();
} catch (e) {
if (e.message === "AUTH") setUser(null);
}
}
} catch {
/* a broken session must not block the app from starting */
}
setLoading(false);
})();
}, []);
const onLogin = async (u) => {
setUser(u);
try {
await sync.fullSync();
} catch {
/* first sync can fail offline; local data still loads */
}
await data.reload();
};
const onLogout = async () => {
await sync.logout();
setUser(null);
data.reset();
setActiveNote(null);
setActiveFolder("dashboard");
};
/* ── Navigation ── */
const openFolder = useCallback(
(id) => {
setActiveFolder(id);
setActiveNote(null);
if (isMobile) {
setSidebarOpen(false);
setMobileView("list");
}
},
[isMobile, setSidebarOpen, setMobileView]
);
const openNote = useCallback(
(id) => {
setActiveNote(id);
if (isMobile) setMobileView("editor");
},
[isMobile, setMobileView]
);
const closeNote = useCallback(() => {
setActiveNote(null);
if (isMobile) setMobileView("list");
}, [isMobile, setMobileView]);
// Adopts a location the user reached with the browser's back button.
const applyNavState = useCallback(
(s) => {
setActiveFolder(s.folder);
setActiveNote(s.note);
setMobileView(s.view);
},
[setMobileView]
);
useNavigationHistory({ folder: activeFolder, note: activeNote, view: mobileView }, isMobile, applyNavState);
/* ── Derived note lists ── */
const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]);
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
const isTrashView = activeFolder === "trash";
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);
if (searchQuery.trim()) {
// A search reads every note's text, so results keep the index's relevance
// order instead of being re-sorted by date.
if (search.results) {
const rank = new Map(search.results.map((h, i) => [h.id, i]));
return list.filter((n) => rank.has(n.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id));
}
// Until the index answers, fall back to matching titles only.
const q = searchQuery.toLowerCase();
return list.filter((n) => n.title.toLowerCase().includes(q));
}
return [...list].sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes]);
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
// it arrives, rather than blocking on a fetch.
const openNoteData = noteMeta ? { ...noteMeta, ...EMPTY_BODY, ...noteContents[activeNote] } : null;
const folderName = useMemo(() => {
const builtIn = DEFAULT_FOLDERS.find((f) => f.id === activeFolder);
if (builtIn) return builtIn.name;
return notebooks.find((n) => n.id === activeFolder)?.name || "Notes";
}, [activeFolder, notebooks]);
/* ── Note links ── */
// Candidates for the @-menu. Notes rank above notebooks, trashed notes never
// appear, and the open note cannot link to itself.
const searchLinkTargets = useCallback(
(query) => {
const q = (query || "").toLowerCase().trim();
const match = (name) => !q || name.toLowerCase().includes(q);
return [
...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" })),
...notebooks
.filter((nb) => match(nb.name))
.slice(0, 4)
.map((nb) => ({ id: nb.id, label: nb.name, kind: "notebook" })),
];
},
[notes, notebooks, activeNote]
);
// A link stores the label it had when inserted; this keeps it showing the
// target's current title, and returns null once the target is gone.
const resolveLinkLabel = useCallback(
(id) => {
const n = notes.find((x) => x.id === id && !x.trashed);
if (n) return n.title || "Untitled";
return notebooks.find((x) => x.id === id)?.name ?? null;
},
[notes, notebooks]
);
const openLinkTarget = useCallback(
(id, kind) => {
if (kind === "notebook") return openFolder(id);
const target = notes.find((n) => n.id === id);
if (!target) return;
if (target.folderId) setActiveFolder(target.folderId);
openNote(id);
},
[notes, openFolder, openNote]
);
const linkProps = useMemo(
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }),
[openLinkTarget, searchLinkTargets, resolveLinkLabel]
);
/* ── Actions ── */
const createNote = async () => {
const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
if (!folderId) return;
const note = await data.createNote(folderId);
if (activeFolder === "dashboard") 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);
},
});
const trashNote = (id) =>
confirm({
title: "Move to Trash",
message: `Move "${notes.find((n) => n.id === id)?.title}" to trash?`,
confirmLabel: "Move to Trash",
onConfirm: async () => {
await data.trashNote(id);
if (activeNote === id) closeNote();
},
});
const restoreNote = async (id) => {
await data.restoreNote(id);
if (activeNote === id) closeNote();
};
const permanentDeleteNote = (id) =>
confirm({
title: "Delete Permanently",
message: `Permanently delete "${notes.find((n) => n.id === id)?.title}"? This cannot be undone.`,
onConfirm: async () => {
await data.permanentDeleteNote(id);
if (activeNote === id) closeNote();
},
});
const emptyTrash = () =>
confirm({
title: "Empty Trash",
message: `Permanently delete all ${trashedNotes.length} trashed notes?`,
onConfirm: async () => {
await data.emptyTrash();
setActiveNote(null);
},
});
const createNotebook = async (fields) => {
const nb = await data.createNotebook(fields);
setShowNewNb(false);
setActiveFolder(nb.id);
};
const updateNotebook = async (id, updates) => {
await data.updateNotebook(id, updates);
setEditingNb(null);
};
const deleteNotebook = (id) => {
const nb = notebooks.find((n) => n.id === id);
const count = activeNotes.filter((n) => n.folderId === id).length;
confirm({
title: "Delete Notebook",
message: `Delete "${nb?.name}"${count > 0 ? ` and trash its ${count} notes` : ""} permanently?`,
onConfirm: async () => {
await data.deleteNotebook(id);
if (activeFolder === id) setActiveFolder("dashboard");
},
});
};
/* ── Context menus ── */
const notebookContextMenu = (e, nb) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: e.clientX,
y: e.clientY,
items: [
{ label: "Edit", icon: I.edit, action: () => setEditingNb(nb) },
{ divider: true },
{ label: "Delete Notebook", icon: I.trash, danger: true, action: () => deleteNotebook(nb.id) },
],
});
};
/** "Move to..." lists every notebook except the one the note is already 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 noteContextMenu = (e, note) => {
e.preventDefault();
e.stopPropagation();
const items = note.trashed
? [
{ label: "Restore", icon: I.restore, action: () => restoreNote(note.id) },
{ divider: true },
{
label: "Delete Permanently",
icon: I.trash,
danger: true,
action: () => permanentDeleteNote(note.id),
},
]
: [
{
label: note.starred ? "Unstar" : "Star",
icon: note.starred ? I.starFill : I.star,
action: () => data.toggleStar(note.id),
},
...moveSubmenu(note),
{ divider: true },
{ label: "Move to Trash", icon: I.trash, danger: true, action: () => trashNote(note.id) },
];
setCtxMenu({ x: e.clientX, y: e.clientY, items });
};
/* ── Render ── */
if (loading)
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
background: T.bg,
color: T.text,
fontFamily: FONT_UI,
fontSize: 16,
}}
>
Loading...
</div>
);
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
// On a phone the list and the editor take turns; on a desktop both are shown.
const showList = !(isMobile && mobileView === "editor" && activeNote);
const showEditor = !isMobile || mobileView === "editor";
return (
<div
style={{
display: "flex",
height: "100vh",
width: "100vw",
overflow: "hidden",
background: T.bg,
color: T.text,
fontFamily: FONT_UI,
}}
>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:${T.borderLight};border-radius:3px}
button:hover{opacity:0.88}
input::placeholder{color:${T.faint}}
[contenteditable]:empty:before{content:'Start writing...';color:${T.faint};pointer-events:none}
[draggable]{user-select:none}
@media(max-width:767px){
::-webkit-scrollbar{display:none}
body{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none}
}
`}</style>
<Sidebar
T={T}
dark={dark}
onToggleDark={() => setDark(!dark)}
isMobile={isMobile}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
activeFolder={activeFolder}
onSelectFolder={openFolder}
notebooks={notebooks}
activeNotes={activeNotes}
trashedCount={trashedNotes.length}
onNewNotebook={() => setShowNewNb(true)}
onOpenAccount={() => setShowAccount(true)}
onNotebookContextMenu={notebookContextMenu}
dnd={dnd}
canInstall={canInstall}
onInstall={promptInstall}
syncStatus={syncStatus}
online={sync.isOnline()}
/>
{activeFolder === "dashboard" ? (
<Dashboard
T={T}
isMobile={isMobile}
notebooks={notebooks}
activeNotes={activeNotes}
activeFolder={activeFolder}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onOpenNotebook={openFolder}
onOpenNote={(note) => {
setActiveFolder(note.folderId);
openNote(note.id);
}}
onNewNotebook={() => setShowNewNb(true)}
onNotebookContextMenu={notebookContextMenu}
dnd={dnd}
/>
) : (
<>
{showList && (
<NoteList
T={T}
isMobile={isMobile}
title={folderName}
notes={filteredNotes}
activeNote={activeNote}
isTrashView={isTrashView}
trashedCount={trashedNotes.length}
snippetFor={search.snippetFor}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
onCreateNote={createNote}
onEmptyTrash={emptyTrash}
/>
)}
{showEditor && (
<EditorPane
T={T}
dark={dark}
isMobile={isMobile}
note={openNoteData}
isTrashView={isTrashView}
restoreEpoch={restoreEpoch}
backlinks={backlinks}
linkProps={linkProps}
onBack={closeNote}
onUpdate={(u) => data.updateNote(activeNote, u)}
onToggleStar={() => data.toggleStar(activeNote)}
onTrash={() => trashNote(activeNote)}
onRestore={() => restoreNote(activeNote)}
onPermanentDelete={() => permanentDeleteNote(activeNote)}
onOpenHistory={() => setHistoryFor(activeNote)}
onCreateNote={createNote}
/>
)}
</>
)}
{showNewNb && (
<CreateNotebookModal onClose={() => setShowNewNb(false)} onCreate={createNotebook} dark={dark} />
)}
{editingNb && (
<EditNotebookModal
notebook={editingNb}
onClose={() => setEditingNb(null)}
onSave={(u) => updateNotebook(editingNb.id, u)}
dark={dark}
/>
)}
{showAccount && (
<AccountMenu
user={user}
onClose={() => setShowAccount(false)}
onLogout={() => {
setShowAccount(false);
onLogout();
}}
onUserUpdate={setUser}
dark={dark}
/>
)}
{historyFor && (
<HistoryModal
noteId={historyFor}
noteTitle={notes.find((n) => n.id === historyFor)?.title}
dark={dark}
onClose={() => setHistoryFor(null)}
onRestored={(content) => {
data.setNoteContent(historyFor, content);
setRestoreEpoch((e) => e + 1);
}}
renderDrawing={(strokes) => (
<DrawingLayer
strokes={strokes.strokes || []}
width={PREVIEW_DRAWING_W}
height={Math.round((PREVIEW_DRAWING_W * CANON_H) / CANON_W)}
zIndex={1}
/>
)}
/>
)}
{confirmDialog && (
<ConfirmDialog
title={confirmDialog.title}
message={confirmDialog.message}
confirmLabel={confirmDialog.confirmLabel}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
dark={dark}
/>
)}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenu.items}
onClose={() => setCtxMenu(null)}
dark={dark}
/>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+342
View File
@@ -0,0 +1,342 @@
import { useEffect, useState } from "react";
/* Icons the main app does not already provide, drawn to match its stroke style. */
const strokeProps = {
width: 15,
height: 15,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round",
};
const TableIcon = () => (
<svg {...strokeProps}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
);
const LinkIcon = () => (
<svg {...strokeProps}>
<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7" />
<path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7" />
</svg>
);
const QuoteIcon = () => (
<svg {...strokeProps}>
<path d="M6 17h3l2-4V7H5v6h3z" />
<path d="M16 17h3l2-4V7h-6v6h3z" />
</svg>
);
const CodeIcon = () => (
<svg {...strokeProps}>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
const AtIcon = () => (
<svg {...strokeProps}>
<circle cx="12" cy="12" r="4" />
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.9 7.9" />
</svg>
);
/**
* Formatting controls for the note body.
*
* Reads its active states straight off the editor, so the buttons reflect the
* cursor even when formatting changed by keyboard shortcut.
*/
export function EditorToolbar({ editor, T, icons, compact }) {
const [, force] = useState(0);
const [showTable, setShowTable] = useState(false);
// ProseMirror state changes do not go through React; subscribe so the active
// states stay in sync with the cursor.
useEffect(() => {
if (!editor) return;
const rerender = () => force((n) => n + 1);
editor.on("transaction", rerender);
editor.on("selectionUpdate", rerender);
return () => {
editor.off("transaction", rerender);
editor.off("selectionUpdate", rerender);
};
}, [editor]);
useEffect(() => {
if (!showTable) return;
const close = (e) => {
if (!e.target.closest?.("[data-table-menu]")) setShowTable(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [showTable]);
if (!editor) return null;
const btn = (active) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
borderRadius: 6,
border: "none",
cursor: "pointer",
background: active ? T.active + "22" : "transparent",
color: active ? T.active : T.text,
transition: "all .12s",
flexShrink: 0,
});
const divider = (
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px", flexShrink: 0 }} />
);
const inTable = editor.isActive("table");
const menuItem = {
display: "block",
width: "100%",
padding: "6px 10px",
border: "none",
borderRadius: 6,
background: "transparent",
color: T.text,
fontSize: 12,
textAlign: "left",
cursor: "pointer",
};
const promptLink = () => {
const previous = editor.getAttributes("link").href || "";
const url = window.prompt("Link URL (leave empty to remove)", previous);
if (url === null) return;
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
if (!/^(https?|mailto):/i.test(url)) {
window.alert("Only http(s) and mailto links are allowed.");
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
};
return (
<>
<button
style={btn(editor.isActive("bold"))}
title="Bold (Ctrl+B)"
onClick={() => editor.chain().focus().toggleBold().run()}
>
{icons.bold}
</button>
<button
style={btn(editor.isActive("italic"))}
title="Italic (Ctrl+I)"
onClick={() => editor.chain().focus().toggleItalic().run()}
>
{icons.italic}
</button>
<button
style={btn(editor.isActive("underline"))}
title="Underline (Ctrl+U)"
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
{icons.underline}
</button>
{divider}
<button
style={btn(editor.isActive("bulletList"))}
title="Bullet list"
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
{icons.list}
</button>
<button
style={btn(editor.isActive("orderedList"))}
title="Numbered list"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
{icons.listOrd}
</button>
<button
style={btn(editor.isActive("taskList"))}
title="Task list"
onClick={() => editor.chain().focus().toggleTaskList().run()}
>
{icons.check}
</button>
{divider}
{/* Tables */}
<div style={{ position: "relative" }} data-table-menu>
<button style={btn(inTable || showTable)} title="Table" onClick={() => setShowTable((v) => !v)}>
<TableIcon />
</button>
{showTable && (
<div
style={{
position: "absolute",
top: 36,
left: 0,
minWidth: 190,
background: T.toolbar,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 6,
zIndex: 200,
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
}}
>
<button
style={menuItem}
onClick={() => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
setShowTable(false);
}}
>
Insert 3 × 3 table
</button>
{inTable && (
<>
<div style={{ height: 1, background: T.border, margin: "5px 2px" }} />
<button style={menuItem} onClick={() => editor.chain().focus().addColumnBefore().run()}>
Add column left
</button>
<button style={menuItem} onClick={() => editor.chain().focus().addColumnAfter().run()}>
Add column right
</button>
<button style={menuItem} onClick={() => editor.chain().focus().addRowBefore().run()}>
Add row above
</button>
<button style={menuItem} onClick={() => editor.chain().focus().addRowAfter().run()}>
Add row below
</button>
<div style={{ height: 1, background: T.border, margin: "5px 2px" }} />
<button style={menuItem} onClick={() => editor.chain().focus().toggleHeaderRow().run()}>
Toggle header row
</button>
<button style={menuItem} onClick={() => editor.chain().focus().mergeOrSplit().run()}>
Merge / split cells
</button>
<div style={{ height: 1, background: T.border, margin: "5px 2px" }} />
<button
style={{ ...menuItem, color: T.danger || "#e5534b" }}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
Delete column
</button>
<button
style={{ ...menuItem, color: T.danger || "#e5534b" }}
onClick={() => editor.chain().focus().deleteRow().run()}
>
Delete row
</button>
<button
style={{ ...menuItem, color: T.danger || "#e5534b" }}
onClick={() => {
editor.chain().focus().deleteTable().run();
setShowTable(false);
}}
>
Delete table
</button>
</>
)}
</div>
)}
</div>
<button
style={btn(false)}
title="Link to another note (or type @)"
onClick={() => editor.chain().focus().insertContent("@").run()}
>
<AtIcon />
</button>
<button style={btn(editor.isActive("link"))} title="Web link" onClick={promptLink}>
<LinkIcon />
</button>
{!compact && (
<>
{divider}
<button
style={btn(editor.isActive("blockquote"))}
title="Quote"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<QuoteIcon />
</button>
<button
style={btn(editor.isActive("codeBlock"))}
title="Code block"
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
>
<CodeIcon />
</button>
</>
)}
{divider}
<select
value={
editor.isActive("heading", { level: 1 })
? "h1"
: editor.isActive("heading", { level: 2 })
? "h2"
: editor.isActive("heading", { level: 3 })
? "h3"
: "p"
}
onChange={(e) => {
const v = e.target.value;
if (v === "p") editor.chain().focus().setParagraph().run();
else
editor
.chain()
.focus()
.toggleHeading({ level: Number(v[1]) })
.run();
}}
style={{
border: `1px solid ${T.border}`,
borderRadius: 6,
padding: "3px 6px",
background: T.toolbar,
color: T.text,
fontSize: 11,
cursor: "pointer",
}}
>
<option value="p">Body</option>
<option value="h1">H1</option>
<option value="h2">H2</option>
<option value="h3">H3</option>
</select>
{divider}
<button
style={{ ...btn(false), opacity: editor.can().undo() ? 1 : 0.35 }}
title="Undo"
onClick={() => editor.chain().focus().undo().run()}
>
{icons.undo}
</button>
<button
style={{ ...btn(false), opacity: editor.can().redo() ? 1 : 0.35 }}
title="Redo"
onClick={() => editor.chain().focus().redo().run()}
>
{icons.redo}
</button>
</>
);
}
export default EditorToolbar;
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useMemo } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { buildExtensions } from "./extensions.js";
import { parseNoteContent } from "./content";
import "./editor.css";
/**
* Read-only render of a stored note body.
*
* Uses the same extension set as the real editor, so a past version is shown
* with the formatting it actually had — including tables and note links —
* rather than a stripped-down approximation.
*/
export function VersionPreview({ content, dark }) {
const extensions = useMemo(() => buildExtensions(), []);
const editor = useEditor(
{
extensions,
editable: false,
content: parseNoteContent(content, extensions),
},
[]
);
useEffect(() => {
if (!editor || editor.isDestroyed) return;
editor.commands.setContent(parseNoteContent(content, extensions), false);
}, [editor, content, extensions]);
return (
<div className="note-editor" data-theme={dark ? "dark" : "light"}>
<EditorContent editor={editor} />
</div>
);
}
export default VersionPreview;
+82
View File
@@ -0,0 +1,82 @@
/**
* Images in a note body.
*
* New images are stored as attachments and the document only keeps their id.
* Older notes inlined the bytes as a base64 data URI; those still render, so
* nothing written before this change is lost — they simply stay heavy until
* re-inserted.
*/
import Image from "@tiptap/extension-image";
import { resolveUrl } from "./attachments.js";
export const AttachmentImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
attachmentId: {
default: null,
parseHTML: (el) => el.getAttribute("data-attachment"),
renderHTML: (attrs) => (attrs.attachmentId ? { "data-attachment": attrs.attachmentId } : {}),
},
// Preserved from the old inline-style images so their sizing survives.
width: {
default: null,
parseHTML: (el) => el.style?.width || el.getAttribute("width") || null,
renderHTML: (attrs) => (attrs.width ? { style: `width: ${attrs.width}` } : {}),
},
};
},
renderHTML({ HTMLAttributes }) {
// A blob URL is meaningless outside this page, so an attachment-backed image
// is serialised without a src; the node view supplies one at display time.
const attrs = { ...HTMLAttributes };
if (attrs["data-attachment"]) delete attrs.src;
return ["img", attrs];
},
addNodeView() {
return ({ node }) => {
const dom = document.createElement("img");
dom.className = "note-image";
if (node.attrs.alt) dom.alt = node.attrs.alt;
if (node.attrs.title) dom.title = node.attrs.title;
if (node.attrs.width) dom.style.width = node.attrs.width;
let currentId = node.attrs.attachmentId;
const paint = (attrs) => {
if (attrs.attachmentId) {
dom.dataset.attachment = attrs.attachmentId;
resolveUrl(attrs.attachmentId)
.then((url) => {
if (url) dom.src = url;
})
.catch(() => {
dom.alt = attrs.alt || "Image unavailable";
dom.dataset.missing = "true";
});
} else if (attrs.src) {
dom.src = attrs.src; // legacy inline data URI
}
};
paint(node.attrs);
return {
dom,
update(updated) {
if (updated.type.name !== node.type.name) return false;
if (updated.attrs.width !== dom.style.width) dom.style.width = updated.attrs.width || "";
if (updated.attrs.attachmentId !== currentId) {
currentId = updated.attrs.attachmentId;
paint(updated.attrs);
}
return true;
},
};
};
},
});
export default AttachmentImage;
+73
View File
@@ -0,0 +1,73 @@
/**
* Attachment plumbing for the editor.
*
* An `<img>` tag cannot carry an Authorization header, so attachment bytes are
* fetched with an authenticated request, kept in IndexedDB and handed to the
* DOM as a blob URL. That also means images render offline, and each one is
* downloaded at most once per device.
*/
import * as sync from "../syncEngine";
import * as db from "../offlineStore";
const _urls = new Map(); // attachmentId -> object URL
const _inflight = new Map(); // attachmentId -> Promise<string>
export const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
export const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"];
const readAsDataUrl = (file) =>
new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(r.error || new Error("Could not read file"));
r.readAsDataURL(file);
});
/** Uploads a pasted or dropped image and returns its attachment id. */
export async function uploadImage(file) {
if (!ACCEPTED_TYPES.includes(file.type))
throw new Error(`Unsupported image type: ${file.type || "unknown"}`);
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("Image is larger than 15 MB");
const dataUrl = await readAsDataUrl(file);
const { id } = await sync.uploadAttachment(dataUrl, file.name);
// Seed the cache from the bytes already in hand — no round trip to display it.
try {
await db.putAttachmentBlob(id, file);
} catch {
/* cache is best-effort */
}
return id;
}
/** A displayable URL for an attachment, from cache when possible. */
export function resolveUrl(id) {
if (!id) return Promise.resolve(null);
const cached = _urls.get(id);
if (cached) return Promise.resolve(cached);
if (_inflight.has(id)) return _inflight.get(id);
const job = (async () => {
let blob = await db.getAttachmentBlob(id).catch(() => null);
if (!blob) {
blob = await sync.fetchAttachmentBlob(id);
db.putAttachmentBlob(id, blob).catch(() => {});
}
const url = URL.createObjectURL(blob);
_urls.set(id, url);
return url;
})().finally(() => _inflight.delete(id));
_inflight.set(id, job);
return job;
}
/* Object URLs live as long as the page does. They are a few dozen bytes of
* bookkeeping each and the same image usually reappears as the user moves
* between notes, so they are kept rather than revoked per unmount. */
export function releaseAll() {
for (const url of _urls.values()) URL.revokeObjectURL(url);
_urls.clear();
}
+183
View File
@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import { parseNoteContent, serializeNoteContent, extractNoteLinks, docToText } from "./content";
import { parsingExtensions } from "./extensions.js";
const parse = (raw: unknown) => parseNoteContent(raw, parsingExtensions);
/** Collects every node type in a document, for asserting on structure. */
const typesIn = (doc: any): string[] => {
const out: string[] = [];
(function walk(n: any) {
if (!n) return;
if (n.type) out.push(n.type);
if (Array.isArray(n.content)) n.content.forEach(walk);
})(doc);
return out;
};
describe("parseNoteContent", () => {
it("returns an empty document for empty input", () => {
for (const empty of [null, undefined, ""]) {
expect(parse(empty)).toEqual({ type: "doc", content: [{ type: "paragraph" }] });
}
});
it("passes a v2 document through untouched", () => {
const doc = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }] };
expect(parse(JSON.stringify({ v: 2, doc }))).toEqual(doc);
});
describe("v1 migration", () => {
it("keeps headings, marks and lists", () => {
const doc = parse(
JSON.stringify({
html: "<h2>Title</h2><p>Text with <b>bold</b> and <i>italic</i>.</p><ul><li>one</li></ul>",
tasks: [],
})
);
const types = typesIn(doc);
expect(types).toContain("heading");
expect(types).toContain("bulletList");
const marks = JSON.stringify(doc);
expect(marks).toContain("bold");
expect(marks).toContain("italic");
});
it("turns the detached task array into a task list, preserving checked state", () => {
const doc = parse(
JSON.stringify({
html: "<p>body</p>",
tasks: [
{ id: "a", text: "done thing", done: true },
{ id: "b", text: "open thing", done: false },
],
})
);
expect(typesIn(doc)).toContain("taskList");
const json = JSON.stringify(doc);
expect(json).toContain("done thing");
expect(json).toContain("open thing");
// exactly one of the two items is checked
expect((json.match(/"checked":true/g) || []).length).toBe(1);
});
it("does not let task text smuggle in markup", () => {
const doc = parse(
JSON.stringify({ html: "", tasks: [{ id: "a", text: "<script>alert(1)</script>", done: false }] })
);
expect(typesIn(doc)).not.toContain("script");
expect(docToText(doc)).toContain("<script>");
});
it("accepts a bare HTML string, which the very first version stored", () => {
expect(typesIn(parse("<p>plain</p>"))).toContain("paragraph");
});
it("falls back to plain text rather than throwing on unparseable input", () => {
expect(() => parse("{ not json at all")).not.toThrow();
});
});
});
describe("serializeNoteContent", () => {
it("round-trips through parse", () => {
const doc = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }] };
expect(parse(serializeNoteContent(doc))).toEqual(doc);
});
it("tags the stored format so future versions can tell them apart", () => {
expect(JSON.parse(serializeNoteContent({ type: "doc" })).v).toBe(2);
});
});
describe("extractNoteLinks", () => {
const docWith = (...ids: string[]) => ({
type: "doc",
content: ids.map((id) => ({
type: "paragraph",
content: [{ type: "noteLink", attrs: { noteId: id, label: id } }],
})),
});
it("finds every linked id", () => {
expect(extractNoteLinks(docWith("a", "b")).sort()).toEqual(["a", "b"]);
});
it("deduplicates repeated links", () => {
expect(extractNoteLinks(docWith("a", "a", "a"))).toEqual(["a"]);
});
it("drops a link to the note itself", () => {
expect(extractNoteLinks(docWith("self", "other"), "self")).toEqual(["other"]);
});
it("finds links nested inside tables", () => {
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
content: [
{
type: "paragraph",
content: [{ type: "noteLink", attrs: { noteId: "deep", label: "d" } }],
},
],
},
],
},
],
},
],
};
expect(extractNoteLinks(doc)).toEqual(["deep"]);
});
it("returns nothing for a document without links", () => {
expect(extractNoteLinks({ type: "doc", content: [{ type: "paragraph" }] })).toEqual([]);
});
});
describe("docToText", () => {
it("joins text across nested nodes", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [{ type: "paragraph", content: [{ type: "text", text: "second" }] }],
},
],
},
],
};
expect(docToText(doc)).toBe("first second");
});
it("includes note link labels, so linked notes are searchable by name", () => {
const doc = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "noteLink", attrs: { label: "Recipes" } }] }],
};
expect(docToText(doc)).toContain("Recipes");
});
it("respects the length limit", () => {
const doc = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "x".repeat(500) }] }],
};
expect(docToText(doc, 50).length).toBeLessThanOrEqual(50);
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* Note content format and migration.
*
* v1 (contentEditable era): JSON.stringify({ html, tasks }) — unstructured HTML
* from document.execCommand plus a separate checkbox list. Tables could not
* be represented reliably and links had no identity.
* v2 (current): JSON.stringify({ v: 2, doc }) where `doc` is a TipTap/ProseMirror
* document. Structured, so tables, task lists and note links are real nodes.
*
* v1 notes are converted on read and rewritten as v2 the next time they are
* saved. Nothing is migrated in bulk, so an old client reading a note it has
* not touched still sees its own format.
*/
import { generateJSON } from "@tiptap/html";
import type { Extensions } from "@tiptap/core";
/** A TipTap/ProseMirror node. Deliberately loose: the schema is the authority. */
export interface DocNode {
type?: string;
text?: string;
attrs?: Record<string, any>;
content?: DocNode[];
}
export const CONTENT_VERSION = 2;
export const emptyDoc = (): DocNode => ({ type: "doc", content: [{ type: "paragraph" }] });
const escapeHtml = (s: unknown): string =>
String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/** Renders a v1 `tasks` array as the HTML TipTap's TaskList parses. */
function tasksToHtml(tasks: unknown): string {
if (!Array.isArray(tasks) || tasks.length === 0) return "";
const items = tasks
.map(
(t: { done?: boolean; text?: string }) =>
`<li data-type="taskItem" data-checked="${t.done ? "true" : "false"}"><p>${escapeHtml(t.text)}</p></li>`
)
.join("");
return `<ul data-type="taskList">${items}</ul>`;
}
/**
* Turns whatever is stored in `note.content` into a TipTap document.
* Never throws: a note that cannot be parsed opens empty rather than blocking
* the editor, and the original string is left untouched until the user edits.
*/
export function parseNoteContent(raw: unknown, extensions: Extensions): DocNode {
if (!raw) return emptyDoc();
let parsed: any = null;
if (typeof raw === "string") {
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
} else if (typeof raw === "object") {
parsed = raw;
}
// v2 — already a TipTap document.
if (parsed && parsed.v === CONTENT_VERSION && parsed.doc) return parsed.doc;
// v1 — HTML plus a detached task list, or a bare HTML string.
const html =
typeof parsed?.html === "string"
? parsed.html
: typeof parsed === "string"
? parsed
: typeof raw === "string"
? raw
: "";
const combined = html + tasksToHtml(parsed?.tasks);
if (!combined.trim()) return emptyDoc();
try {
return generateJSON(combined, extensions);
} catch (err) {
console.error("[content] could not migrate v1 note, opening as plain text:", err);
return {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: stripHtml(combined) }] }],
};
}
}
export function serializeNoteContent(doc: DocNode): string {
return JSON.stringify({ v: CONTENT_VERSION, doc });
}
function stripHtml(html: string): string {
const el = document.createElement("div");
el.innerHTML = html;
return el.textContent || "";
}
/** Depth-first walk over every node in a TipTap document. */
function walk(node: DocNode | undefined | null, visit: (n: DocNode) => void): void {
if (!node) return;
visit(node);
if (Array.isArray(node.content)) for (const child of node.content) walk(child, visit);
}
/**
* Ids of every note this document links to.
*
* The server stores content encrypted and cannot read it, so the client is what
* tells it which links exist. Deduplicated and self-links dropped.
*/
export function extractNoteLinks(doc: DocNode, selfId?: string): string[] {
const ids = new Set<string>();
walk(doc, (n) => {
if (n.type === "noteLink" && n.attrs?.noteId && n.attrs.noteId !== selfId) ids.add(n.attrs.noteId);
});
return [...ids];
}
/** Plain text of a document — used for list previews and local search. */
export function docToText(doc: unknown, limit = 400): string {
const parts: string[] = [];
let len = 0;
walk(doc as DocNode, (n) => {
if (len >= limit) return;
if (n.type === "text" && n.text) {
parts.push(n.text);
len += n.text.length;
} else if (n.type === "noteLink" && n.attrs?.label) {
parts.push(n.attrs.label);
len += n.attrs.label.length;
}
});
return parts.join(" ").replace(/\s+/g, " ").trim().slice(0, limit);
}
+262
View File
@@ -0,0 +1,262 @@
/* Editor content styling.
*
* The rest of the app styles inline, but ProseMirror renders its own DOM, so
* these nodes can only be reached from a stylesheet. Colours come from custom
* properties the editor container sets per theme. */
.note-editor {
--ne-text: #1f2328;
--ne-muted: #656d76;
--ne-faint: #afb8c1;
--ne-border: #d0d7de;
--ne-accent: #0969da;
--ne-surface: #ffffff;
--ne-subtle: #f6f8fa;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
--ne-muted: #8b949e;
--ne-faint: #484f58;
--ne-border: #30363d;
--ne-accent: #388bfd;
--ne-surface: #0d1117;
--ne-subtle: #161b22;
}
.note-editor .ProseMirror {
outline: none;
color: var(--ne-text);
font-family: "Newsreader", Georgia, serif;
font-size: 15px;
line-height: 1.7;
padding: 20px 24px;
min-height: 100%;
word-break: break-word;
}
.note-editor .ProseMirror > * + * {
margin-top: 0.6em;
}
.note-editor .ProseMirror p {
margin: 0;
}
.note-editor .ProseMirror h1 {
font-size: 1.7em;
font-weight: 700;
line-height: 1.3;
margin-top: 0.8em;
}
.note-editor .ProseMirror h2 {
font-size: 1.4em;
font-weight: 700;
line-height: 1.3;
margin-top: 0.7em;
}
.note-editor .ProseMirror h3 {
font-size: 1.15em;
font-weight: 700;
line-height: 1.4;
margin-top: 0.6em;
}
.note-editor .ProseMirror ul,
.note-editor .ProseMirror ol {
padding-left: 1.4em;
}
.note-editor .ProseMirror li p {
margin: 0;
}
.note-editor .ProseMirror blockquote {
border-left: 3px solid var(--ne-border);
padding-left: 12px;
color: var(--ne-muted);
}
.note-editor .ProseMirror code {
background: var(--ne-subtle);
border: 1px solid var(--ne-border);
border-radius: 4px;
padding: 1px 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.88em;
}
.note-editor .ProseMirror pre {
background: var(--ne-subtle);
border: 1px solid var(--ne-border);
border-radius: 10px;
padding: 12px 14px;
overflow-x: auto;
}
.note-editor .ProseMirror pre code {
background: none;
border: none;
padding: 0;
}
.note-editor .ProseMirror hr {
border: none;
border-top: 1px solid var(--ne-border);
margin: 1.2em 0;
}
.note-editor .ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
}
.note-editor .ProseMirror img.note-image {
background: var(--ne-subtle);
min-height: 24px;
}
/* An attachment whose bytes could not be fetched — visible, not a blank gap */
.note-editor .ProseMirror img[data-missing="true"] {
border: 1px dashed var(--ne-border);
padding: 12px;
min-width: 120px;
}
.note-editor .ProseMirror a {
color: var(--ne-accent);
}
/* Placeholder on the first empty line */
.note-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--ne-faint);
float: left;
height: 0;
pointer-events: none;
}
/* ── Tables ── */
.note-editor .ProseMirror table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0.8em 0;
overflow: hidden;
}
.note-editor .ProseMirror td,
.note-editor .ProseMirror th {
border: 1px solid var(--ne-border);
padding: 7px 10px;
vertical-align: top;
position: relative;
min-width: 1em;
}
.note-editor .ProseMirror th {
background: var(--ne-subtle);
font-weight: 600;
text-align: left;
}
.note-editor .ProseMirror .selectedCell::after {
content: "";
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--ne-accent) 18%, transparent);
pointer-events: none;
}
/* Drag handle between columns */
.note-editor .ProseMirror .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background: var(--ne-accent);
pointer-events: none;
}
.note-editor .ProseMirror.resize-cursor {
cursor: col-resize;
}
/* ── Task lists ── */
.note-editor .ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.note-editor .ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.note-editor .ProseMirror ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-top: 0.35em;
user-select: none;
}
.note-editor .ProseMirror ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
.note-editor .ProseMirror ul[data-type="taskList"] input[type="checkbox"] {
accent-color: var(--ne-accent);
width: 15px;
height: 15px;
cursor: pointer;
}
.note-editor .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div {
color: var(--ne-muted);
text-decoration: line-through;
}
/* ── Note links ── */
.note-editor .ProseMirror a.note-link,
.note-link {
display: inline-flex;
align-items: baseline;
gap: 3px;
padding: 1px 6px;
border-radius: 6px;
background: color-mix(in srgb, var(--ne-accent) 14%, transparent);
color: var(--ne-accent);
text-decoration: none;
font-family: "DM Sans", system-ui, sans-serif;
font-size: 0.9em;
white-space: nowrap;
cursor: pointer;
}
.note-editor .ProseMirror a.note-link:hover {
background: color-mix(in srgb, var(--ne-accent) 26%, transparent);
}
/* A link whose target is gone — still readable, visibly broken */
.note-editor .ProseMirror a.note-link[data-missing="true"] {
background: color-mix(in srgb, #cf222e 14%, transparent);
color: #cf222e;
text-decoration: line-through;
}
/* ── @-mention popup ── */
.note-link-popup {
position: fixed;
z-index: 3000;
max-height: 240px;
overflow-y: auto;
padding: 5px;
border-radius: 12px;
border: 1px solid var(--popup-border, #d0d7de);
background: var(--popup-bg, #ffffff);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
font-family: "DM Sans", system-ui, sans-serif;
}
.note-link-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--popup-text, #1f2328);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.note-link-option.is-selected {
background: var(--popup-hover, #eaeef2);
}
.note-link-option .note-link-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-link-empty {
padding: 9px 11px;
font-size: 12px;
color: var(--popup-muted, #656d76);
}
+49
View File
@@ -0,0 +1,49 @@
/** The one place that decides which nodes and marks a note may contain. */
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import { NoteLink } from "./noteLink.js";
import { AttachmentImage } from "./attachmentImage.js";
export function buildExtensions({ search = () => [], placeholder = "Start writing..." } = {}) {
return [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
// History is on by default; the drawing layer keeps its own undo stack.
codeBlock: { HTMLAttributes: { class: "note-code-block" } },
}),
Underline,
Link.configure({
openOnClick: false, // the app decides what a click does
autolink: true,
protocols: ["http", "https", "mailto"],
HTMLAttributes: { rel: "noopener noreferrer nofollow", target: "_blank" },
}),
// allowBase64 stays on so notes written before attachments still render.
AttachmentImage.configure({ inline: false, allowBase64: true }),
Placeholder.configure({ placeholder }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true, lastColumnResizable: false, allowTableNodeSelection: true }),
TableRow,
TableHeader,
TableCell,
NoteLink.configure({ search }),
];
}
/**
* A static extension list for parsing stored HTML outside of a live editor
* (`generateJSON`). It must accept everything `buildExtensions` produces, or
* migrating an old note would silently drop nodes.
*/
export const parsingExtensions = buildExtensions();
+221
View File
@@ -0,0 +1,221 @@
/**
* Note links — type `@` to reference another note or notebook.
*
* A link is an atomic inline node carrying the target's id, so it survives
* renames: the label is refreshed from the current title on render, and the id
* is what the backlink index is built from.
*/
import { Node, mergeAttributes } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
export const NoteLinkPluginKey = new PluginKey("noteLinkSuggestion");
export const NoteLink = Node.create({
name: "noteLink",
group: "inline",
inline: true,
atom: true,
selectable: true,
draggable: false,
addOptions() {
return {
// (query) => [{ id, label, kind: 'note' | 'notebook' }]
search: () => [],
HTMLAttributes: {},
};
},
addAttributes() {
return {
noteId: {
default: null,
parseHTML: (el) => el.getAttribute("data-note-link"),
renderHTML: (a) => ({ "data-note-link": a.noteId }),
},
label: {
default: "",
parseHTML: (el) => el.getAttribute("data-label") || el.textContent,
renderHTML: (a) => ({ "data-label": a.label }),
},
kind: {
default: "note",
parseHTML: (el) => el.getAttribute("data-kind") || "note",
renderHTML: (a) => ({ "data-kind": a.kind }),
},
// Set when the target no longer exists, so a dead link reads as broken
// instead of silently going nowhere.
missing: {
default: false,
parseHTML: (el) => el.getAttribute("data-missing") === "true",
renderHTML: (a) => (a.missing ? { "data-missing": "true" } : {}),
},
};
},
parseHTML() {
return [{ tag: "a[data-note-link]" }, { tag: "span[data-note-link]" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"a",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: "note-link",
// Real href would navigate away; the app intercepts the click instead.
href: "#",
title: node.attrs.label,
}),
`${node.attrs.kind === "notebook" ? "📓" : "📝"} ${node.attrs.label}`,
];
},
renderText({ node }) {
return `@${node.attrs.label}`;
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: "@",
pluginKey: NoteLinkPluginKey,
allowSpaces: true,
startOfLine: false,
items: ({ query }) => this.options.search(query),
command: ({ editor, range, props }) => {
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: "noteLink",
attrs: { noteId: props.id, label: props.label, kind: props.kind || "note" },
},
{ type: "text", text: " " },
])
.run();
},
render: renderSuggestionList,
}),
];
},
});
/**
* A small dropdown driven by the suggestion plugin.
*
* Deliberately plain DOM rather than a positioning library — it is one list in
* one place, and pulling in tippy/popper for it is not worth the bundle.
*/
function renderSuggestionList() {
let el = null;
let items = [];
let selected = 0;
let command = null;
const paint = () => {
if (!el) return;
if (items.length === 0) {
el.innerHTML = '<div class="note-link-empty">No matching note</div>';
return;
}
el.innerHTML = items
.map(
(
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-label"></span>
</button>`
)
.join("");
// Titles are user text — set them as text, never as markup.
el.querySelectorAll(".note-link-option").forEach((btn, i) => {
btn.querySelector(".note-link-label").textContent = items[i].label;
btn.addEventListener("mousedown", (e) => {
e.preventDefault();
pick(i);
});
});
};
const pick = (i) => {
const item = items[i];
if (item && command) command({ id: item.id, label: item.label, kind: item.kind });
};
const place = (rect) => {
if (!el || !rect) return;
const r = rect();
if (!r) return;
const width = 260;
const left = Math.min(Math.max(8, r.left), window.innerWidth - width - 8);
// Flip above the caret when there is no room below.
const below = window.innerHeight - r.bottom;
el.style.width = `${width}px`;
el.style.left = `${left}px`;
if (below < 220) {
el.style.top = "auto";
el.style.bottom = `${window.innerHeight - r.top + 6}px`;
} else {
el.style.bottom = "auto";
el.style.top = `${r.bottom + 6}px`;
}
};
return {
onStart: (props) => {
items = props.items || [];
selected = 0;
command = props.command;
el = document.createElement("div");
el.className = "note-link-popup";
document.body.appendChild(el);
paint();
place(props.clientRect);
},
onUpdate: (props) => {
items = props.items || [];
selected = Math.min(selected, Math.max(0, items.length - 1));
command = props.command;
paint();
place(props.clientRect);
},
onKeyDown: (props) => {
const key = props.event.key;
if (key === "Escape") {
props.event.preventDefault();
el?.remove();
el = null;
return true;
}
if (items.length === 0) return false;
if (key === "ArrowDown") {
selected = (selected + 1) % items.length;
paint();
return true;
}
if (key === "ArrowUp") {
selected = (selected - 1 + items.length) % items.length;
paint();
return true;
}
if (key === "Enter" || key === "Tab") {
props.event.preventDefault();
pick(selected);
return true;
}
return false;
},
onExit: () => {
el?.remove();
el = null;
items = [];
command = null;
},
};
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useMemo, useRef, useCallback } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { buildExtensions } from "./extensions.js";
import { parseNoteContent, serializeNoteContent, extractNoteLinks } from "./content";
import { uploadImage, ACCEPTED_TYPES } from "./attachments.js";
import "./editor.css";
/* Marks transactions this module dispatches itself, so they are not mistaken
* for the user editing the note. */
const LABEL_REFRESH = "noteLinkLabelRefresh";
/**
* Uploads images from a paste or drop and inserts them as attachments.
*
* Returns true only when images were actually taken over, so pasting text or a
* screenshot-free clipboard still behaves normally.
*/
function insertDroppedImages(view, transfer, at, onErrorRef) {
const files = [...(transfer?.files || [])].filter((f) => ACCEPTED_TYPES.includes(f.type));
if (files.length === 0) return false;
(async () => {
for (const file of files) {
try {
const id = await uploadImage(file);
const node = view.state.schema.nodes.image.create({ attachmentId: id, alt: file.name || null });
const pos = at ?? view.state.selection.from;
view.dispatch(view.state.tr.insert(Math.min(pos, view.state.doc.content.size), node));
} catch (err) {
console.error("[attachment] upload failed:", err);
onErrorRef?.current?.(err.message || "Could not attach that image");
}
}
})();
return true; // handled — do not also paste the raw file
}
/**
* Builds the note body editor.
*
* 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 }) {
// 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);
searchRef.current = searchLinkTargets;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const extensions = useMemo(
() => buildExtensions({ search: (query) => (searchRef.current ? searchRef.current(query) : []) }),
[]
);
const editor = useEditor(
{
extensions,
content: parseNoteContent(content, extensions),
editable,
editorProps: {
attributes: { spellcheck: "true" },
handlePaste: (view, event) => insertDroppedImages(view, event.clipboardData, null, onErrorRef),
handleDrop: (view, event) => {
const at = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos ?? null;
return insertDroppedImages(view, event.dataTransfer, at, onErrorRef);
},
},
onUpdate: ({ editor: ed, transaction }) => {
// Refreshing a link label is not a user edit and must not mark the note dirty.
if (transaction?.getMeta(LABEL_REFRESH)) return;
const doc = ed.getJSON();
onChangeRef.current?.({
content: serializeNoteContent(doc),
links: extractNoteLinks(doc, noteId),
});
},
// Rebuilt per note so each one starts with its own document and history.
},
[noteId]
);
useEffect(() => {
if (editor && editor.isEditable !== editable) editor.setEditable(editable);
}, [editor, editable]);
/* Adopt content that arrived from another device. Skipped while the editor
* has focus, so a sync push can never yank the document out from under the
* cursor, and skipped when nothing actually differs. */
useEffect(() => {
if (!editor || editor.isDestroyed || editor.isFocused) return;
const incoming = parseNoteContent(content, extensions);
if (JSON.stringify(incoming) === JSON.stringify(editor.getJSON())) return;
editor.commands.setContent(incoming, false);
}, [editor, content, extensions]);
return editor;
}
/** The editable page itself. */
export function EditorSurface({ editor, dark, 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.
*
* This has to go through a transaction. Writing the new label straight into
* `editor.view.dom` looks simpler and works on screen, but ProseMirror's
* mutation observer then re-reads the document from that DOM and drops the
* 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 handleClick = useCallback(
(e) => {
const noteLink = e.target.closest("a.note-link");
if (noteLink) {
e.preventDefault();
const id = noteLink.getAttribute("data-note-link");
if (id && !noteLink.hasAttribute("data-missing")) {
onOpenNote?.(id, noteLink.getAttribute("data-kind") || "note");
}
return;
}
const external = e.target.closest("a[href]");
if (external) {
const url = external.getAttribute("href");
if (url && /^(https?|mailto):/i.test(url)) {
e.preventDefault();
window.open(url, "_blank", "noopener");
}
}
},
[onOpenNote]
);
return (
<div
className="note-editor"
data-theme={dark ? "dark" : "light"}
onClick={handleClick}
style={{ minHeight }}
>
<EditorContent editor={editor} />
</div>
);
}
@@ -0,0 +1,18 @@
import { useEffect, useRef } from "react";
import { sizeCanvas, paintStrokeDoc, strokeDocOf } from "./strokes.js";
export function DrawingLayer({ strokes, width, height, zIndex }) {
const ref = useRef(null);
useEffect(() => {
const c = ref.current;
if (!c) return;
const { bw, bh } = sizeCanvas(c, width, height);
paintStrokeDoc(c.getContext("2d"), strokeDocOf(strokes), bw, bh);
}, [strokes, width, height]);
return (
<canvas
ref={ref}
style={{ position: "absolute", left: 0, top: 0, width: "100%", height, pointerEvents: "none", zIndex }}
/>
);
}
+105
View File
@@ -0,0 +1,105 @@
import * as sync from "../../syncEngine";
/* ═══════ DRAWING MODEL ═══════
* A drawing is a list of vector strokes in one canonical page space (CANON_W ×
* CANON_H), never a bitmap. Every screen scales that same page to whatever room
* it has, so a note looks identical on a phone and on a desktop — and stays
* sharp at any pixel density, because we rasterize at render time.
*/
export const CANON_W = 1200,
CANON_H = 1600;
export const PREVIEW_DRAWING_W = 260; // history preview thumbnail
export const STROKES_VERSION = 1;
// Widths are canonical units, so a "Medium" pen covers the same fraction of the
// page everywhere. At the 760px desktop page width, M lands at roughly 3.5 CSS px.
export const PEN_SIZES = [
{ label: "XS", w: 2 },
{ label: "S", w: 3.5 },
{ label: "M", w: 5.5 },
{ label: "L", w: 9 },
{ label: "XL", w: 14 },
{ label: "2XL", w: 22 },
{ label: "3XL", w: 34 },
];
export const DEFAULT_PEN_SIZE = 2; // index into PEN_SIZES → "M"
export const HIGHLIGHTER_FACTOR = 5;
export const ERASER_FACTOR = 4;
export const HIGHLIGHTER_ALPHA = "55";
// Minimum canonical distance between recorded points — keeps payloads small
// without visibly changing the curve.
export const MIN_POINT_DIST = 2.5;
// `t` tags a save so the editor can recognise its own drawing coming back from
// the server and ignore reads that were already in flight when it saved.
let saveSeq = 0;
export const newSaveToken = () => `${sync.DEVICE_ID}:${Date.now()}:${++saveSeq}`;
export const strokeDocOf = (strokes, t) => ({
v: STROKES_VERSION,
w: CANON_W,
h: CANON_H,
strokes,
...(t ? { t } : {}),
});
export const docStrokes = (doc) => (Array.isArray(doc?.strokes) ? doc.strokes : []);
export const sameStrokeDoc = (a, b) =>
a === b ||
(docStrokes(a).length === docStrokes(b).length && docStrokes(a).every((s, i) => s === docStrokes(b)[i]));
export function paintStroke(ctx, s) {
const p = s?.p;
if (!p || p.length < 2) return;
const eraser = s.t === "eraser";
ctx.globalCompositeOperation = eraser ? "destination-out" : "source-over";
const color = s.t === "highlighter" ? s.c + HIGHLIGHTER_ALPHA : s.c;
ctx.strokeStyle = color;
ctx.lineWidth = s.s;
if (p.length === 2) {
// a tap, not a drag — draw the nib itself
ctx.beginPath();
ctx.arc(p[0], p[1], Math.max(s.s / 2, 0.4), 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.globalCompositeOperation = "source-over";
return;
}
// One path per stroke: a translucent highlighter stays evenly translucent
// instead of darkening at every joint.
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
for (let i = 2; i < p.length - 2; i += 2) {
ctx.quadraticCurveTo(p[i], p[i + 1], (p[i] + p[i + 2]) / 2, (p[i + 1] + p[i + 3]) / 2);
}
ctx.lineTo(p[p.length - 2], p[p.length - 1]);
ctx.stroke();
ctx.globalCompositeOperation = "source-over";
}
// Replays a whole drawing into a context of targetW × targetH device pixels.
export function paintStrokeDoc(ctx, doc, targetW, targetH) {
const w = doc?.w || CANON_W,
h = doc?.h || CANON_H;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, targetW, targetH);
ctx.scale(targetW / w, targetH / h);
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const s of docStrokes(doc)) paintStroke(ctx, s);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
export const backingScale = () => Math.min(window.devicePixelRatio || 1, 3);
export function sizeCanvas(c, cssW, cssH) {
const dpr = backingScale();
const bw = Math.max(1, Math.round(cssW * dpr)),
bh = Math.max(1, Math.round(cssH * dpr));
if (c.width !== bw || c.height !== bh) {
c.width = bw;
c.height = bh;
}
return { bw, bh };
}
/* Read-only drawing layer — used wherever a saved drawing is shown but not edited. */
@@ -0,0 +1,234 @@
import { COVERS } from "../../ui/constants.jsx";
import { I } from "../../ui/icons.jsx";
import { NotebookCover } from "../../ui/NotebookCover.jsx";
import { fmtDate } from "../../ui/format";
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. */
export function Dashboard({
T,
isMobile,
notebooks,
activeNotes,
activeFolder,
onToggleSidebar,
onOpenNotebook,
onOpenNote,
onNewNotebook,
onNotebookContextMenu,
dnd,
}) {
return (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
background: T.dashBg,
fontFamily: FONT_UI,
overflow: "auto",
}}
>
<div
style={{
padding: isMobile ? "20px 20px 16px" : "28px 36px 20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button
onClick={onToggleSidebar}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
borderRadius: 10,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
}}
>
{I.menu}
</button>
<h1
style={{
margin: 0,
fontSize: isMobile ? 20 : 24,
fontWeight: 700,
color: T.text,
fontFamily: FONT_SERIF,
}}
>
My Notebooks
</h1>
</div>
</div>
<div
style={{
padding: isMobile ? "0 16px 24px" : "0 36px 32px",
display: "grid",
gridTemplateColumns: isMobile
? "repeat(auto-fill,minmax(140px,1fr))"
: "repeat(auto-fill,minmax(180px,1fr))",
gap: isMobile ? "20px 16px" : "32px 28px",
justifyItems: "center",
}}
>
{notebooks.map((nb) => (
<NotebookCover
key={nb.id}
notebook={nb}
noteCount={activeNotes.filter((n) => n.folderId === nb.id).length}
onClick={() => onOpenNotebook(nb.id)}
onContextMenu={(e) => onNotebookContextMenu(e, nb)}
isActive={activeFolder === nb.id}
isDragOver={dnd.overId === nb.id}
draggable={!isMobile}
onDragStart={(e) => dnd.onDragStart(e, nb.id)}
onDragEnd={dnd.onDragEnd}
onDragOver={(e) => dnd.onDragOver(e, nb.id)}
onDrop={(e) => dnd.onDrop(e, nb.id)}
/>
))}
<button
onClick={onNewNotebook}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
background: "none",
border: "none",
cursor: "pointer",
}}
>
<div
style={{
width: isMobile ? 130 : 156,
height: isMobile ? 175 : 210,
borderRadius: 12,
border: `2px dashed ${T.border}`,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 8,
color: T.faint,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
background: T.accentSoft,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: T.accent,
}}
>
{I.plus}
</div>
<span style={{ fontSize: 12, fontWeight: 500 }}>Add Notebook</span>
</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>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { I } from "../../ui/icons.jsx";
import { BackButton } from "../../ui/BackButton.jsx";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
import { NoteEditor } from "./NoteEditor.jsx";
const toolbarButton = (T, color) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color,
});
/** Shown on desktop when nothing is selected; on a phone the list fills the screen instead. */
function EmptyState({ T, isTrashView, onCreateNote }) {
return (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: T.bg,
color: T.faint,
fontFamily: FONT_UI,
gap: 14,
}}
>
<div
style={{
width: 72,
height: 72,
borderRadius: 20,
background: T.accentSoft,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 32,
}}
>
✏️
</div>
<div style={{ fontSize: 15, fontWeight: 500, color: T.muted }}>Select a note to edit</div>
{!isTrashView && (
<button
onClick={onCreateNote}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 22px",
borderRadius: 12,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
marginTop: 6,
}}
>
{I.plus} New Note
</button>
)}
</div>
);
}
/** A trashed note can be read and restored, but not edited. */
function TrashedNote({ T, isMobile, note, onBack, onRestore, onPermanentDelete }) {
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: T.bg, fontFamily: FONT_UI }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 16px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
{isMobile && <BackButton T={T} onClick={onBack} />}
<span
style={{
fontSize: 17,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{note.title}
</span>
<button
onClick={onRestore}
style={{
padding: "6px 14px",
borderRadius: 8,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
}}
>
Restore
</button>
<button onClick={onPermanentDelete} style={toolbarButton(T, T.danger)}>
{I.trash}
</button>
</div>
<div style={{ flex: 1, padding: "20px 24px", color: T.muted, fontStyle: "italic", fontSize: 14 }}>
Trashed notes are read-only. Restore to edit.
</div>
</div>
);
}
/**
* The right-hand pane: title bar plus the rich-text editor.
*
* `restoreEpoch` is part of the editor's key so that restoring an old version
* remounts it on the restored body instead of trying to merge into the live
* document.
*/
export function EditorPane({
T,
dark,
isMobile,
note,
isTrashView,
restoreEpoch,
backlinks,
linkProps,
onBack,
onUpdate,
onToggleStar,
onTrash,
onRestore,
onPermanentDelete,
onOpenHistory,
onCreateNote,
}) {
if (!note)
return isMobile ? null : <EmptyState T={T} isTrashView={isTrashView} onCreateNote={onCreateNote} />;
if (isTrashView)
return (
<TrashedNote
T={T}
isMobile={isMobile}
note={note}
onBack={onBack}
onRestore={onRestore}
onPermanentDelete={onPermanentDelete}
/>
);
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: T.bg, fontFamily: FONT_UI }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 16px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
{isMobile && <BackButton T={T} onClick={onBack} />}
<input
type="text"
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,
}}
/>
<button onClick={onOpenHistory} title="Version history" style={toolbarButton(T, T.faint)}>
{I.history}
</button>
<button onClick={onToggleStar} style={toolbarButton(T, note.starred ? "#f0b429" : T.faint)}>
{note.starred ? I.starFill : I.star}
</button>
<button onClick={onTrash} style={toolbarButton(T, T.danger)}>
{I.trash}
</button>
</div>
<div style={{ flex: 1, overflow: "hidden" }}>
<NoteEditor
key={`${note.id}:${restoreEpoch}`}
note={note}
onUpdate={onUpdate}
dark={dark}
onOpenNote={linkProps.openLinkTarget}
searchLinkTargets={linkProps.searchLinkTargets}
resolveLinkLabel={linkProps.resolveLinkLabel}
backlinks={backlinks}
/>
</div>
</div>
);
}
@@ -0,0 +1,297 @@
import { useEffect, useMemo, useState } from "react";
import * as sync from "../../syncEngine";
import { VersionPreview } from "../../editor/VersionPreview.jsx";
import { I } from "../../ui/icons.jsx";
/* ═══════ 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 [versions, setVersions] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(false);
const [restoring, setRestoring] = useState(false);
const [err, setErr] = useState("");
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
danger: dark ? "#f85149" : "#cf222e",
surface: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
let cancelled = false;
sync
.getNoteVersions(noteId)
.then((v) => {
if (cancelled) return;
setVersions(v);
if (v.length) setSelectedId(v[0].id);
})
.catch(() => {
if (!cancelled) setVersions([]);
});
return () => {
cancelled = true;
};
}, [noteId]);
useEffect(() => {
if (!selectedId) return;
let cancelled = false;
setLoading(true);
setErr("");
sync
.getNoteVersion(noteId, selectedId)
.then((d) => {
if (!cancelled) setDetail(d);
})
.catch((e) => {
if (!cancelled) setErr(e.message || "Could not load this version");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [noteId, selectedId]);
const restore = async () => {
if (!selectedId) return;
setRestoring(true);
setErr("");
try {
const r = await sync.restoreNoteVersion(noteId, selectedId);
onRestored?.(r.content);
onClose();
} catch (e) {
setErr(e.message || "Restore failed");
}
setRestoring(false);
};
const when = (t) => {
const d = new Date(String(t).replace(" ", "T") + "Z");
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const REASONS = {
"large-deletion": "large deletion",
"before-restore": "before restore",
"drawing-cleared": "drawing cleared",
drawing: "drawing",
edit: "edit",
};
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: T.bg,
borderRadius: 20,
width: 940,
maxWidth: "94vw",
height: 620,
maxHeight: "88vh",
border: `1px solid ${T.border}`,
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
borderBottom: `1px solid ${T.border}`,
}}
>
<div>
<h3
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: T.text,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
Version history
</h3>
<div style={{ fontSize: 12, color: T.muted, marginTop: 2 }}>{noteTitle || "Untitled"}</div>
</div>
<button
onClick={onClose}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
}}
>
{I.x}
</button>
</div>
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{/* Version list */}
<div
style={{
width: 260,
flexShrink: 0,
borderRight: `1px solid ${T.border}`,
overflowY: "auto",
padding: 8,
background: T.surface,
}}
>
{versions === null && <div style={{ padding: 14, fontSize: 12, color: T.muted }}>Loading…</div>}
{versions?.length === 0 && (
<div style={{ padding: 14, fontSize: 12, color: T.muted, lineHeight: 1.6 }}>
No earlier versions yet. One is kept whenever you come back to a note after a break, and
always before a large deletion.
</div>
)}
{versions?.map((v) => (
<button
key={v.id}
onClick={() => setSelectedId(v.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "9px 11px",
marginBottom: 3,
borderRadius: 9,
border: "none",
cursor: "pointer",
background: selectedId === v.id ? T.accent + "22" : "transparent",
}}
>
<div
style={{ fontSize: 12.5, fontWeight: 600, color: selectedId === v.id ? T.accent : T.text }}
>
{when(v.created_at)}
</div>
<div style={{ fontSize: 11, color: T.muted, marginTop: 2 }}>
{REASONS[v.reason] || v.reason}
{v.has_drawing && " · drawing"}
</div>
</button>
))}
</div>
{/* Preview */}
<div style={{ flex: 1, overflowY: "auto", minWidth: 0, position: "relative" }}>
{loading && <div style={{ padding: 20, fontSize: 12, color: T.muted }}>Loading version…</div>}
{!loading && detail && (
<>
{detail.drawingStrokes && renderDrawing && (
<div
style={{
margin: "14px 18px 0",
border: `1px solid ${T.border}`,
borderRadius: 10,
overflow: "hidden",
position: "relative",
background: dark ? "#0d1117" : "#fff",
}}
>
{renderDrawing(detail.drawingStrokes)}
</div>
)}
<VersionPreview content={detail.content} dark={dark} />
</>
)}
{!loading && !detail && !err && versions?.length > 0 && (
<div style={{ padding: 20, fontSize: 12, color: T.muted }}>Select a version.</div>
)}
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "12px 20px",
borderTop: `1px solid ${T.border}`,
}}
>
<div style={{ fontSize: 11.5, color: err ? T.danger : T.muted, flex: 1, minWidth: 0 }}>
{err || "Restoring keeps a snapshot of the current state first, so this is reversible."}
</div>
<button
onClick={onClose}
style={{
padding: "8px 18px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Close
</button>
<button
onClick={restore}
disabled={!selectedId || restoring || loading}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: selectedId ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
opacity: !selectedId || restoring || loading ? 0.5 : 1,
}}
>
{restoring ? "Restoring…" : "Restore this version"}
</button>
</div>
</div>
</div>
);
}
+691
View File
@@ -0,0 +1,691 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx";
import { EditorToolbar } from "../../editor/EditorToolbar.jsx";
import { DrawingLayer } from "../drawing/DrawingLayer.jsx";
import {
CANON_W,
CANON_H,
PEN_SIZES,
DEFAULT_PEN_SIZE,
HIGHLIGHTER_FACTOR,
ERASER_FACTOR,
MIN_POINT_DIST,
newSaveToken,
strokeDocOf,
docStrokes,
paintStroke,
paintStrokeDoc,
sizeCanvas,
} from "../drawing/strokes.js";
import { COLORS } from "../../ui/constants.jsx";
import { I } from "../../ui/icons.jsx";
import { BacklinksPanel } from "../../ui/BacklinksPanel.jsx";
/* ═══════ NOTE EDITOR ═══════ */
export function NoteEditor({
note,
onUpdate,
dark,
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
backlinks,
}) {
const [mode, setMode] = useState("text");
const canvasRef = useRef(null);
const [tool, setTool] = useState("pen");
const [penColor, setPenColor] = useState(dark ? "#e6edf3" : "#1a1a2e");
const [penSizeIdx, setPenSizeIdx] = useState(DEFAULT_PEN_SIZE);
const [undoStack, setUndoStack] = useState([]);
const [redoStack, setRedoStack] = useState([]);
const [showColors, setShowColors] = useState(false);
const [showSizes, setShowSizes] = useState(false);
const [hideDrawing, setHideDrawing] = useState(false);
const [drawingBlend, setDrawingBlend] = useState({ mode: "normal", opacity: 1 });
const [isMobileViewport, setIsMobileViewport] = useState(window.innerWidth < 768);
const stageRef = useRef(null);
const saveTimerRef = useRef(null);
const dirtyRef = useRef(false);
const SAVE_DEBOUNCE_MS = 600;
const [stageSize, setStageSize] = useState({ w: CANON_W, h: CANON_H });
/* ── DRAWING STATE ──
* strokesRef is what the pointer handlers read and write synchronously;
* `strokes` mirrors it so React can repaint the read-only layer. */
const strokesRef = useRef([]);
const [strokes, setStrokes] = useState([]);
const liveRef = useRef(null); // stroke currently under the pointer
const drawingRef = useRef(false);
const committedRef = useRef(null); // offscreen canvas holding all finished strokes
const appliedDocRef = useRef(undefined);
const pendingTokenRef = useRef(null);
const hasStrokes = strokes.length > 0;
// Old notes may still carry a raster drawing. It stays visible underneath any
// new vector strokes so migrating a note never loses what was already drawn.
const legacyRaster = note.drawingData && !hideDrawing;
const commitStrokes = (next) => {
strokesRef.current = next;
setStrokes(next);
};
const T = useMemo(
() => ({
bg: dark ? "#0d1117" : "#ffffff",
toolbar: dark ? "#161b22" : "#f6f8fa",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
active: dark ? "#388bfd" : "#0969da",
faint: dark ? "#484f58" : "#afb8c1",
}),
[dark]
);
/* The note body. Structured TipTap document — tables, task lists and note
* links are real nodes, not HTML that happened to survive execCommand. */
const [editorNotice, setEditorNotice] = useState("");
const textEditor = useNoteEditor({
noteId: note.id,
content: note.content,
editable: mode === "text",
searchLinkTargets,
onChange: ({ content, links }) => onUpdate({ content, links }),
onError: (msg) => {
setEditorNotice(msg);
setTimeout(() => setEditorNotice(""), 5000);
},
});
useEffect(() => {
setHideDrawing(false);
const isLegacyRaster =
typeof note.drawingData === "string" && /^data:image\/(jpeg|jpg|webp)/i.test(note.drawingData);
if (!note.drawingData) {
setDrawingBlend({ mode: "normal", opacity: 1 });
return;
}
let cancelled = false;
const img = new Image();
img.onload = () => {
if (cancelled) return;
try {
const c = document.createElement("canvas");
c.width = 32;
c.height = 32;
const cx = c.getContext("2d", { willReadFrequently: true });
if (!cx) throw new Error("ctx");
cx.drawImage(img, 0, 0, 32, 32);
const data = cx.getImageData(0, 0, 32, 32).data;
let solid = 0,
black = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 245) continue;
solid++;
if (data[i] < 16 && data[i + 1] < 16 && data[i + 2] < 16) black++;
}
const blackRatio = solid ? black / solid : 0;
if (blackRatio > 0.9) setDrawingBlend({ mode: "screen", opacity: 0.62 });
else if (isLegacyRaster) setDrawingBlend({ mode: "screen", opacity: 0.42 });
else setDrawingBlend({ mode: "normal", opacity: 1 });
} catch {
setDrawingBlend(isLegacyRaster ? { mode: "screen", opacity: 0.42 } : { mode: "normal", opacity: 1 });
}
};
img.onerror = () => {
if (!cancelled) setHideDrawing(true);
};
img.src = note.drawingData;
return () => {
cancelled = true;
};
}, [note.id, note.drawingData]);
// Keep one fixed-aspect drawing stage regardless of device dimensions.
useEffect(() => {
const fitStage = () => {
const host = stageRef.current;
if (!host) return;
const W = Math.max(1, host.clientWidth);
const mobile = window.innerWidth < 768;
setIsMobileViewport(mobile);
const sidePad = mobile ? 10 : 28;
const desktopMax = 760;
const minW = mobile ? 220 : 320;
const maxAllowed = Math.max(220, W - sidePad * 2);
const targetW = mobile ? maxAllowed : Math.min(desktopMax, maxAllowed);
const w = Math.floor(Math.max(minW, targetW));
const h = Math.floor((w * CANON_H) / CANON_W);
setStageSize({ w, h });
};
fitStage();
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(fitStage);
if (stageRef.current) ro.observe(stageRef.current);
window.addEventListener("resize", fitStage);
return () => {
ro.disconnect();
window.removeEventListener("resize", fitStage);
};
}
window.addEventListener("resize", fitStage);
return () => window.removeEventListener("resize", fitStage);
}, []);
const saveDrawingNow = () => {
if (!dirtyRef.current) return;
const token = newSaveToken();
const doc = strokeDocOf(strokesRef.current, token);
appliedDocRef.current = doc; // ignore the echo coming straight back through props
pendingTokenRef.current = token; // ...and any server read still in flight
dirtyRef.current = false;
onUpdate({ drawingStrokes: doc });
};
const scheduleDrawingSave = () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
saveDrawingNow();
}, SAVE_DEBOUNCE_MS);
};
/* ── DRAWING: painting ── */
const renderFrame = () => {
const c = canvasRef.current;
if (!c) return;
const ctx = c.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, c.width, c.height);
if (committedRef.current) ctx.drawImage(committedRef.current, 0, 0);
const live = liveRef.current;
if (live) {
ctx.save();
ctx.scale(c.width / CANON_W, c.height / CANON_H);
ctx.lineCap = "round";
ctx.lineJoin = "round";
paintStroke(ctx, live);
ctx.restore();
}
};
// Repaint the offscreen canvas from scratch, then show it. Cheap enough at
// stroke boundaries, and it keeps eraser strokes ordered correctly.
const repaintAll = () => {
const c = canvasRef.current;
if (!c) return;
const { bw, bh } = sizeCanvas(c, stageSize.w, stageSize.h);
let off = committedRef.current;
if (!off) {
off = document.createElement("canvas");
committedRef.current = off;
}
if (off.width !== bw || off.height !== bh) {
off.width = bw;
off.height = bh;
}
paintStrokeDoc(off.getContext("2d"), strokeDocOf(strokesRef.current), bw, bh);
renderFrame();
};
/* Adopt strokes arriving from props (note switch, or a sync push from another
* device) without throwing away strokes we have not saved yet. */
useEffect(() => {
const doc = note.drawingStrokes || null;
if (appliedDocRef.current === doc) return;
appliedDocRef.current = doc;
if (dirtyRef.current) return; // strokes in hand that are not saved yet
if (pendingTokenRef.current) {
// A save is on its way to the server. Until it comes back, any doc we get
// is a read that started before it — adopting it would drop fresh strokes.
if (doc?.t !== pendingTokenRef.current) return;
pendingTokenRef.current = null; // our own save echoed back; in sync again
return;
}
commitStrokes(docStrokes(doc));
setUndoStack([]);
setRedoStack([]);
repaintAll();
}, [note.id, note.drawingStrokes]);
useEffect(() => {
if (mode === "draw") repaintAll();
}, [mode, stageSize.w, stageSize.h]);
/* ── DRAWING: input ── */
const canonPos = (e) => {
const r = canvasRef.current.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { x: (t.clientX - r.left) * (CANON_W / r.width), y: (t.clientY - r.top) * (CANON_H / r.height) };
};
const currentStrokeWidth = () => {
const base = PEN_SIZES[penSizeIdx].w;
if (tool === "highlighter") return base * HIGHLIGHTER_FACTOR;
if (tool === "eraser") return base * ERASER_FACTOR;
return base;
};
const r1 = (n) => Math.round(n * 10) / 10;
const startDraw = (e) => {
if (mode !== "draw") return;
e.preventDefault();
const p = canonPos(e);
liveRef.current = { t: tool, c: penColor, s: r1(currentStrokeWidth()), p: [r1(p.x), r1(p.y)] };
drawingRef.current = true;
renderFrame();
};
const doDraw = (e) => {
if (!drawingRef.current) return;
e.preventDefault();
const p = canonPos(e),
live = liveRef.current,
n = live.p.length;
const dx = p.x - live.p[n - 2],
dy = p.y - live.p[n - 1];
if (dx * dx + dy * dy < MIN_POINT_DIST * MIN_POINT_DIST) return;
live.p.push(r1(p.x), r1(p.y));
renderFrame();
};
const stopDraw = (e) => {
if (!drawingRef.current) return;
e?.preventDefault();
drawingRef.current = false;
const live = liveRef.current;
liveRef.current = null;
if (!live) {
renderFrame();
return;
}
pushUndo();
commitStrokes([...strokesRef.current, live]);
dirtyRef.current = true;
repaintAll();
scheduleDrawingSave();
};
/* ── DRAWING: history ── */
const pushUndo = () => {
setUndoStack((p) => [...p.slice(-30), strokesRef.current]);
setRedoStack([]);
};
const applyHistory = (next) => {
commitStrokes(next);
dirtyRef.current = true;
repaintAll();
scheduleDrawingSave();
};
const undo = () => {
if (!undoStack.length) return;
const prev = undoStack[undoStack.length - 1];
setUndoStack((p) => p.slice(0, -1));
setRedoStack((p) => [...p, strokesRef.current]);
applyHistory(prev);
};
const redo = () => {
if (!redoStack.length) return;
const next = redoStack[redoStack.length - 1];
setRedoStack((p) => p.slice(0, -1));
setUndoStack((p) => [...p, strokesRef.current]);
applyHistory(next);
};
const clearCanvas = () => {
pushUndo();
applyHistory([]);
};
/* Save drawing on mode switch and debounced stroke-end updates. */
const switchMode = (m) => {
if (mode === "draw" && m !== "draw" && dirtyRef.current) {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
saveDrawingNow();
}
setMode(m);
setShowColors(false);
setShowSizes(false);
};
useEffect(
() => () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveDrawingNow();
},
[]
);
/* ── TEXT ──
* Image paste, drag-to-resize and the execCommand toolbar used to live
* here. TipTap owns the document now, so formatting goes through editor
* commands rather than a deprecated browser API. */
/* ── RENDER ── */
const tb = (a) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
borderRadius: 6,
border: "none",
cursor: "pointer",
background: a ? T.active + "22" : "transparent",
color: a ? T.active : T.text,
transition: "all .12s",
flexShrink: 0,
});
const mb = (m, ic, lb) => (
<button
onClick={() => switchMode(m)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
borderRadius: 8,
fontSize: 12,
fontWeight: 600,
border: `1.5px solid ${mode === m ? T.active : T.border}`,
background: mode === m ? T.active + "15" : "transparent",
color: mode === m ? T.active : T.muted,
cursor: "pointer",
flexShrink: 0,
}}
>
{ic}
<span>{lb}</span>
</button>
);
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
background: isMobileViewport
? T.bg
: dark
? "linear-gradient(180deg,#0f141b 0%,#0d1117 60%)"
: "linear-gradient(180deg,#f8fafd 0%,#eef3f8 70%)",
}}
>
{/* Main toolbar */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "7px 14px",
borderBottom: `1px solid ${T.border}`,
background: T.toolbar,
flexWrap: "wrap",
minHeight: 46,
}}
>
{mb("text", I.text, "Write")}
{mb("draw", I.draw, "Draw")}
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px", flexShrink: 0 }} />
{mode === "text" ? (
<EditorToolbar
editor={textEditor}
T={{ ...T, danger: "#e5534b" }}
icons={I}
compact={isMobileViewport}
/>
) : (
<>
<button style={tb(tool === "pen")} onClick={() => setTool("pen")}>
{I.pen}
</button>
<button style={tb(tool === "highlighter")} onClick={() => setTool("highlighter")}>
{I.highlight}
</button>
<button style={tb(tool === "eraser")} onClick={() => setTool("eraser")}>
{I.eraser}
</button>
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px" }} />
<button style={tb()} onClick={undo}>
{I.undo}
</button>
<button style={tb()} onClick={redo}>
{I.redo}
</button>
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px" }} />
<div style={{ position: "relative" }}>
<button
style={tb(showColors)}
onClick={() => {
setShowColors(!showColors);
setShowSizes(false);
}}
>
<div
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: penColor,
border: `2px solid ${T.border}`,
}}
/>
</button>
{showColors && (
<div
style={{
position: "absolute",
top: 36,
left: 0,
background: T.toolbar,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 8,
display: "grid",
gridTemplateColumns: "repeat(5,1fr)",
gap: 4,
zIndex: 100,
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
}}
>
{COLORS.map((c) => (
<button
key={c}
onClick={() => {
setPenColor(c);
setShowColors(false);
}}
style={{
width: 26,
height: 26,
borderRadius: "50%",
background: c,
border: penColor === c ? `3px solid ${T.active}` : `1px solid ${T.border}`,
cursor: "pointer",
}}
/>
))}
</div>
)}
</div>
<div style={{ position: "relative" }}>
<button
style={tb(showSizes)}
onClick={() => {
setShowSizes(!showSizes);
setShowColors(false);
}}
>
<div
style={{
width: Math.min(4 + penSizeIdx * 2, 16),
height: Math.min(4 + penSizeIdx * 2, 16),
borderRadius: "50%",
background: T.text,
}}
/>
</button>
{showSizes && (
<div
style={{
position: "absolute",
top: 36,
left: 0,
background: T.toolbar,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 8,
display: "flex",
flexDirection: "column",
gap: 4,
zIndex: 100,
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
}}
>
{PEN_SIZES.map((s, i) => (
<button
key={s.label}
onClick={() => {
setPenSizeIdx(i);
setShowSizes(false);
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "3px 8px",
borderRadius: 6,
border: "none",
cursor: "pointer",
background: penSizeIdx === i ? T.active + "22" : "transparent",
}}
>
<div
style={{
width: Math.min(4 + i * 3, 22),
height: Math.min(4 + i * 3, 22),
borderRadius: "50%",
background: T.text,
flexShrink: 0,
}}
/>
<span style={{ fontSize: 11, color: T.muted }}>{s.label}</span>
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button style={{ ...tb(), color: "#e5534b" }} onClick={clearCanvas}>
{I.trash}
</button>
</>
)}
</div>
{editorNotice && (
<div
style={{
padding: "7px 14px",
background: "#e5534b18",
color: "#e5534b",
fontSize: 12,
borderBottom: `1px solid ${T.border}`,
}}
>
{editorNotice}
</div>
)}
{/* Content area */}
<div ref={stageRef} style={{ flex: 1, position: "relative", overflow: "hidden" }}>
<div
style={{
position: "absolute",
inset: 0,
overflowY: "auto",
WebkitOverflowScrolling: "touch",
zIndex: 1,
padding: isMobileViewport ? "10px 0 18px" : "18px 0 28px",
}}
>
<div
style={{
position: "relative",
width: stageSize.w,
minHeight: stageSize.h,
margin: isMobileViewport ? "0 auto" : "0 0 0 28px",
background: T.bg,
border: isMobileViewport ? "none" : `1px solid ${T.border}`,
borderRadius: isMobileViewport ? 0 : 14,
boxShadow: isMobileViewport ? "none" : "0 16px 36px rgba(0,0,0,0.14)",
}}
>
<div
style={{
minHeight: stageSize.h,
width: "100%",
pointerEvents: mode === "text" ? "auto" : "none",
}}
>
<EditorSurface
editor={textEditor}
dark={dark}
minHeight={stageSize.h}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
/>
</div>
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
{legacyRaster && (
<img
src={note.drawingData}
alt=""
onError={() => setHideDrawing(true)}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: stageSize.h,
pointerEvents: "none",
objectFit: "fill",
zIndex: 2,
opacity: drawingBlend.opacity,
mixBlendMode: drawingBlend.mode,
}}
/>
)}
{/* Saved drawing — re-rendered from vectors, so identical on every screen */}
{hasStrokes && mode !== "draw" && (
<DrawingLayer strokes={strokes} width={stageSize.w} height={stageSize.h} zIndex={2} />
)}
{mode === "draw" && (
<canvas
ref={canvasRef}
onMouseDown={startDraw}
onMouseMove={doDraw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
onTouchStart={startDraw}
onTouchMove={doDraw}
onTouchEnd={stopDraw}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: stageSize.h,
touchAction: "none",
cursor: "crosshair",
background: "transparent",
zIndex: 3,
}}
/>
)}
</div>
</div>
</div>
{mode === "text" && <BacklinksPanel backlinks={backlinks} onOpenNote={onOpenNote} dark={dark} />}
</div>
);
}
+217
View File
@@ -0,0 +1,217 @@
import { I } from "../../ui/icons.jsx";
import { fmtDate } from "../../ui/format";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
/** The middle column: every note in the selected folder, newest or best match first. */
export function NoteList({
T,
isMobile,
title,
notes,
activeNote,
isTrashView,
trashedCount,
snippetFor,
onToggleSidebar,
onSelectNote,
onNoteContextMenu,
onCreateNote,
onEmptyTrash,
}) {
return (
<div
style={{
width: isMobile ? "100%" : 300,
minWidth: isMobile ? 0 : 300,
flex: isMobile ? 1 : undefined,
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
display: "flex",
flexDirection: "column",
background: T.bg,
fontFamily: FONT_UI,
}}
>
<div
style={{
padding: "14px 16px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={onToggleSidebar}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
}}
>
{I.menu}
</button>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
{title}
</h2>
</div>
<div style={{ display: "flex", gap: 4 }}>
{!isTrashView && (
<button
onClick={onCreateNote}
title="New Note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 10,
border: "none",
background: T.accent,
cursor: "pointer",
color: "#fff",
boxShadow: `0 2px 8px ${T.accent}44`,
}}
>
{I.plus}
</button>
)}
{isTrashView && trashedCount > 0 && (
<button
onClick={onEmptyTrash}
title="Empty Trash"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "6px 12px",
borderRadius: 10,
border: "none",
background: T.danger,
cursor: "pointer",
color: "#fff",
fontSize: 12,
fontWeight: 600,
}}
>
Empty
</button>
)}
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
{notes.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: T.faint,
gap: 10,
padding: 30,
}}
>
<div style={{ fontSize: 36 }}>{isTrashView ? "🗑️" : "📝"}</div>
<div style={{ fontSize: 13, textAlign: "center" }}>
{isTrashView ? "Trash is empty" : "No notes yet"}
</div>
{!isTrashView && (
<button
onClick={onCreateNote}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "10px 20px",
borderRadius: 12,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
marginTop: 8,
}}
>
{I.plus} New Note
</button>
)}
</div>
) : (
notes.map((note) => {
const snippet = snippetFor(note.id);
return (
<button
key={note.id}
onClick={() => onSelectNote(note.id)}
onContextMenu={(e) => onNoteContextMenu(e, note)}
style={{
display: "flex",
flexDirection: "column",
width: "100%",
padding: "12px 14px",
borderRadius: 10,
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
background: activeNote === note.id ? T.accentSoft : "transparent",
cursor: "pointer",
marginBottom: 2,
textAlign: "left",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3, width: "100%" }}
>
<span style={{ fontSize: 13 }}>{note.trashed ? "🗑️" : "📝"}</span>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: T.text,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{note.title}
</span>
{note.starred && !note.trashed && (
<span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>
)}
</div>
{snippet && (
<div
style={{
fontSize: 11,
color: T.textSec,
lineHeight: 1.45,
marginTop: 1,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{snippet}
</div>
)}
<div style={{ fontSize: 10, color: T.faint, marginTop: 3 }}>{fmtDate(note.updatedAt)}</div>
</button>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { useEffect, useRef } from "react";
import type { MobileView } from "./useResponsive";
export interface NavState {
folder: string;
note: string | null;
view: MobileView;
}
const same = (a: NavState, b: NavState) => a.folder === b.folder && a.note === b.note && a.view === b.view;
/**
* Maps the app's current location onto browser history, so the phone's back
* gesture steps back through notes and notebooks instead of leaving the app.
*
* `apply` is called when the user navigates back and should adopt the state it
* is handed.
*/
export function useNavigationHistory(
current: NavState,
isMobile: boolean,
apply: (state: NavState) => void
): void {
const applyRef = useRef(apply);
applyRef.current = apply;
const currentRef = useRef(current);
currentRef.current = current;
const mobileRef = useRef(isMobile);
mobileRef.current = isMobile;
/** What the topmost history entry represents. */
const pushed = useRef<NavState>({ folder: "dashboard", note: null, view: "list" });
useEffect(() => {
if (same(pushed.current, current)) return;
pushed.current = { ...current };
try {
window.history.pushState(pushed.current, "");
} catch {
/* pushState is rate-limited in some browsers; losing an entry is not fatal */
}
}, [current.folder, current.note, current.view]);
useEffect(() => {
const onPop = (e: PopStateEvent) => {
const state = e.state as Partial<NavState> | null;
const cur = currentRef.current;
const view = mobileRef.current ? (state?.view ?? "list") : cur.view;
let next: NavState;
if (state) {
next = { folder: state.folder || "dashboard", note: state.note ?? null, view };
} else if (cur.note) {
// Stepping back past our own entries: close the note, then the notebook.
next = { folder: cur.folder, note: null, view: mobileRef.current ? "list" : cur.view };
} else if (cur.folder !== "dashboard") {
next = { folder: "dashboard", note: null, view: cur.view };
} else {
return;
}
// Record where history now stands *before* applying it. Without this the
// push effect sees a change it did not make and pushes a duplicate entry,
// which makes the back button bounce between two views forever.
pushed.current = next;
applyRef.current(next);
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
}
+302
View File
@@ -0,0 +1,302 @@
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 type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
/** How often the whole account is reconciled with the server. */
const FULL_SYNC_MS = 30_000;
/** How often the open note is re-read, so a second device's edits show up. */
const ACTIVE_NOTE_POLL_MS = 6_000;
/** After this long without a body arriving, show an empty editor rather than a spinner. */
const CONTENT_TIMEOUT_MS = 5_000;
const EMPTY_CONTENT: NoteContent = { content: "", drawingData: null, drawingStrokes: null };
export interface NoteEdit {
title?: string;
content?: string;
/** Ids of notes and notebooks the body links to; stored server-side for backlinks. */
links?: string[];
drawingStrokes?: StrokeDoc | null;
}
export interface NoteData {
notes: NoteMeta[];
notebooks: Notebook[];
noteContents: Record<string, NoteContent>;
/** Notes linking to the currently open one. */
backlinks: Backlink[];
reload: () => Promise<void>;
reset: () => void;
createNote: (folderId: string) => Promise<NoteMeta>;
updateNote: (id: string, edit: NoteEdit) => void;
setNoteContent: (id: string, content: NoteContent) => void;
toggleStar: (id: string) => 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>;
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>;
reorderNotebooks: (dragId: string, targetId: string) => void;
}
/**
* Owns everything the app knows about notes and notebooks: the local copy, the
* mutations that change it, and the background work that keeps it current.
*
* Every mutation writes locally first and hands the change to the sync engine,
* so the UI stays responsive and offline edits are never lost. Confirmation
* prompts are deliberately *not* here — destructive intent is a question for
* the interface, not the store.
*/
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
const [notes, setNotes] = useState<NoteMeta[]>([]);
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
const notesRef = useRef(notes);
notesRef.current = notes;
const notebooksRef = useRef(notebooks);
notebooksRef.current = notebooks;
const contentsRef = useRef(noteContents);
contentsRef.current = noteContents;
const reload = useCallback(async () => {
setNotebooks(await sync.getNotebooks());
setNotes(await sync.getNotes());
}, []);
const reset = useCallback(() => {
setNotes([]);
setNotebooks([]);
setNoteContents({});
setBacklinks([]);
}, []);
/* ── Background upkeep ── */
// The sync engine tells us when it has written to IndexedDB behind our back.
// Loaded bodies are kept as they are: replacing them here would make the
// editor flicker on every poll.
useEffect(() => sync.onDataChange(reload), [reload]);
useEffect(() => {
const i = setInterval(() => {
if (sync.isOnline()) void sync.fullSync().then(reload).catch(noop);
}, FULL_SYNC_MS);
return () => clearInterval(i);
}, [reload]);
/* ── The open note's body ── */
const hasContent = activeNote != null && noteContents[activeNote] !== undefined;
// Fetched on demand: on a cold start every body is missing, and loading them
// all up front would be slow and mostly wasted.
useEffect(() => {
if (!activeNote || hasContent) return;
let cancelled = false;
sync
.loadNoteContent(activeNote)
.then((c) => {
if (cancelled) return;
setNoteContents((p) => ({ ...p, [activeNote]: c }));
const title = notesRef.current.find((n) => n.id === activeNote)?.title || "";
searchIndex.setEntry(activeNote, title, c.content);
})
.catch(() => {
if (!cancelled) setNoteContents((p) => ({ ...p, [activeNote]: EMPTY_CONTENT }));
});
return () => {
cancelled = true;
};
}, [activeNote, hasContent]);
// Hard fallback: an unreachable server must not leave the editor stuck.
useEffect(() => {
if (!activeNote || hasContent) return;
const t = setTimeout(() => {
setNoteContents((p) => (p[activeNote] ? p : { ...p, [activeNote]: EMPTY_CONTENT }));
}, CONTENT_TIMEOUT_MS);
return () => clearTimeout(t);
}, [activeNote, hasContent]);
// Keep the open note fresh across devices.
useEffect(() => {
if (!user || !activeNote) return;
const i = setInterval(() => {
if (!sync.isOnline()) return;
// Never adopt a response while this note has writes on the way out: it
// was requested before them and would undo what was just typed.
if (sync.hasPendingWrites(activeNote)) return;
sync
.loadNoteContent(activeNote, { forceRemote: true })
.then((c) => {
setNoteContents((p) => {
const prev = p[activeNote];
if (
prev &&
prev.content === c.content &&
prev.drawingData === c.drawingData &&
sameStrokeDoc(prev.drawingStrokes, c.drawingStrokes)
)
return p;
return { ...p, [activeNote]: c };
});
})
.catch(noop);
}, ACTIVE_NOTE_POLL_MS);
return () => clearInterval(i);
}, [user, activeNote]);
const activeBody = activeNote ? noteContents[activeNote]?.content : undefined;
useEffect(() => {
if (!activeNote || !user) {
setBacklinks([]);
return;
}
let cancelled = false;
sync
.getBacklinks(activeNote)
.then((r) => {
if (!cancelled) setBacklinks(r);
})
.catch(noop);
return () => {
cancelled = true;
};
}, [activeNote, user, activeBody]);
/* ── 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;
}, []);
const updateNote = useCallback((id: string, edit: NoteEdit) => {
if (edit.title !== undefined) {
const title = edit.title;
const updatedAt = new Date().toISOString();
setNotes((p) => p.map((n) => (n.id === id ? { ...n, title, updatedAt } : n)));
void sync.updateNoteTitle(id, title);
searchIndex.setEntry(id, title, contentsRef.current[id]?.content);
}
if (edit.content !== undefined) {
const content = edit.content;
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
void sync.updateNoteContent(id, content, edit.links);
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 } }));
void sync.updateNoteDrawing(id, drawingStrokes);
}
}, []);
/** Replaces a body wholesale — used when restoring an older version. */
const setNoteContent = useCallback((id: string, content: NoteContent) => {
setNoteContents((p) => ({ ...p, [id]: content }));
}, []);
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);
setNotes((p) => p.filter((n) => n.id !== id));
}, []);
const emptyTrash = useCallback(async () => {
await sync.emptyTrashLocal();
for (const n of notesRef.current) if (n.trashed) searchIndex.removeEntry(n.id);
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)));
}, []);
/* ── Notebooks ── */
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
const nb = await sync.createNotebook(fields);
setNotebooks((p) => [...p, nb]);
return nb;
}, []);
const updateNotebook = useCallback(async (id: string, updates: Partial<Notebook>) => {
await sync.updateNotebook(id, updates);
setNotebooks((p) => p.map((nb) => (nb.id === id ? { ...nb, ...updates } : nb)));
}, []);
/** Deleting a notebook trashes its notes rather than destroying them. */
const deleteNotebook = useCallback(async (id: string) => {
await sync.deleteNotebookLocal(id);
setNotes((p) => p.map((n) => (n.folderId === id ? { ...n, trashed: true } : n)));
setNotebooks((p) => p.filter((nb) => nb.id !== id));
}, []);
const reorderNotebooks = useCallback((dragId: string, targetId: string) => {
const list = [...notebooksRef.current];
const from = list.findIndex((n) => n.id === dragId);
const to = list.findIndex((n) => n.id === targetId);
if (from < 0 || to < 0 || from === to) return;
const [moved] = list.splice(from, 1);
if (!moved) return;
list.splice(to, 0, moved);
const reordered = list.map((n, i) => ({ ...n, sortOrder: i }));
setNotebooks(reordered);
void sync.reorderNotebooks(reordered.map((n) => n.id));
}, []);
return {
notes,
notebooks,
noteContents,
backlinks,
reload,
reset,
createNote,
updateNote,
setNoteContent,
toggleStar,
trashNote,
restoreNote,
permanentDeleteNote,
emptyTrash,
moveNote,
createNotebook,
updateNotebook,
deleteNotebook,
reorderNotebooks,
};
}
const noop = () => {};
+48
View File
@@ -0,0 +1,48 @@
import { useCallback, useRef, useState } from "react";
import type { DragEvent } from "react";
export interface NotebookDnd {
/** Notebook currently under the pointer, for the drop indicator. */
overId: string | null;
onDragStart: (e: DragEvent, id: string) => void;
onDragOver: (e: DragEvent, id: string) => void;
onDrop: (e: DragEvent, id: string) => void;
onDragEnd: () => void;
}
/**
* Drag-to-reorder for the notebook list, shared by the sidebar and the
* dashboard so a notebook can be dragged in either place.
*/
export function useNotebookDnd(reorder: (dragId: string, targetId: string) => void): NotebookDnd {
const [overId, setOverId] = useState<string | null>(null);
// The dragged id is not rendered, so a ref avoids a re-render per dragover.
const dragId = useRef<string | null>(null);
const onDragStart = useCallback((e: DragEvent, id: string) => {
dragId.current = id;
e.dataTransfer.effectAllowed = "move";
}, []);
const onDragOver = useCallback((e: DragEvent, id: string) => {
e.preventDefault();
if (dragId.current && dragId.current !== id) setOverId(id);
}, []);
const clear = useCallback(() => {
dragId.current = null;
setOverId(null);
}, []);
const onDrop = useCallback(
(e: DragEvent, targetId: string) => {
e.preventDefault();
const from = dragId.current;
if (from && from !== targetId) reorder(from, targetId);
clear();
},
[reorder, clear]
);
return { overId, onDragStart, onDragOver, onDrop, onDragEnd: clear };
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
/** Chrome's non-standard install event. Not in lib.dom, so it is declared here. */
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export interface PwaInstall {
/** Non-null only while the browser is willing to show an install prompt. */
canInstall: boolean;
promptInstall: () => Promise<void>;
}
/**
* Captures the browser's install offer so the app can present it at a sensible
* moment instead of letting the browser choose one.
*/
export function usePwaInstall(): PwaInstall {
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
useEffect(() => {
const onPrompt = (e: Event) => {
e.preventDefault();
setEvent(e as BeforeInstallPromptEvent);
};
window.addEventListener("beforeinstallprompt", onPrompt);
return () => window.removeEventListener("beforeinstallprompt", onPrompt);
}, []);
const promptInstall = async () => {
if (!event) return;
await event.prompt();
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);
};
return { canInstall: event !== null, promptInstall };
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
const MOBILE_BREAKPOINT = 768;
export type MobileView = "list" | "editor";
export interface Responsive {
isMobile: boolean;
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
/** On a phone, list and editor share the screen; this says which one has it. */
mobileView: MobileView;
setMobileView: (view: MobileView) => void;
}
/**
* Tracks whether we are on a phone-sized screen and keeps the sidebar's
* open/closed state consistent with it: on a desktop the sidebar is always
* available, on a phone it is an overlay that starts closed.
*/
export function useResponsive(): Responsive {
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > MOBILE_BREAKPOINT);
const [mobileView, setMobileView] = useState<MobileView>("list");
useEffect(() => {
const onResize = () => {
const mobile = window.innerWidth < MOBILE_BREAKPOINT;
setIsMobile(mobile);
// Growing past the breakpoint must not leave the sidebar hidden with no
// way to bring it back.
if (!mobile) setSidebarOpen(true);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView };
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as sync from "../syncEngine";
import * as searchIndex from "../search";
import type { NoteMeta, SearchHit, User } from "../types";
const DEBOUNCE_MS = 120;
/** Refresh visible results every N notes while the backfill is still running. */
const PROGRESS_EVERY = 10;
export interface SearchState {
/** null means "not searching"; an empty array means "searched, found nothing". */
results: SearchHit[] | null;
/** Excerpt around the match, empty when the note is not a hit. */
snippetFor: (id: string) => string;
}
/**
* Full-text search over the local copy of every note.
*
* The server stores bodies encrypted and cannot index them, so the index is
* built here. Bodies normally reach the client only when a note is opened, so
* the missing ones are backfilled in the background after login.
*/
export function useSearchIndex(user: User | null, notes: NoteMeta[], query: string): SearchState {
const [results, setResults] = useState<SearchHit[] | null>(null);
const [version, setVersion] = useState(0);
const running = useRef(false);
// Read by the backfill so it sees current titles without being torn down and
// restarted every time a note changes.
const notesRef = useRef(notes);
notesRef.current = notes;
useEffect(() => {
// Waits for the note list: starting before the first sync would find
// nothing to fetch and never run again.
if (!user || notes.length === 0 || running.current) return;
running.current = true;
let cancelled = false;
const signal = { aborted: false };
void (async () => {
await searchIndex.rebuild();
if (!cancelled) setVersion((v) => v + 1);
await sync.prefetchAllContent({
signal,
onProgress: ({ id, content, fetched }) => {
const note = notesRef.current.find((n) => n.id === id);
searchIndex.setEntry(id, note?.title || "", content);
if (fetched % PROGRESS_EVERY === 0 && !cancelled) setVersion((v) => v + 1);
},
});
if (!cancelled) setVersion((v) => v + 1);
running.current = false;
})();
return () => {
cancelled = true;
signal.aborted = true;
};
}, [user, notes.length]);
useEffect(() => {
const q = query.trim();
if (!q) {
setResults(null);
return;
}
// Debounced so typing does not re-scan the index on every keystroke.
const t = setTimeout(() => setResults(searchIndex.search(q)), DEBOUNCE_MS);
return () => clearTimeout(t);
}, [query, version]);
const snippets = useMemo(() => {
const m = new Map<string, string>();
for (const r of results || []) if (r.snippet) m.set(r.id, r.snippet);
return m;
}, [results]);
const snippetFor = useMemo(() => (id: string) => snippets.get(id) || "", [snippets]);
return { results, snippetFor };
}
+16 -13
View File
@@ -1,14 +1,17 @@
import React from 'react' import React from "react";
import ReactDOM from 'react-dom/client' import ReactDOM from "react-dom/client";
import NoteApp from './NoteApp.jsx' import NoteApp from "./App.jsx";
// Hide splash screen // Hide splash screen
const hideSplash = () => { const hideSplash = () => {
const splash = document.getElementById('splash'); const splash = document.getElementById("splash");
if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 300); } if (splash) {
splash.classList.add("hide");
setTimeout(() => splash.remove(), 300);
}
}; };
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<NoteApp /> <NoteApp />
</React.StrictMode> </React.StrictMode>
@@ -17,17 +20,17 @@ ReactDOM.createRoot(document.getElementById('root')).render(
setTimeout(hideSplash, 200); setTimeout(hideSplash, 200);
// Register service worker // Register service worker
if ('serviceWorker' in navigator) { if ("serviceWorker" in navigator) {
window.addEventListener('load', async () => { window.addEventListener("load", async () => {
try { try {
// Use the page's base URL as scope so it works behind any reverse proxy path // Use the page's base URL as scope so it works behind any reverse proxy path
const swUrl = new URL('./sw.js', window.location.href).href; const swUrl = new URL("./sw.js", window.location.href).href;
const scope = new URL('./', window.location.href).href; const scope = new URL("./", window.location.href).href;
const reg = await navigator.serviceWorker.register(swUrl, { scope }); const reg = await navigator.serviceWorker.register(swUrl, { scope });
console.log('[SW] Registered, scope:', reg.scope); console.log("[SW] Registered, scope:", reg.scope);
setInterval(() => reg.update(), 60 * 60 * 1000); setInterval(() => reg.update(), 60 * 60 * 1000);
} catch (err) { } catch (err) {
console.warn('[SW] Registration failed:', err); console.warn("[SW] Registration failed:", err);
} }
}); });
} }
-204
View File
@@ -1,204 +0,0 @@
/**
* InkNote Offline Store — IndexedDB local-first storage
*
* All reads/writes go here first (instant, works offline).
* Sync engine flushes changes to server when online.
*/
const DB_NAME = 'inknote';
const DB_VERSION = 2;
let db = null;
function open() {
return new Promise((resolve, reject) => {
if (db) return resolve(db);
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const d = e.target.result;
// Notebooks store
if (!d.objectStoreNames.contains('notebooks')) {
const nb = d.createObjectStore('notebooks', { keyPath: 'id' });
nb.createIndex('sortOrder', 'sortOrder');
}
// Notes metadata store
if (!d.objectStoreNames.contains('notes')) {
const n = d.createObjectStore('notes', { keyPath: 'id' });
n.createIndex('folderId', 'folderId');
n.createIndex('trashed', 'trashed');
n.createIndex('updatedAt', 'updatedAt');
}
// Note content store (separate for lazy loading)
if (!d.objectStoreNames.contains('noteContents')) {
d.createObjectStore('noteContents', { keyPath: 'id' });
}
// Sync queue — pending changes to push to server
if (!d.objectStoreNames.contains('syncQueue')) {
const sq = d.createObjectStore('syncQueue', { keyPath: 'queueId', autoIncrement: true });
sq.createIndex('entityId', 'entityId');
sq.createIndex('timestamp', 'timestamp');
}
// Key-value meta store (token, user, last sync time, etc)
if (!d.objectStoreNames.contains('meta')) {
d.createObjectStore('meta', { keyPath: 'key' });
}
};
req.onsuccess = (e) => { db = e.target.result; resolve(db); };
req.onerror = (e) => reject(e.target.error);
});
}
// ─── Generic helpers ───
async function tx(stores, mode, fn) {
const d = await open();
return new Promise((resolve, reject) => {
const t = d.transaction(stores, mode);
const result = fn(t);
t.oncomplete = () => resolve(result._value);
t.onerror = (e) => reject(e.target.error);
});
}
function reqToPromise(req) {
return new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = (e) => rej(e.target.error); });
}
// ─── Meta (key-value) ───
export async function getMeta(key) {
const d = await open();
const r = await reqToPromise(d.transaction('meta', 'readonly').objectStore('meta').get(key));
return r?.value ?? null;
}
export async function setMeta(key, value) {
const d = await open();
await reqToPromise(d.transaction('meta', 'readwrite').objectStore('meta').put({ key, value }));
}
export async function deleteMeta(key) {
const d = await open();
await reqToPromise(d.transaction('meta', 'readwrite').objectStore('meta').delete(key));
}
// ─── Notebooks ───
export async function getAllNotebooks() {
const d = await open();
const all = await reqToPromise(d.transaction('notebooks', 'readonly').objectStore('notebooks').getAll());
return all.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
export async function putNotebook(nb) {
const d = await open();
await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').put(nb));
}
export async function putNotebooks(list) {
const d = await open();
const t = d.transaction('notebooks', 'readwrite');
const s = t.objectStore('notebooks');
for (const nb of list) s.put(nb);
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
export async function deleteNotebook(id) {
const d = await open();
await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').delete(id));
}
export async function clearNotebooks() {
const d = await open();
await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').clear());
}
// ─── Notes (metadata) ───
export async function getAllNotes() {
const d = await open();
return reqToPromise(d.transaction('notes', 'readonly').objectStore('notes').getAll());
}
export async function getNote(id) {
const d = await open();
return reqToPromise(d.transaction('notes', 'readonly').objectStore('notes').get(id));
}
export async function putNote(note) {
const d = await open();
await reqToPromise(d.transaction('notes', 'readwrite').objectStore('notes').put(note));
}
export async function putNotes(list) {
const d = await open();
const t = d.transaction('notes', 'readwrite');
const s = t.objectStore('notes');
for (const n of list) s.put(n);
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
export async function deleteNote(id) {
const d = await open();
const t = d.transaction(['notes', 'noteContents'], 'readwrite');
t.objectStore('notes').delete(id);
t.objectStore('noteContents').delete(id);
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
export async function clearNotes() {
const d = await open();
const t = d.transaction(['notes'], 'readwrite');
t.objectStore('notes').clear();
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
// ─── Note content (lazy loaded) ───
export async function getNoteContent(id) {
const d = await open();
return reqToPromise(d.transaction('noteContents', 'readonly').objectStore('noteContents').get(id));
}
export async function putNoteContent(id, content, drawingData) {
const d = await open();
await reqToPromise(d.transaction('noteContents', 'readwrite').objectStore('noteContents').put({ id, content, drawingData }));
}
// ─── Sync Queue ───
export async function enqueue(action) {
const d = await open();
const entry = { ...action, timestamp: Date.now() };
await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').add(entry));
}
export async function peekQueue(limit = 50) {
const d = await open();
return reqToPromise(d.transaction('syncQueue', 'readonly').objectStore('syncQueue').getAll(null, limit));
}
export async function dequeue(queueId) {
const d = await open();
await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').delete(queueId));
}
export async function clearQueue() {
const d = await open();
await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').clear());
}
export async function getQueueSize() {
const d = await open();
return reqToPromise(d.transaction('syncQueue', 'readonly').objectStore('syncQueue').count());
}
// ─── Full wipe (for logout) ───
export async function clearAll() {
const d = await open();
const stores = ['notebooks', 'notes', 'noteContents', 'syncQueue', 'meta'];
const t = d.transaction(stores, 'readwrite');
stores.forEach((s) => t.objectStore(s).clear());
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, beforeEach } from "vitest";
import * as db from "./offlineStore";
import type { NoteMeta, Notebook } from "./types";
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
id,
title: `Note ${id}`,
folderId: "nb1",
starred: false,
trashed: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...over,
});
const notebook = (id: string, sortOrder = 0): Notebook => ({ id, name: `NB ${id}`, cover: 0, sortOrder });
beforeEach(async () => {
await db.clearAll();
});
describe("notes", () => {
it("stores and reads back a note", async () => {
await db.putNote(note("a"));
expect(await db.getNote("a")).toMatchObject({ id: "a", title: "Note a" });
});
it("returns undefined for a note that does not exist", async () => {
expect(await db.getNote("nope")).toBeUndefined();
});
it("writes a batch in one transaction", async () => {
await db.putNotes([note("a"), note("b"), note("c")]);
expect((await db.getAllNotes()).length).toBe(3);
});
it("deleting a note also drops its body, leaving nothing orphaned", async () => {
await db.putNote(note("a"));
await db.putNoteContent("a", "content", null);
await db.deleteNote("a");
expect(await db.getNote("a")).toBeUndefined();
expect(await db.getNoteContent("a")).toBeUndefined();
});
});
describe("note content", () => {
it("keeps content, drawing and revision together", async () => {
const strokes = {
v: 1,
w: 1200,
h: 1600,
strokes: [{ t: "pen" as const, c: "#fff", s: 5, p: [0, 0, 1, 1] }],
};
await db.putNoteContent("a", "body", null, strokes, "2026-02-02T00:00:00.000Z");
const got = await db.getNoteContent("a");
expect(got?.content).toBe("body");
expect(got?.drawingStrokes?.strokes.length).toBe(1);
expect(got?.updatedAt).toBe("2026-02-02T00:00:00.000Z");
});
it("defaults the drawing fields rather than leaving them undefined", async () => {
await db.putNoteContent("a", "body", null);
const got = await db.getNoteContent("a");
expect(got?.drawingStrokes).toBeNull();
expect(got?.updatedAt).toBeNull();
});
it("overwrites an existing body", async () => {
await db.putNoteContent("a", "first", null);
await db.putNoteContent("a", "second", null);
expect((await db.getNoteContent("a"))?.content).toBe("second");
});
});
describe("notebooks", () => {
it("returns notebooks in sort order, not insertion order", async () => {
await db.putNotebooks([notebook("c", 2), notebook("a", 0), notebook("b", 1)]);
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["a", "b", "c"]);
});
});
describe("sync queue", () => {
it("preserves insertion order", async () => {
await db.enqueue({ action: "first", data: {} });
await db.enqueue({ action: "second", data: {} });
expect((await db.peekQueue()).map((e) => e.action)).toEqual(["first", "second"]);
});
it("counts what is waiting", async () => {
await db.enqueue({ action: "a", data: {} });
await db.enqueue({ action: "b", data: {} });
expect(await db.getQueueSize()).toBe(2);
});
it("removes only the dequeued entry", async () => {
await db.enqueue({ action: "keep", data: {} });
await db.enqueue({ action: "drop", data: {} });
const [, second] = await db.peekQueue();
await db.dequeue(second!.queueId);
expect((await db.peekQueue()).map((e) => e.action)).toEqual(["keep"]);
});
it("records a failed attempt without losing the entry — the bug that silently ate offline edits", async () => {
await db.enqueue({ action: "updateNote", data: { id: "a" } });
const [entry] = await db.peekQueue();
await db.updateQueueEntry(entry!.queueId, { attempts: 1, lastError: "boom" });
const [updated] = await db.peekQueue();
expect(updated!.attempts).toBe(1);
expect(updated!.lastError).toBe("boom");
expect(updated!.action).toBe("updateNote");
expect(updated!.data).toEqual({ id: "a" });
});
it("ignores an update for an entry that is already gone", async () => {
await expect(db.updateQueueEntry(9999, { attempts: 1 })).resolves.toBeUndefined();
});
});
describe("attachments", () => {
/* jsdom's Blob is a stub without `arrayBuffer`, and fake-indexeddb's
* structured clone flattens it to `{}` — so real Blob fidelity cannot be
* asserted here. A byte array clones faithfully and still exercises the
* store's own logic; that images survive a round trip through a real browser
* is covered by the end-to-end suite instead. */
it("round-trips binary payloads", async () => {
const bytes = new Uint8Array([1, 2, 3, 250]);
await db.putAttachmentBlob("att1", bytes as unknown as Blob);
const got = (await db.getAttachmentBlob("att1")) as unknown as Uint8Array;
expect(got).not.toBeNull();
expect(Array.from(got)).toEqual([1, 2, 3, 250]);
});
it("returns null for an attachment that was never cached", async () => {
expect(await db.getAttachmentBlob("missing")).toBeNull();
});
});
describe("meta", () => {
it("stores arbitrary values", async () => {
await db.setMeta("user", { id: "u1", username: "marc" });
expect(await db.getMeta("user")).toEqual({ id: "u1", username: "marc" });
});
it("returns null for an unset key", async () => {
expect(await db.getMeta("nothing")).toBeNull();
});
it("deletes a key", async () => {
await db.setMeta("token", "abc");
await db.deleteMeta("token");
expect(await db.getMeta("token")).toBeNull();
});
});
describe("clearAll", () => {
it("wipes every store, as logout must", async () => {
await db.putNote(note("a"));
await db.putNoteContent("a", "body", null);
await db.putNotebook(notebook("nb1"));
await db.enqueue({ action: "x", data: {} });
await db.setMeta("token", "abc");
await db.putAttachmentBlob("att", new Blob(["x"]));
await db.clearAll();
expect(await db.getAllNotes()).toEqual([]);
expect(await db.getNoteContent("a")).toBeUndefined();
expect(await db.getAllNotebooks()).toEqual([]);
expect(await db.getQueueSize()).toBe(0);
expect(await db.getMeta("token")).toBeNull();
expect(await db.getAttachmentBlob("att")).toBeNull();
});
});
+245
View File
@@ -0,0 +1,245 @@
/**
* InkNote Offline Store — IndexedDB local-first storage.
*
* All reads and writes go here first, so the app works offline; the sync engine
* pushes queued changes to the server when a connection is available.
*/
import type { NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
const DB_NAME = "inknote";
const DB_VERSION = 3;
type StoreName = "notebooks" | "notes" | "noteContents" | "syncQueue" | "meta" | "attachments";
let db: IDBDatabase | null = null;
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (db) return resolve(db);
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const d = (e.target as IDBOpenDBRequest).result;
if (!d.objectStoreNames.contains("notebooks")) {
const nb = d.createObjectStore("notebooks", { keyPath: "id" });
nb.createIndex("sortOrder", "sortOrder");
}
if (!d.objectStoreNames.contains("notes")) {
const n = d.createObjectStore("notes", { keyPath: "id" });
n.createIndex("folderId", "folderId");
n.createIndex("trashed", "trashed");
n.createIndex("updatedAt", "updatedAt");
}
// Bodies live apart from metadata so the note list stays cheap to load.
if (!d.objectStoreNames.contains("noteContents")) {
d.createObjectStore("noteContents", { keyPath: "id" });
}
if (!d.objectStoreNames.contains("syncQueue")) {
const sq = d.createObjectStore("syncQueue", { keyPath: "queueId", autoIncrement: true });
sq.createIndex("entityId", "entityId");
sq.createIndex("timestamp", "timestamp");
}
if (!d.objectStoreNames.contains("meta")) {
d.createObjectStore("meta", { keyPath: "key" });
}
// Attachment bytes, so images render offline and download only once.
if (!d.objectStoreNames.contains("attachments")) {
d.createObjectStore("attachments", { keyPath: "id" });
}
};
req.onsuccess = (e) => {
db = (e.target as IDBOpenDBRequest).result;
resolve(db);
};
req.onerror = (e) => reject((e.target as IDBOpenDBRequest).error);
});
}
function reqToPromise<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((res, rej) => {
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
});
}
/** Resolves when the whole transaction commits, not just one request. */
function txDone(t: IDBTransaction): Promise<void> {
return new Promise((res, rej) => {
t.oncomplete = () => res();
t.onerror = () => rej(t.error);
t.onabort = () => rej(t.error ?? new Error("Transaction aborted"));
});
}
async function store(name: StoreName, mode: IDBTransactionMode): Promise<IDBObjectStore> {
const d = await open();
return d.transaction(name, mode).objectStore(name);
}
/* ─── Meta (key-value) ─── */
export async function getMeta<T = unknown>(key: string): Promise<T | null> {
const s = await store("meta", "readonly");
const r = await reqToPromise<{ key: string; value: T } | undefined>(s.get(key));
return r?.value ?? null;
}
export async function setMeta(key: string, value: unknown): Promise<void> {
const s = await store("meta", "readwrite");
await reqToPromise(s.put({ key, value }));
}
export async function deleteMeta(key: string): Promise<void> {
const s = await store("meta", "readwrite");
await reqToPromise(s.delete(key));
}
/* ─── Notebooks ─── */
export async function getAllNotebooks(): Promise<Notebook[]> {
const s = await store("notebooks", "readonly");
const all = await reqToPromise<Notebook[]>(s.getAll());
return all.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
export async function putNotebook(nb: Notebook): Promise<void> {
const s = await store("notebooks", "readwrite");
await reqToPromise(s.put(nb));
}
export async function putNotebooks(list: Notebook[]): Promise<void> {
const d = await open();
const t = d.transaction("notebooks", "readwrite");
const s = t.objectStore("notebooks");
for (const nb of list) s.put(nb);
return txDone(t);
}
export async function deleteNotebook(id: string): Promise<void> {
const s = await store("notebooks", "readwrite");
await reqToPromise(s.delete(id));
}
export async function clearNotebooks(): Promise<void> {
const s = await store("notebooks", "readwrite");
await reqToPromise(s.clear());
}
/* ─── Notes (metadata) ─── */
export async function getAllNotes(): Promise<NoteMeta[]> {
const s = await store("notes", "readonly");
return reqToPromise<NoteMeta[]>(s.getAll());
}
export async function getNote(id: string): Promise<NoteMeta | undefined> {
const s = await store("notes", "readonly");
return reqToPromise<NoteMeta | undefined>(s.get(id));
}
export async function putNote(note: NoteMeta): Promise<void> {
const s = await store("notes", "readwrite");
await reqToPromise(s.put(note));
}
export async function putNotes(list: NoteMeta[]): Promise<void> {
const d = await open();
const t = d.transaction("notes", "readwrite");
const s = t.objectStore("notes");
for (const n of list) s.put(n);
return txDone(t);
}
export async function deleteNote(id: string): Promise<void> {
const d = await open();
const t = d.transaction(["notes", "noteContents"], "readwrite");
t.objectStore("notes").delete(id);
t.objectStore("noteContents").delete(id);
return txDone(t);
}
export async function clearNotes(): Promise<void> {
const s = await store("notes", "readwrite");
await reqToPromise(s.clear());
}
/* ─── Note content (lazily loaded) ─── */
export async function getNoteContent(id: string): Promise<(NoteContent & { id: string }) | undefined> {
const s = await store("noteContents", "readonly");
return reqToPromise<(NoteContent & { id: string }) | undefined>(s.get(id));
}
/**
* `updatedAt` records which server revision this copy came from, so a background
* prefetch can tell a stale cached body from an up-to-date one.
*/
export async function putNoteContent(
id: string,
content: string,
drawingData: string | null,
drawingStrokes: StrokeDoc | null = null,
updatedAt: string | null = null
): Promise<void> {
const s = await store("noteContents", "readwrite");
await reqToPromise(s.put({ id, content, drawingData, drawingStrokes, updatedAt }));
}
/* ─── Attachments (binary cache) ─── */
export async function getAttachmentBlob(id: string): Promise<Blob | null> {
const s = await store("attachments", "readonly");
const r = await reqToPromise<{ id: string; blob: Blob } | undefined>(s.get(id));
return r?.blob ?? null;
}
export async function putAttachmentBlob(id: string, blob: Blob): Promise<void> {
const s = await store("attachments", "readwrite");
await reqToPromise(s.put({ id, blob, cachedAt: Date.now() }));
}
/* ─── Sync queue ─── */
export async function enqueue(action: { action: string; data: Record<string, unknown> }): Promise<void> {
const s = await store("syncQueue", "readwrite");
await reqToPromise(s.add({ ...action, timestamp: Date.now() }));
}
export async function peekQueue(limit = 50): Promise<QueueEntry[]> {
const s = await store("syncQueue", "readonly");
return reqToPromise<QueueEntry[]>(s.getAll(null, limit));
}
export async function dequeue(queueId: number): Promise<void> {
const s = await store("syncQueue", "readwrite");
await reqToPromise(s.delete(queueId));
}
/** Records a failed attempt so a retried entry can eventually be given up on. */
export async function updateQueueEntry(queueId: number, patch: Partial<QueueEntry>): Promise<void> {
const s = await store("syncQueue", "readwrite");
const entry = await reqToPromise<QueueEntry | undefined>(s.get(queueId));
if (!entry) return;
await reqToPromise(s.put({ ...entry, ...patch }));
}
export async function clearQueue(): Promise<void> {
const s = await store("syncQueue", "readwrite");
await reqToPromise(s.clear());
}
export async function getQueueSize(): Promise<number> {
const s = await store("syncQueue", "readonly");
return reqToPromise<number>(s.count());
}
/* ─── Full wipe (on logout) ─── */
export async function clearAll(): Promise<void> {
const d = await open();
const stores: StoreName[] = ["notebooks", "notes", "noteContents", "syncQueue", "meta", "attachments"];
const t = d.transaction(stores, "readwrite");
for (const s of stores) t.objectStore(s).clear();
return txDone(t);
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach } from "vitest";
import * as search from "./search";
import { serializeNoteContent } from "./editor/content";
const body = (text: string) =>
serializeNoteContent({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
const seed = () => {
search.removeEntry("a");
search.removeEntry("b");
search.removeEntry("c");
search.setEntry("a", "Haushalt", body("Der Kühlschrank braucht eine neue Dichtung."));
search.setEntry("b", "Finanzen", body("Steuererklärung abgeben, Belege liegen im Ordner."));
search.setEntry("c", "Dichtung", body("Nur der Titel enthält das Wort."));
};
beforeEach(seed);
describe("search", () => {
it("returns null for an empty query, meaning 'no search'", () => {
expect(search.search("")).toBeNull();
expect(search.search(" ")).toBeNull();
});
it("finds a word that appears only in the body", () => {
const hits = search.search("Belege");
expect(hits?.map((h) => h.id)).toEqual(["b"]);
});
it("ranks a title match above a body match", () => {
const hits = search.search("Dichtung");
expect(hits?.map((h) => h.id)).toEqual(["c", "a"]);
});
it("requires every term to match", () => {
expect(search.search("Belege Ordner")?.map((h) => h.id)).toEqual(["b"]);
expect(search.search("Belege Kühlschrank")).toEqual([]);
});
it("ignores case", () => {
expect(search.search("BELEGE")?.map((h) => h.id)).toEqual(["b"]);
});
it("ignores diacritics, so an ASCII keyboard still finds umlauts", () => {
expect(search.search("kuhlschrank")?.map((h) => h.id)).toEqual(["a"]);
expect(search.search("Steuererklarung")?.map((h) => h.id)).toEqual(["b"]);
});
it("returns a snippet around the match", () => {
const hit = search.search("Dichtung")?.find((h) => h.id === "a");
expect(hit?.snippet).toMatch(/Dichtung/);
});
it("respects the result limit", () => {
for (let i = 0; i < 30; i++) search.setEntry(`x${i}`, `Note ${i}`, body("common word"));
expect(search.search("common", { limit: 5 })?.length).toBe(5);
for (let i = 0; i < 30; i++) search.removeEntry(`x${i}`);
});
it("stops matching a note once it is removed from the index", () => {
expect(search.search("Belege")?.length).toBe(1);
search.removeEntry("b");
expect(search.search("Belege")).toEqual([]);
});
it("reflects an updated body immediately", () => {
search.setEntry("a", "Haushalt", body("Völlig anderer Inhalt jetzt."));
expect(search.search("Kühlschrank")).toEqual([]);
expect(search.search("anderer")?.map((h) => h.id)).toEqual(["a"]);
});
});
describe("contentToText", () => {
it("reads a v2 document", () => {
expect(search.contentToText(body("hello world"))).toBe("hello world");
});
it("reads a v1 body, HTML and task list alike", () => {
const v1 = JSON.stringify({
html: "<p>legacy <b>text</b></p>",
tasks: [{ id: "t", text: "buy milk", done: false }],
});
const text = search.contentToText(v1);
expect(text).toContain("legacy");
expect(text).toContain("buy milk");
});
it("does not execute or leak markup from a v1 body", () => {
const text = search.contentToText(JSON.stringify({ html: "<p>safe</p><script>bad()</script>" }));
expect(text).toContain("safe");
expect(text).not.toContain("<script>");
});
it("survives content that is not valid JSON", () => {
expect(() => search.contentToText("<p>bare html</p>")).not.toThrow();
expect(search.contentToText("<p>bare html</p>")).toContain("bare html");
});
it("returns an empty string for empty input", () => {
expect(search.contentToText(null)).toBe("");
expect(search.contentToText(undefined)).toBe("");
expect(search.contentToText("")).toBe("");
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Local full-text search.
*
* The server stores note bodies encrypted and deliberately keeps no plaintext
* index — so searching has to happen on the client, over the copy already in
* IndexedDB. That also means search keeps working offline.
*
* The index is a plain in-memory map. For a personal note collection that is
* both fast enough and far simpler than a real inverted index: matching a few
* thousand strings costs well under a frame.
*/
import * as db from "./offlineStore";
import { docToText } from "./editor/content";
import type { SearchHit } from "./types";
interface IndexEntry {
/** Normalised title, for matching. */
title: string;
/** Normalised body text, for matching. */
text: string;
/** Original body text, for building the snippet shown to the user. */
raw: string;
}
const _index = new Map<string, IndexEntry>();
/** Lowercases and strips diacritics, so `kuhlschrank` matches `Kühlschrank`. */
const normalize = (s: unknown): string =>
String(s ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "");
/** Plain text of a stored note body, whatever format it is in. */
export function contentToText(raw: string | null | undefined): string {
if (!raw) return "";
let parsed: unknown = null;
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
const obj = parsed as { v?: number; doc?: unknown; html?: unknown; tasks?: unknown } | null;
if (obj && obj.v === 2 && obj.doc) return docToText(obj.doc, 20000);
// v1 — HTML plus a detached task list.
const html = typeof obj?.html === "string" ? obj.html : raw;
const el = document.createElement("div");
el.innerHTML = html;
const tasks = Array.isArray(obj?.tasks)
? (obj.tasks as Array<{ text?: string }>).map((t) => t.text ?? "").join(" ")
: "";
return `${el.textContent || ""} ${tasks}`.replace(/\s+/g, " ").trim();
}
export function setEntry(id: string, title: string, content?: string | null): void {
const raw = contentToText(content);
_index.set(id, { title: normalize(title), text: normalize(raw), raw });
}
export function removeEntry(id: string): void {
_index.delete(id);
}
export function size(): number {
return _index.size;
}
/** Builds the index from whatever is cached locally. */
export async function rebuild(): Promise<number> {
const notes = await db.getAllNotes();
_index.clear();
for (const n of notes) {
const c = await db.getNoteContent(n.id);
setEntry(n.id, n.title, c?.content);
}
return _index.size;
}
/**
* Ranked matches for a query.
*
* All terms must appear (AND). Title matches outrank body matches so that
* searching for a note by name still puts that note first. Returns `null` for
* an empty query, which callers read as "no search, show the plain list".
*/
export function search(query: string, { limit = 200 }: { limit?: number } = {}): SearchHit[] | null {
const terms = normalize(query).split(/\s+/).filter(Boolean);
if (terms.length === 0) return null;
const hits: Array<{ id: string; score: number; entry: IndexEntry }> = [];
for (const [id, entry] of _index) {
let score = 0;
let matchedAll = true;
for (const term of terms) {
const inTitle = entry.title.includes(term);
const inText = entry.text.includes(term);
if (!inTitle && !inText) {
matchedAll = false;
break;
}
if (inTitle) score += entry.title.startsWith(term) ? 12 : 8;
if (inText) score += 1;
}
if (matchedAll) hits.push({ id, score, entry });
}
hits.sort((a, b) => b.score - a.score);
return hits.slice(0, limit).map(({ id, score, entry }) => ({
id,
score,
snippet: snippetFor(entry, terms),
}));
}
/** A short excerpt around the first matching term, for the results list. */
function snippetFor(entry: IndexEntry, terms: string[], radius = 60): string {
const text = entry.raw;
if (!text) return "";
const haystack = normalize(text);
let at = -1;
for (const term of terms) {
const i = haystack.indexOf(term);
if (i !== -1 && (at === -1 || i < at)) at = i;
}
if (at === -1) return text.slice(0, radius * 2).trim();
const start = Math.max(0, at - radius);
const end = Math.min(text.length, at + radius);
return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`;
}
-369
View File
@@ -1,369 +0,0 @@
/**
* InkNote Sync Engine v2 — online-first, offline-fallback
*
* When ONLINE: Server call → cache to IndexedDB (server IDs everywhere)
* When OFFLINE: IndexedDB + queue → flush on reconnect
*/
import * as db from './offlineStore.js';
const REQUEST_TIMEOUT_MS = 12000;
const DEVICE_ID = (() => {
try {
let d = localStorage.getItem('inknote_device');
if (!d) { d = 'dev_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('inknote_device', d); }
return d;
} catch { return 'dev_' + Math.random().toString(36).slice(2, 10); }
})();
/* ─── API helper ─── */
const api = {
base: '/api',
token: null,
async req(method, path, body) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Device-Id': DEVICE_ID }, signal: controller.signal };
if (this.token) opts.headers['Authorization'] = 'Bearer ' + this.token;
if (body) opts.body = JSON.stringify(body);
try {
let r;
try {
r = await fetch(this.base + path, opts);
} catch (err) {
if (err?.name === 'AbortError') throw new Error('TIMEOUT');
throw new Error('NETWORK');
}
if (r.status === 401) { this.token = null; try { localStorage.removeItem('inknote_token'); } catch {} throw new Error('AUTH'); }
let data = {};
try { data = await r.json(); } catch {}
if (!r.ok) throw new Error(data.error || 'Request failed');
return data;
} finally {
clearTimeout(timeoutId);
}
},
get(p) { return this.req('GET', p); },
post(p, b) { return this.req('POST', p, b); },
put(p, b) { return this.req('PUT', p, b); },
del(p) { return this.req('DELETE', p); },
};
/* ─── Online status ─── */
let _online = navigator.onLine;
window.addEventListener('online', () => { _online = true; notifyStatus('syncing'); flushQueue().then(() => fullSync()).catch(() => {}); });
window.addEventListener('offline', () => { _online = false; notifyStatus('offline'); });
export function isOnline() { return _online; }
/* ─── Status ─── */
let _syncStatus = 'synced';
let _statusCbs = [];
let _dataCbs = [];
function notifyStatus(s) { _syncStatus = s; _statusCbs.forEach(fn => fn(s)); }
export function onSyncStatus(fn) { _statusCbs.push(fn); fn(_syncStatus); return () => { _statusCbs = _statusCbs.filter(f => f !== fn); }; }
function notifyData(t, d) { _dataCbs.forEach(fn => fn(t, d)); }
export function onDataChange(fn) { _dataCbs.push(fn); return () => { _dataCbs = _dataCbs.filter(f => f !== fn); }; }
/* ─── Auth ─── */
export async function login(email, password) {
const data = await api.post('/auth/login', { email, password });
api.token = data.token;
await db.setMeta('token', data.token);
await db.setMeta('user', data.user);
try { localStorage.setItem('inknote_token', data.token); } catch {}
return data.user;
}
export async function register(email, password, displayName) {
const data = await api.post('/auth/register', { email, password, displayName });
api.token = data.token;
await db.setMeta('token', data.token);
await db.setMeta('user', data.user);
try { localStorage.setItem('inknote_token', data.token); } catch {}
return data.user;
}
export async function restoreSession() {
let token = await db.getMeta('token');
if (!token) { try { token = localStorage.getItem('inknote_token'); } catch {} }
if (!token) return null;
api.token = token;
if (_online) {
try { const u = await api.get('/auth/me'); await db.setMeta('user', u); return u; }
catch (e) { if (e.message === 'AUTH') { await logout(); return null; } }
}
return await db.getMeta('user') || {};
}
export async function logout() {
api.token = null;
try { localStorage.removeItem('inknote_token'); } catch {}
await db.clearAll();
}
/* ─── Account ─── */
export async function updateProfile(displayName) {
const u = await api.put('/auth/profile', { displayName });
await db.setMeta('user', u);
return u;
}
export async function updateEmail(newEmail, password) {
const u = await api.put('/auth/email', { newEmail, password });
await db.setMeta('user', u);
return u;
}
export async function updatePassword(currentPassword, newPassword) {
await api.put('/auth/password', { currentPassword, newPassword });
}
/* ─── Full sync ─── */
export async function fullSync() {
if (!_online || !api.token) return;
notifyStatus('syncing');
try {
await flushQueue();
const serverNbs = await api.get('/notebooks');
const nbs = serverNbs.map(nb => ({ id: nb.id, name: nb.name, cover: nb.cover_index || 0, sortOrder: nb.sort_order || 0 }));
await db.clearNotebooks();
if (nbs.length > 0) await db.putNotebooks(nbs);
const serverNotes = await api.get('/notes');
const active = serverNotes.map(n => ({ id: n.id, title: n.title, folderId: n.notebook_id, starred: !!n.starred, trashed: false, createdAt: n.created_at, updatedAt: n.updated_at }));
let trashed = [];
try {
const st = await api.get('/notes/trash');
trashed = st.map(n => ({ id: n.id, title: n.title, folderId: n.notebook_id, starred: !!n.starred, trashed: true, createdAt: n.created_at, updatedAt: n.updated_at || n.deleted_at }));
} catch {}
await db.clearNotes();
const all = [...active, ...trashed];
if (all.length > 0) await db.putNotes(all);
notifyStatus('synced');
notifyData('fullSync', null);
} catch (e) {
if (e.message === 'AUTH') throw e;
notifyStatus('error');
}
}
/* ─── Queue ─── */
let _flushing = false;
async function flushQueue() {
if (_flushing || !_online || !api.token) return;
_flushing = true;
try {
const pending = await db.peekQueue(100);
for (const entry of pending) {
try { await processEntry(entry); } catch (e) {
if (e.message === 'AUTH') { _flushing = false; throw e; }
if (!_online) break;
console.warn('[sync] skip failed entry:', entry.action, e.message);
}
await db.dequeue(entry.queueId);
}
} catch (e) { if (e.message === 'AUTH') throw e; }
_flushing = false;
}
async function processEntry(entry) {
const d = entry.data;
switch (entry.action) {
case 'createNotebook': {
const r = await api.post('/notebooks', { name: d.name, coverIndex: d.cover });
if (r.id !== d.localId) {
await db.deleteNotebook(d.localId);
await db.putNotebook({ id: r.id, name: d.name, cover: d.cover, sortOrder: d.sortOrder || 0 });
const notes = await db.getAllNotes();
for (const n of notes) { if (n.folderId === d.localId) { n.folderId = r.id; await db.putNote(n); } }
}
break;
}
case 'updateNotebook': await api.put(`/notebooks/${d.id}`, { name: d.name, coverIndex: d.cover, sortOrder: d.sortOrder }); break;
case 'deleteNotebook': await api.del(`/notebooks/${d.id}`); break;
case 'createNote': {
const content = await db.getNoteContent(d.localId);
const r = await api.post('/notes', { title: d.title, notebookId: d.folderId, content: content?.content || '' });
if (r.id !== d.localId) {
const old = await db.getNote(d.localId);
const oldC = await db.getNoteContent(d.localId);
await db.deleteNote(d.localId);
if (old) { delete old._pendingCreate; old.id = r.id; await db.putNote(old); }
if (oldC) await db.putNoteContent(r.id, oldC.content, oldC.drawingData);
}
break;
}
case 'updateNote': await api.put(`/notes/${d.id}`, d.updates); break;
case 'updateDrawing': await api.put(`/notes/${d.id}/drawing`, { drawingData: d.drawingData }); break;
case 'trashNote': await api.del(`/notes/${d.id}`); break;
case 'restoreNote': await api.put(`/notes/${d.id}/restore`, {}); break;
case 'permanentDelete': await api.del(`/notes/${d.id}/permanent`); break;
case 'emptyTrash': await api.del('/notes/trash/empty'); break;
}
}
/* ─── Debounce ─── */
const _timers = {};
function debounce(key, fn, ms = 800) { if (_timers[key]) clearTimeout(_timers[key]); _timers[key] = setTimeout(fn, ms); }
/* ════════ PUBLIC DATA API ════════ */
export async function getNotebooks() { return db.getAllNotebooks(); }
export async function createNotebook({ name, cover }) {
if (_online && api.token) {
try {
const r = await api.post('/notebooks', { name, coverIndex: cover });
const nb = { id: r.id, name, cover, sortOrder: (await db.getAllNotebooks()).length };
await db.putNotebook(nb); notifyData('notebook', nb); return nb;
} catch (e) { if (e.message === 'AUTH') throw e; }
}
const lid = 'nb_' + Math.random().toString(36).slice(2, 10);
const nb = { id: lid, name, cover, sortOrder: (await db.getAllNotebooks()).length };
await db.putNotebook(nb);
await db.enqueue({ action: 'createNotebook', data: { localId: lid, name, cover, sortOrder: nb.sortOrder } });
notifyData('notebook', nb); notifyStatus('pending'); return nb;
}
export async function updateNotebook(id, updates) {
const nbs = await db.getAllNotebooks(); const nb = nbs.find(n => n.id === id); if (!nb) return;
const upd = { ...nb, ...updates }; await db.putNotebook(upd); notifyData('notebook', upd);
if (_online && api.token) { try { await api.put(`/notebooks/${id}`, { name: updates.name, coverIndex: updates.cover, sortOrder: updates.sortOrder }); return; } catch (e) { if (e.message === 'AUTH') throw e; } }
await db.enqueue({ action: 'updateNotebook', data: { id, name: updates.name, cover: updates.cover, sortOrder: updates.sortOrder } }); notifyStatus('pending');
}
export async function deleteNotebookLocal(id) {
const notes = await db.getAllNotes();
for (const n of notes) { if (n.folderId === id && !n.trashed) { n.trashed = true; await db.putNote(n); } }
await db.deleteNotebook(id); notifyData('notebook', { id, deleted: true });
if (_online && api.token) { try { await api.del(`/notebooks/${id}`); return; } catch (e) { if (e.message === 'AUTH') throw e; } }
await db.enqueue({ action: 'deleteNotebook', data: { id } }); notifyStatus('pending');
}
export async function reorderNotebooks(orderedIds) {
const nbs = await db.getAllNotebooks();
for (let i = 0; i < orderedIds.length; i++) { const nb = nbs.find(n => n.id === orderedIds[i]); if (nb) { nb.sortOrder = i; await db.putNotebook(nb); } }
notifyData('notebooks', null);
if (_online && api.token) { for (let i = 0; i < orderedIds.length; i++) { api.put(`/notebooks/${orderedIds[i]}`, { sortOrder: i }).catch(() => {}); } }
}
export async function getNotes() { return db.getAllNotes(); }
export async function loadNoteContent(id, opts = {}) {
const forceRemote = !!opts.forceRemote;
// Local-first for instant editor open and resilience against slow/hung requests.
if (!forceRemote) {
const local = await db.getNoteContent(id);
if (local) {
if (_online && api.token) {
api.get(`/notes/${id}`).then(async (d) => {
const c = { content: d.content || '', drawingData: d.drawingData || null };
await db.putNoteContent(id, c.content, c.drawingData);
}).catch(() => {});
}
return { content: local.content || '', drawingData: local.drawingData || null };
}
}
// No local cache (or force remote): fetch server, then fallback to cache.
if (_online && api.token) {
try {
const d = await api.get(`/notes/${id}`);
const c = { content: d.content || '', drawingData: d.drawingData || null };
await db.putNoteContent(id, c.content, c.drawingData);
return c;
} catch {}
}
const local = await db.getNoteContent(id);
if (local) return { content: local.content || '', drawingData: local.drawingData || null };
return { content: '', drawingData: null };
}
export async function createNote(folderId) {
const ts = new Date().toISOString();
if (_online && api.token) {
try {
const r = await api.post('/notes', { title: 'Untitled Note', notebookId: folderId, content: '' });
const note = { id: r.id, title: 'Untitled Note', folderId, starred: false, trashed: false, createdAt: ts, updatedAt: ts };
await db.putNote(note); await db.putNoteContent(r.id, '', null); notifyData('note', note); return note;
} catch (e) { if (e.message === 'AUTH') throw e; }
}
const lid = 'local_' + Math.random().toString(36).slice(2, 10);
const note = { id: lid, title: 'Untitled Note', folderId, starred: false, trashed: false, createdAt: ts, updatedAt: ts, _pendingCreate: true };
await db.putNote(note); await db.putNoteContent(lid, '', null);
await db.enqueue({ action: 'createNote', data: { localId: lid, title: 'Untitled Note', folderId } });
notifyData('note', note); notifyStatus('pending'); return note;
}
export async function updateNoteTitle(id, title) {
const note = await db.getNote(id); if (!note) return;
note.title = title; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
debounce(`title_${id}`, async () => {
if (_online && api.token && !note._pendingCreate) { try { await api.put(`/notes/${id}`, { title }); notifyStatus('synced'); return; } catch {} }
await db.enqueue({ action: 'updateNote', data: { id, updates: { title } } }); notifyStatus('pending');
});
}
export async function updateNoteContent(id, content) {
const ex = await db.getNoteContent(id); await db.putNoteContent(id, content, ex?.drawingData || null);
const note = await db.getNote(id); if (note) { note.updatedAt = new Date().toISOString(); await db.putNote(note); }
notifyData('noteContent', { id });
debounce(`content_${id}`, async () => {
if (_online && api.token && !note?._pendingCreate) { try { await api.put(`/notes/${id}`, { content }); notifyStatus('synced'); return; } catch {} }
await db.enqueue({ action: 'updateNote', data: { id, updates: { content } } }); notifyStatus('pending');
});
}
export async function updateNoteDrawing(id, drawingData) {
const ex = await db.getNoteContent(id); await db.putNoteContent(id, ex?.content || '', drawingData);
notifyData('noteContent', { id });
debounce(`drawing_${id}`, async () => {
const note = await db.getNote(id);
if (_online && api.token && !note?._pendingCreate) { try { await api.put(`/notes/${id}/drawing`, { drawingData }); notifyStatus('synced'); return; } catch {} }
await db.enqueue({ action: 'updateDrawing', data: { id, drawingData } }); notifyStatus('pending');
});
}
export async function toggleNoteStar(id) {
const note = await db.getNote(id); if (!note) return false;
note.starred = !note.starred; await db.putNote(note); notifyData('note', note);
if (_online && api.token && !note._pendingCreate) { api.put(`/notes/${id}`, { starred: note.starred }).catch(() => {}); }
else { await db.enqueue({ action: 'updateNote', data: { id, updates: { starred: note.starred } } }); notifyStatus('pending'); }
return note.starred;
}
export async function moveNote(id, fid) {
const note = await db.getNote(id); if (!note) return;
note.folderId = fid; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
if (_online && api.token && !note._pendingCreate) { api.put(`/notes/${id}`, { notebookId: fid }).catch(() => {}); }
else { await db.enqueue({ action: 'updateNote', data: { id, updates: { notebookId: fid } } }); notifyStatus('pending'); }
}
export async function trashNote(id) {
const note = await db.getNote(id); if (!note) return;
note.trashed = true; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
if (_online && api.token && !note._pendingCreate) { try { await api.del(`/notes/${id}`); } catch {} }
else { await db.enqueue({ action: 'trashNote', data: { id } }); notifyStatus('pending'); }
}
export async function restoreNoteLocal(id) {
const note = await db.getNote(id); if (!note) return;
note.trashed = false; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
if (_online && api.token) { try { await api.put(`/notes/${id}/restore`, {}); } catch {} }
else { await db.enqueue({ action: 'restoreNote', data: { id } }); notifyStatus('pending'); }
}
export async function permanentDeleteNoteLocal(id) {
await db.deleteNote(id); notifyData('note', { id, deleted: true });
if (_online && api.token) { try { await api.del(`/notes/${id}/permanent`); } catch {} }
else { await db.enqueue({ action: 'permanentDelete', data: { id } }); notifyStatus('pending'); }
}
export async function emptyTrashLocal() {
const all = await db.getAllNotes(); for (const n of all) { if (n.trashed) await db.deleteNote(n.id); }
notifyData('notes', null);
if (_online && api.token) { try { await api.del('/notes/trash/empty'); } catch {} }
else { await db.enqueue({ action: 'emptyTrash', data: {} }); notifyStatus('pending'); }
}
export { DEVICE_ID };
+393
View File
@@ -0,0 +1,393 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as db from "./offlineStore";
import * as sync from "./syncEngine";
import type { NoteMeta, Notebook } from "./types";
/* A stub transport. `req()` only ever touches status, ok and json(), so a plain
* object is enough and keeps the tests free of undici/jsdom differences. */
type Reply = { status: number; body?: unknown };
const reply = ({ status, body = {} }: Reply) => ({
status,
ok: status >= 200 && status < 300,
json: async () => body,
});
let calls: Array<{ method: string; url: string; body: unknown }> = [];
let handler: (method: string, url: string, body: unknown) => Reply | Promise<Reply>;
function installFetch() {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, opts: RequestInit = {}) => {
const method = opts.method || "GET";
const body = opts.body ? JSON.parse(opts.body as string) : undefined;
calls.push({ method, url, body });
return reply(await handler(method, url, body));
})
);
}
const succeed = () => {
handler = () => ({ status: 200, body: { id: "server-id" } });
};
const failWith = (status: number, body: unknown = { error: "nope" }) => {
handler = () => ({ status, body });
};
const failTransport = () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new TypeError("Failed to fetch");
})
);
};
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
id,
title: `Note ${id}`,
folderId: "nb1",
starred: false,
trashed: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...over,
});
const notebook = (id: string, over: Partial<Notebook> = {}): Notebook => ({
id,
name: `NB ${id}`,
cover: 0,
sortOrder: 0,
...over,
});
beforeEach(async () => {
await db.clearAll();
calls = [];
succeed();
installFetch();
sync.__testing.setToken("test-token");
sync.__testing.setOnline(true);
sync.__testing.resetPendingWrites();
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
});
afterEach(() => {
// Restored here as well as in each test: a timed-out test never runs its own
// cleanup, and leaked fake timers would hang every hook after it.
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Fakes only setTimeout, so IndexedDB keeps working while the debounce does not. */
const withFakeDebounce = async (fn: () => Promise<void>) => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
await fn();
} finally {
vi.useRealTimers();
}
};
describe("flushQueue", () => {
it("removes an entry once the server accepted it", async () => {
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await sync.__testing.flushQueue();
expect(await db.getQueueSize()).toBe(0);
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual(["DELETE /api/notes/n1"]);
});
it("keeps a failed entry queued and counts the attempt — losing it silently ate offline edits", async () => {
failWith(500);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await sync.__testing.flushQueue();
const [entry] = await db.peekQueue();
expect(entry).toBeDefined();
expect(entry!.attempts).toBe(1);
expect(entry!.action).toBe("trashNote");
});
it("retries across flushes and gives up only after repeated failures", async () => {
failWith(500);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
for (let i = 1; i <= 4; i++) {
await sync.__testing.flushQueue();
const [entry] = await db.peekQueue();
expect(entry!.attempts).toBe(i);
}
// Fifth failure hits the cap; the entry is dropped so it cannot wedge the queue.
await sync.__testing.flushQueue();
expect(await db.getQueueSize()).toBe(0);
});
it("stops at the first transport failure and keeps everything for later", async () => {
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await db.enqueue({ action: "trashNote", data: { id: "n2" } });
failTransport();
await sync.__testing.flushQueue();
expect(await db.getQueueSize()).toBe(2);
const entries = await db.peekQueue();
// Nothing is counted as a failed attempt: being offline is not the entry's fault.
expect(entries.every((e) => e.attempts === undefined)).toBe(true);
});
it("propagates an auth failure and leaves the queue untouched", async () => {
failWith(401);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await expect(sync.__testing.flushQueue()).rejects.toThrow("AUTH");
expect(await db.getQueueSize()).toBe(1);
});
it("processes entries in the order they were queued", async () => {
await db.enqueue({ action: "trashNote", data: { id: "first" } });
await db.enqueue({ action: "trashNote", data: { id: "second" } });
await sync.__testing.flushQueue();
expect(calls.map((c) => c.url)).toEqual(["/api/notes/first", "/api/notes/second"]);
});
it("does nothing while signed out", async () => {
sync.__testing.setToken(null);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await sync.__testing.flushQueue();
expect(calls.length).toBe(0);
expect(await db.getQueueSize()).toBe(1);
});
it("repoints notes at the server id when a queued notebook is finally created", async () => {
await db.putNotebook(notebook("nb_local", { _pendingCreate: true }));
await db.putNote(note("n1", { folderId: "nb_local" }));
await db.enqueue({
action: "createNotebook",
data: { localId: "nb_local", name: "Later", cover: 0, sortOrder: 0 },
});
await sync.__testing.flushQueue();
expect((await db.getNote("n1"))?.folderId).toBe("server-id");
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["server-id"]);
});
});
describe("fullSync", () => {
const serverState = (notes: unknown[], notebooks: unknown[]) => {
handler = (_method, url) => {
if (url.endsWith("/notebooks")) return { status: 200, body: notebooks };
if (url.endsWith("/notes/trash")) return { status: 200, body: [] };
if (url.endsWith("/notes")) return { status: 200, body: notes };
return { status: 200, body: {} };
};
};
it("mirrors the server's notes and notebooks", async () => {
serverState(
[{ id: "s1", title: "Server note", notebook_id: "snb", created_at: "t", updated_at: "t" }],
[{ id: "snb", name: "Server book", cover_index: 2, sort_order: 1 }]
);
await sync.fullSync();
expect((await db.getAllNotes()).map((n) => n.id)).toEqual(["s1"]);
const [nb] = await db.getAllNotebooks();
expect(nb).toMatchObject({ id: "snb", name: "Server book", cover: 2, sortOrder: 1 });
});
it("keeps notes created offline that the server has never seen", async () => {
await db.putNote(note("local_1", { _pendingCreate: true }));
await db.putNote(note("stale_1")); // synced earlier, since deleted elsewhere
serverState([], []);
await sync.fullSync();
const ids = (await db.getAllNotes()).map((n) => n.id);
expect(ids).toContain("local_1");
expect(ids).not.toContain("stale_1");
});
it("keeps notebooks created offline", async () => {
await db.putNotebook(notebook("nb_local", { _pendingCreate: true }));
serverState([], []);
await sync.fullSync();
expect((await db.getAllNotebooks()).map((n) => n.id)).toContain("nb_local");
});
it("drops the local-only flag once the server reports the same id", async () => {
await db.putNote(note("s1", { _pendingCreate: true }));
serverState(
[{ id: "s1", title: "Now on server", notebook_id: null, created_at: "t", updated_at: "t" }],
[]
);
await sync.fullSync();
const [n] = await db.getAllNotes();
expect(n!._pendingCreate).toBeUndefined();
expect(n!.title).toBe("Now on server");
});
it("leaves the local mirror intact when a server read fails midway", async () => {
await db.putNote(note("keep"));
await db.putNotebook(notebook("keep_nb"));
handler = (_method, url) => {
if (url.endsWith("/notebooks")) return { status: 200, body: [] };
return { status: 500, body: { error: "boom" } }; // the notes read fails
};
await sync.fullSync();
expect((await db.getAllNotes()).map((n) => n.id)).toEqual(["keep"]);
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["keep_nb"]);
});
it("does nothing while signed out", async () => {
sync.__testing.setToken(null);
await sync.fullSync();
expect(calls.length).toBe(0);
});
});
describe("hasPendingWrites", () => {
it("is false without an id", () => {
expect(sync.hasPendingWrites(null)).toBe(false);
expect(sync.hasPendingWrites(undefined)).toBe(false);
expect(sync.hasPendingWrites("")).toBe(false);
});
it("is false for a note nobody is writing", () => {
expect(sync.hasPendingWrites("n1")).toBe(false);
});
it("reports a write that is in flight — the guard that stops stale reads winning", () => {
sync.__testing.beginWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(true);
expect(sync.hasPendingWrites("n2")).toBe(false);
sync.__testing.endWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(false);
});
it("stays true until the last of several overlapping writes finishes", () => {
sync.__testing.beginWrite("n1");
sync.__testing.beginWrite("n1");
sync.__testing.endWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(true);
sync.__testing.endWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(false);
});
it("covers the debounce window before a save even starts", async () => {
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "typing…");
// The request has not been sent yet, but the note must already count as busy.
expect(sync.hasPendingWrites("n1")).toBe(true);
await vi.runAllTimersAsync();
});
expect(sync.hasPendingWrites("n1")).toBe(false);
});
});
describe("updateNoteContent", () => {
it("writes locally before anything reaches the network", async () => {
await db.putNote(note("n1"));
await sync.updateNoteContent("n1", "body text");
expect((await db.getNoteContent("n1"))?.content).toBe("body text");
expect(calls.length).toBe(0); // still inside the debounce window
});
it("sends links along with the body, since the server cannot read it", async () => {
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body", ["target-1"]);
await vi.runAllTimersAsync();
});
const put = calls.find((c) => c.method === "PUT");
expect(put?.body).toMatchObject({ content: "body", links: ["target-1"] });
});
it("omits the links field when the caller passes none", async () => {
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
const put = calls.find((c) => c.method === "PUT");
expect(put?.body).toEqual({ content: "body" });
});
it("queues the change when the request fails", async () => {
failWith(500);
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
const [entry] = await db.peekQueue();
expect(entry?.action).toBe("updateNote");
});
});
describe("loadNoteContent", () => {
it("returns the cached body without waiting for the server", async () => {
await db.putNoteContent("n1", "cached", null);
const c = await sync.loadNoteContent("n1");
expect(c.content).toBe("cached");
});
it("does not refresh from the server while a write is in flight", async () => {
await db.putNoteContent("n1", "cached", null);
sync.__testing.beginWrite("n1");
await sync.loadNoteContent("n1");
expect(calls.length).toBe(0);
sync.__testing.endWrite("n1");
});
it("falls back to the cache when the server read fails", async () => {
await db.putNoteContent("n1", "cached", null);
failWith(500);
const c = await sync.loadNoteContent("n1", { forceRemote: true });
expect(c.content).toBe("cached");
});
it("returns an empty shape for a note with nothing cached and no connection", async () => {
sync.__testing.setOnline(false);
const c = await sync.loadNoteContent("missing");
expect(c).toEqual({ content: "", drawingData: null, drawingStrokes: null, updatedAt: null });
sync.__testing.setOnline(true);
});
});
describe("offline behaviour", () => {
beforeEach(() => sync.__testing.setOnline(false));
afterEach(() => sync.__testing.setOnline(true));
it("creates a note locally and queues it", async () => {
const n = await sync.createNote("nb1");
expect(n._pendingCreate).toBe(true);
expect(calls.length).toBe(0);
const [entry] = await db.peekQueue();
expect(entry?.action).toBe("createNote");
});
it("creates a notebook locally, flagged so fullSync will not drop it", async () => {
const nb = await sync.createNotebook({ name: "Later", cover: 1 });
expect(nb._pendingCreate).toBe(true);
expect((await db.getAllNotebooks())[0]?._pendingCreate).toBe(true);
});
it("queues a star toggle instead of losing it", async () => {
await db.putNote(note("n1"));
expect(await sync.toggleNoteStar("n1")).toBe(true);
const [entry] = await db.peekQueue();
expect(entry?.action).toBe("updateNote");
expect((await db.getNote("n1"))?.starred).toBe(true);
});
});
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
// IndexedDB is not part of jsdom; this gives the store layer a real one to
// exercise, so the tests cover actual transactions rather than a hand-rolled mock.
import "fake-indexeddb/auto";
+147
View File
@@ -0,0 +1,147 @@
/**
* The shapes that travel between the server, IndexedDB and the UI.
*
* Most bugs in this app have been shape bugs: a field that is sometimes absent,
* a body cached in the wrong format, a stale object adopted over a fresh one.
* These types exist to make that class of mistake fail at build time.
*/
/* ── Drawing ── */
export type StrokeTool = "pen" | "highlighter" | "eraser";
export interface Stroke {
/** Tool the stroke was drawn with. */
t: StrokeTool;
/** Colour, `#rrggbb`. */
c: string;
/** Width in canonical page units, not pixels. */
s: number;
/** Flat `[x0, y0, x1, y1, …]` in canonical page space. */
p: number[];
}
export interface StrokeDoc {
v: number;
/** Canonical page width the points are expressed in. */
w: number;
/** Canonical page height. */
h: number;
strokes: Stroke[];
/** Save token — lets the editor recognise its own write coming back. */
t?: string;
}
/* ── Notes ── */
/** Note metadata, as held in the notes list. */
export interface NoteMeta {
id: string;
title: string;
folderId: string | null;
starred: boolean;
trashed: boolean;
createdAt: string;
updatedAt: string;
/** Set while a note created offline has not reached the server yet. */
_pendingCreate?: boolean;
}
/** The lazily loaded body of a note. */
export interface NoteContent {
/** Serialised document: `{"v":2,"doc":…}`, or a v1 `{html,tasks}` string. */
content: string;
/** Legacy raster drawing as a data URL, for notes predating vector strokes. */
drawingData: string | null;
drawingStrokes: StrokeDoc | null;
/** Server revision this copy came from, used to spot a stale cache. */
updatedAt?: string | null;
}
export interface Notebook {
id: string;
name: string;
cover: number;
sortOrder: number;
_pendingCreate?: boolean;
}
export interface User {
id: string;
username: string;
displayName: string;
}
/* ── Version history ── */
export type VersionReason = "edit" | "large-deletion" | "drawing" | "drawing-cleared" | "before-restore";
export interface NoteVersionSummary {
id: number;
title: string | null;
device_id: string | null;
reason: VersionReason | string;
created_at: string;
content_size: number | null;
has_drawing: boolean;
}
export interface NoteVersion {
id: number;
title: string | null;
created_at: string;
device_id: string | null;
reason: string;
content: string;
drawingStrokes: StrokeDoc | null;
}
/* ── Links ── */
export interface Backlink {
id: string;
title: string;
notebook_id: string | null;
notebook_name: string | null;
updated_at: string;
}
export type LinkKind = "note" | "notebook";
export interface LinkTarget {
id: string;
label: string;
kind: LinkKind;
}
/* ── Sync ── */
export type SyncStatus = "synced" | "syncing" | "pending" | "offline" | "error";
/** A change waiting to be pushed to the server. */
export interface QueueEntry {
queueId: number;
action: string;
data: Record<string, unknown>;
timestamp: number;
attempts?: number;
lastError?: string;
}
/* ── Attachments ── */
export interface AttachmentInfo {
id: string;
mime: string;
size: number;
deduplicated: boolean;
}
/* ── Search ── */
export interface SearchHit {
id: string;
score: number;
/** Excerpt around the first matching term. */
snippet: string;
}
+394
View File
@@ -0,0 +1,394 @@
import { useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
/* ═══════ ACCOUNT MENU ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, dark }) {
const [tab, setTab] = useState("profile"); // profile | username | password
const [displayName, setDisplayName] = useState(user?.displayName || "");
const [newUsername, setNewUsername] = useState(user?.username || "");
const [usernamePass, setUsernamePass] = useState("");
const [curPass, setCurPass] = useState("");
const [newPass, setNewPass] = useState("");
const [newPass2, setNewPass2] = useState("");
const [msg, setMsg] = useState("");
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
danger: dark ? "#f85149" : "#cf222e",
success: dark ? "#3fb950" : "#1a7f37",
inputBg: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
const inp = {
width: "100%",
padding: "10px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.inputBg,
color: T.text,
fontSize: 14,
outline: "none",
boxSizing: "border-box",
marginBottom: 10,
fontFamily: "'DM Sans',sans-serif",
};
const tbtn = (active) => ({
padding: "7px 14px",
borderRadius: 8,
border: "none",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
background: active ? T.accent + "22" : "transparent",
color: active ? T.accent : T.muted,
});
const clear = () => {
setMsg("");
setErr("");
};
const saveProfile = async () => {
if (!displayName.trim()) return;
clear();
setSaving(true);
try {
const u = await sync.updateProfile(displayName.trim());
onUserUpdate(u);
setMsg("Display name updated");
} catch (e) {
setErr(e.message);
}
setSaving(false);
};
const saveUsername = async () => {
if (!newUsername || !usernamePass) return;
clear();
setSaving(true);
try {
const u = await sync.updateUsername(newUsername.trim(), usernamePass);
onUserUpdate(u);
setUsernamePass("");
setMsg("Username updated");
} catch (e) {
setErr(e.message);
}
setSaving(false);
};
const savePassword = async () => {
if (!curPass || !newPass) return;
if (newPass !== newPass2) {
setErr("Passwords don't match");
return;
}
clear();
setSaving(true);
try {
await sync.updatePassword(curPass, newPass);
setCurPass("");
setNewPass("");
setNewPass2("");
setMsg("Password changed");
} catch (e) {
setErr(e.message);
}
setSaving(false);
};
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
>
<div
style={{
background: T.bg,
borderRadius: 20,
padding: 0,
width: 440,
maxWidth: "92vw",
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
border: `1px solid ${T.border}`,
overflow: "hidden",
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "18px 22px",
borderBottom: `1px solid ${T.border}`,
}}
>
<h3
style={{
margin: 0,
fontSize: 17,
fontWeight: 600,
color: T.text,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
Account
</h3>
<button
onClick={onClose}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
}}
>
{I.x}
</button>
</div>
<div
style={{ display: "flex", gap: 4, padding: "12px 22px 0", borderBottom: `1px solid ${T.border}` }}
>
<button
onClick={() => {
setTab("profile");
clear();
}}
style={tbtn(tab === "profile")}
>
Profile
</button>
<button
onClick={() => {
setTab("username");
clear();
}}
style={tbtn(tab === "username")}
>
Username
</button>
<button
onClick={() => {
setTab("password");
clear();
}}
style={tbtn(tab === "password")}
>
Password
</button>
</div>
<div style={{ padding: "18px 22px 22px" }}>
{msg && (
<div
style={{
color: T.success,
fontSize: 12,
marginBottom: 10,
padding: "8px 12px",
borderRadius: 8,
background: T.success + "15",
}}
>
{msg}
</div>
)}
{err && (
<div
style={{
color: T.danger,
fontSize: 12,
marginBottom: 10,
padding: "8px 12px",
borderRadius: 8,
background: T.danger + "12",
}}
>
{err}
</div>
)}
{tab === "profile" && (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
style={inp}
onKeyDown={(e) => {
if (e.key === "Enter") saveProfile();
}}
/>
<button
onClick={saveProfile}
disabled={saving}
style={{
width: "100%",
padding: "10px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Saving..." : "Save Changes"}
</button>
</>
)}
{tab === "username" && (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
New Username
</label>
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
style={inp}
/>
<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();
}}
/>
<button
onClick={saveUsername}
disabled={saving}
style={{
width: "100%",
padding: "10px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Saving..." : "Update Username"}
</button>
</>
)}
{tab === "password" && (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Current Password
</label>
<input
type="password"
value={curPass}
onChange={(e) => setCurPass(e.target.value)}
style={inp}
/>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
New Password
</label>
<input
type="password"
value={newPass}
onChange={(e) => setNewPass(e.target.value)}
style={inp}
/>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Confirm New Password
</label>
<input
type="password"
value={newPass2}
onChange={(e) => setNewPass2(e.target.value)}
style={inp}
onKeyDown={(e) => {
if (e.key === "Enter") savePassword();
}}
/>
<button
onClick={savePassword}
disabled={saving}
style={{
width: "100%",
padding: "10px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Saving..." : "Change Password"}
</button>
</>
)}
</div>
<div style={{ padding: "0 22px 18px" }}>
<button
onClick={onLogout}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "10px",
borderRadius: 10,
border: `1px solid ${T.danger}33`,
background: T.danger + "08",
cursor: "pointer",
color: T.danger,
fontSize: 13,
fontWeight: 600,
justifyContent: "center",
}}
>
{I.logout} Sign Out
</button>
</div>
</div>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { useMemo, useState } from "react";
import * as sync from "../syncEngine";
/* ═══════ AUTH SCREEN ═══════ */
export function AuthScreen({ onLogin, dark }) {
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState("");
const [pass, setPass] = useState("");
const [name, setName] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const T = useMemo(
() => ({
bg: dark ? "#0d1117" : "#f6f8fa",
card: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
danger: dark ? "#f85149" : "#cf222e",
}),
[dark]
);
const submit = async () => {
const u = username.trim();
if (!u || !pass) {
setErr("Username and password required");
return;
}
if (!isLogin && !/^[a-zA-Z0-9._-]{3,32}$/.test(u)) {
setErr("Username must be 3-32 chars: letters, digits, . _ -");
return;
}
setLoading(true);
setErr("");
try {
const user = isLogin ? await sync.login(u, pass) : await sync.register(u, pass, name || undefined);
onLogin(user);
} catch (e) {
setErr(e.message === "AUTH" ? "Invalid credentials" : e.message);
} finally {
setLoading(false);
}
};
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: T.bg,
fontFamily: "'DM Sans',sans-serif",
}}
>
<div
style={{
background: T.card,
borderRadius: 24,
padding: 40,
width: 400,
maxWidth: "90vw",
boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
border: `1px solid ${T.border}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 28 }}>
<div
style={{
width: 42,
height: 42,
borderRadius: 14,
background: `linear-gradient(135deg,${T.accent},#a855f7)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 22,
}}
>
✏️
</div>
<div>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: T.text,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
InkNote
</div>
<div style={{ fontSize: 12, color: T.muted }}>
{isLogin ? "Sign in to sync your notes" : "Create your account"}
</div>
</div>
</div>
{!isLogin && (
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Display name"
style={{
width: "100%",
padding: "12px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.bg,
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 12,
boxSizing: "border-box",
}}
/>
)}
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
autoFocus
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoComplete="username"
style={{
width: "100%",
padding: "12px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.bg,
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 12,
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
/>
<input
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
placeholder="Password"
autoComplete={isLogin ? "current-password" : "new-password"}
style={{
width: "100%",
padding: "12px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.bg,
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 16,
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
/>
{err && (
<div
style={{
color: T.danger,
fontSize: 12,
marginBottom: 12,
padding: "8px 12px",
borderRadius: 8,
background: T.danger + "12",
}}
>
{err}
</div>
)}
<button
onClick={submit}
disabled={loading}
style={{
width: "100%",
padding: "12px",
borderRadius: 12,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
marginBottom: 16,
opacity: loading ? 0.6 : 1,
}}
>
{loading ? "..." : isLogin ? "Sign In" : "Create Account"}
</button>
<div style={{ textAlign: "center", fontSize: 13, color: T.muted }}>
{isLogin ? "Don't have an account? " : "Already have an account? "}
<button
onClick={() => {
setIsLogin(!isLogin);
setErr("");
}}
style={{
background: "none",
border: "none",
color: T.accent,
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
textDecoration: "underline",
}}
>
{isLogin ? "Sign Up" : "Sign In"}
</button>
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { I } from "./icons.jsx";
/** Phone-only return arrow; desktop keeps list and editor side by side. */
export function BackButton({ T, label = "Back", onClick }) {
return (
<button
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.accent,
fontSize: 13,
fontWeight: 600,
}}
>
{I.back}
{label}
</button>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useMemo, useState } from "react";
/* ═══════ BACKLINKS ═══════ */
/* Notes that point at the one currently open. The reverse direction is not part
* of the note itself, so it can only come from the server's link index.
*
* Sits as a bar under the editor rather than after the page: a page is a fixed
* 4:3 sheet, so anything placed below it starts a full screen out of sight. */
export function BacklinksPanel({ backlinks, onOpenNote, dark }) {
const [open, setOpen] = useState(false);
if (!backlinks || backlinks.length === 0) return null;
const T = {
border: dark ? "#30363d" : "#d0d7de",
muted: dark ? "#8b949e" : "#656d76",
surface: dark ? "#161b22" : "#f6f8fa",
accent: dark ? "#388bfd" : "#0969da",
};
return (
<div
style={{
borderTop: `1px solid ${T.border}`,
background: T.surface,
flexShrink: 0,
maxHeight: "38vh",
display: "flex",
flexDirection: "column",
}}
>
<button
onClick={() => setOpen((o) => !o)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "9px 16px",
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "left",
flexShrink: 0,
}}
>
<span
style={{
display: "inline-block",
transform: open ? "rotate(90deg)" : "none",
transition: "transform .15s",
}}
>
▸
</span>
<span>
Linked from {backlinks.length} {backlinks.length === 1 ? "note" : "notes"}
</span>
</button>
{open && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 2,
padding: "0 10px 10px",
overflowY: "auto",
}}
>
{backlinks.map((b) => (
<button
key={b.id}
onClick={() => onOpenNote?.(b.id)}
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
width: "100%",
padding: "7px 9px",
border: "none",
borderRadius: 8,
background: "transparent",
cursor: "pointer",
textAlign: "left",
}}
>
<span
style={{
fontSize: 13,
color: T.accent,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{b.title || "Untitled"}
</span>
{b.notebook_name && (
<span style={{ fontSize: 11, color: T.muted, flexShrink: 0 }}>in {b.notebook_name}</span>
)}
</button>
))}
</div>
)}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useMemo } from "react";
/* ═══════ Shared Components ═══════ */
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onCancel, dark }) {
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
danger: dark ? "#f85149" : "#cf222e",
}),
[dark]
);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onCancel}
>
<div
style={{
background: T.bg,
borderRadius: 16,
padding: 26,
width: 380,
maxWidth: "90vw",
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
border: `1px solid ${T.border}`,
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
fontSize: 17,
fontWeight: 600,
color: T.text,
marginBottom: 8,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
{title}
</div>
<div
style={{
fontSize: 13,
color: T.muted,
lineHeight: 1.6,
marginBottom: 22,
fontFamily: "'DM Sans',sans-serif",
}}
>
{message}
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onCancel}
style={{
padding: "8px 20px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Cancel
</button>
<button
onClick={onConfirm}
style={{
padding: "8px 20px",
borderRadius: 10,
border: "none",
background: T.danger,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useMemo, useRef, useState } from "react";
export function ContextMenu({ x, y, items, onClose, dark }) {
const T = useMemo(
() => ({
bg: dark ? "#1c2128" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
hover: dark ? "#262c36" : "#f3f4f6",
danger: dark ? "#f85149" : "#cf222e",
}),
[dark]
);
useEffect(() => {
const h = () => onClose();
window.addEventListener("click", h);
window.addEventListener("contextmenu", h);
return () => {
window.removeEventListener("click", h);
window.removeEventListener("contextmenu", h);
};
}, [onClose]);
return (
<div
style={{
position: "fixed",
left: Math.min(x, window.innerWidth - 220),
top: Math.min(y, window.innerHeight - 200),
zIndex: 3000,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 12,
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
padding: 4,
minWidth: 190,
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>
)
)}
</div>
);
}
function SubMenu({ item, T, onClose }) {
const [open, setOpen] = useState(false);
return (
<div
style={{ position: "relative" }}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "8px 12px",
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
color: T.text,
cursor: "pointer",
background: open ? T.hover : "transparent",
}}
>
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
{item.label}
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>▸</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>
)}
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useState } from "react";
import { COVERS } from "./constants.jsx";
/* ═══════ NOTEBOOK COVER ═══════ */
export function NotebookCover({
notebook,
noteCount,
onClick,
onContextMenu,
isActive,
isDragOver,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
}) {
const cover = COVERS[notebook.cover % COVERS.length];
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDrop={onDrop}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
background: "none",
border: "none",
cursor: "pointer",
transition: "transform .22s cubic-bezier(.4,0,.2,1),opacity .2s",
transform: hovered ? "translateY(-6px) scale(1.03)" : isDragOver ? "scale(1.06)" : "none",
opacity: isDragOver ? 0.6 : 1,
}}
>
<div style={{ position: "relative", width: 156, height: 210, perspective: 800 }}>
<div
style={{
position: "absolute",
bottom: -5,
left: 10,
right: 10,
height: 14,
background: "rgba(0,0,0,0.25)",
borderRadius: "50%",
filter: "blur(8px)",
opacity: hovered ? 0.5 : 0.3,
}}
/>
<div
style={{
position: "absolute",
left: 0,
top: 2,
bottom: 2,
width: 18,
background: cover.spine,
borderRadius: "4px 0 0 4px",
boxShadow: "inset -2px 0 4px rgba(0,0,0,0.3)",
zIndex: 2,
}}
>
{[12, 18].map((t) => (
<div
key={t}
style={{
position: "absolute",
top: t,
left: 3,
right: 3,
height: 1,
background: "rgba(255,255,255,0.1)",
}}
/>
))}
{[12, 18].map((t) => (
<div
key={"b" + t}
style={{
position: "absolute",
bottom: t,
left: 3,
right: 3,
height: 1,
background: "rgba(255,255,255,0.1)",
}}
/>
))}
</div>
<div
style={{
position: "absolute",
right: 2,
top: 8,
bottom: 8,
width: 5,
background: "linear-gradient(90deg,#e8e0d4,#f5f0e8,#e8e0d4)",
borderRadius: "0 2px 2px 0",
zIndex: 1,
}}
/>
<div
style={{
position: "absolute",
left: 14,
top: 0,
right: 0,
bottom: 0,
background: cover.bg,
borderRadius: "2px 8px 8px 2px",
boxShadow: isActive
? `0 4px 20px rgba(0,0,0,0.3),0 0 0 3px ${cover.accent}88`
: "0 4px 16px rgba(0,0,0,0.2)",
overflow: "hidden",
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",
top: 0,
left: 0,
right: 0,
height: 3,
background: cover.accent,
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>
<div
style={{
position: "absolute",
top: 90,
left: 8,
right: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<div
style={{
width: "100%",
padding: "6px 4px",
borderTop: `1px solid ${cover.accent}33`,
borderBottom: `1px solid ${cover.accent}33`,
textAlign: "center",
}}
>
<span
style={{
color: "#fff",
fontSize: notebook.name.length > 12 ? 12 : 14,
fontWeight: 600,
fontFamily: "'Newsreader',Georgia,serif",
textShadow: "0 1px 4px rgba(0,0,0,0.4)",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
wordBreak: "break-word",
}}
>
{notebook.name}
</span>
</div>
<div style={{ display: "flex", gap: 3 }}>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{ width: 2.5, height: 2.5, borderRadius: "50%", background: cover.accent + "55" }}
/>
))}
</div>
</div>
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 3,
background: cover.accent,
opacity: 0.7,
}}
/>
</div>
</div>
<div style={{ textAlign: "center", maxWidth: 156 }}>
<div
style={{
fontSize: 13,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{notebook.name}
</div>
<div style={{ fontSize: 11, opacity: 0.5, marginTop: 1 }}>
{noteCount} {noteCount === 1 ? "note" : "notes"}
</div>
</div>
</button>
);
}
+273
View File
@@ -0,0 +1,273 @@
import { useMemo, useState } from "react";
import { COVERS } from "./constants.jsx";
export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
const [name, setName] = useState(notebook.name);
const [sel, setSel] = useState(notebook.cover);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
active: dark ? "#388bfd" : "#0969da",
}),
[dark]
);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
>
<div
style={{
background: T.bg,
borderRadius: 20,
padding: 30,
width: 460,
maxWidth: "92vw",
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
border: `1px solid ${T.border}`,
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 22px",
color: T.text,
fontSize: 18,
fontWeight: 600,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
Edit Notebook
</h3>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
style={{
width: "100%",
padding: "10px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: dark ? "#0d1117" : "#f6f8fa",
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 16,
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim()) onSave({ name: name.trim(), cover: sel });
}}
/>
<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>
))}
</div>
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onClose}
style={{
padding: "8px 20px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Cancel
</button>
<button
onClick={() => {
if (name.trim()) onSave({ name: name.trim(), cover: sel });
}}
style={{
padding: "8px 20px",
borderRadius: 10,
border: "none",
background: T.active,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Save
</button>
</div>
</div>
</div>
);
}
export function CreateNotebookModal({ onClose, onCreate, dark }) {
const [name, setName] = useState("");
const [sel, setSel] = useState(0);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
active: dark ? "#388bfd" : "#0969da",
}),
[dark]
);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
>
<div
style={{
background: T.bg,
borderRadius: 20,
padding: 30,
width: 460,
maxWidth: "92vw",
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
border: `1px solid ${T.border}`,
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 22px",
color: T.text,
fontSize: 18,
fontWeight: 600,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
New Notebook
</h3>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Notebook name..."
autoFocus
style={{
width: "100%",
padding: "10px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: dark ? "#0d1117" : "#f6f8fa",
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 16,
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim()) onCreate({ name: name.trim(), cover: sel });
}}
/>
<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>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onClose}
style={{
padding: "8px 20px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Cancel
</button>
<button
onClick={() => {
if (name.trim()) onCreate({ name: name.trim(), cover: sel });
}}
style={{
padding: "8px 20px",
borderRadius: 10,
border: "none",
background: T.active,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Create
</button>
</div>
</div>
</div>
);
}
+375
View File
@@ -0,0 +1,375 @@
import { COVERS, DEFAULT_FOLDERS } from "./constants.jsx";
import { I } from "./icons.jsx";
import { FONT_UI } from "./theme";
const SYNC_LABELS = {
synced: "All synced",
syncing: "Syncing...",
offline: "Offline mode",
pending: "Changes pending",
};
const syncColor = (status, T) => {
if (status === "synced") return T.success;
if (status === "syncing" || status === "pending") return T.warning;
if (status === "offline") return T.muted;
return T.danger;
};
const iconButton = (T) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
});
const folderButton = (T, active) => ({
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "8px 12px",
borderRadius: 10,
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
textAlign: "left",
background: active ? T.sidebarActive : "transparent",
color: active ? T.accentText : T.textSec,
});
const countBadge = (T, color) => ({
marginLeft: "auto",
fontSize: 11,
color,
background: color === T.danger ? T.danger + "15" : T.accentSoft,
padding: "2px 7px",
borderRadius: 10,
});
/**
* Navigation rail: search, the built-in views, and the notebook list.
*
* 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.
*/
export function Sidebar({
T,
dark,
onToggleDark,
isMobile,
open,
onClose,
searchQuery,
onSearchChange,
activeFolder,
onSelectFolder,
notebooks,
activeNotes,
trashedCount,
onNewNotebook,
onOpenAccount,
onNotebookContextMenu,
dnd,
canInstall,
onInstall,
syncStatus,
online,
}) {
const notesIn = (nbId) => activeNotes.filter((n) => n.folderId === nbId).length;
return (
<div
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",
background: T.sidebar,
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: "all .25s cubic-bezier(.4,0,.2,1)",
fontFamily: FONT_UI,
}}
>
{isMobile && open && (
<div
onClick={onClose}
style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: -1 }}
/>
)}
<div
style={{
width: isMobile ? "85vw" : 260,
maxWidth: 320,
height: "100%",
background: T.sidebar,
display: "flex",
flexDirection: "column",
position: "relative",
zIndex: 1,
}}
>
<div
style={{
padding: "20px 18px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 10,
background: `linear-gradient(135deg,${T.accent},#a855f7)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
}}
>
✏️
</div>
<span style={{ fontWeight: 700, fontSize: 17, color: T.text, letterSpacing: "-0.02em" }}>
InkNote
</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}
</button>
</div>
</div>
<div style={{ padding: "0 14px 12px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 10,
background: T.inputBg,
border: `1px solid ${T.borderLight}`,
}}
>
<span style={{ color: T.faint }}>{I.search}</span>
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search notes..."
style={{
border: "none",
background: "transparent",
outline: "none",
flex: 1,
color: T.text,
fontSize: 13,
fontFamily: FONT_UI,
}}
/>
</div>
</div>
<div style={{ padding: "0 10px" }}>
<button
onClick={() => onSelectFolder("dashboard")}
style={{ ...folderButton(T, activeFolder === "dashboard"), padding: "9px 12px", fontWeight: 600 }}
>
<span style={{ fontSize: 15 }}>📚</span>
<span>Dashboard</span>
</button>
</div>
<div style={{ padding: "6px 10px 0" }}>
{DEFAULT_FOLDERS.map((f) => (
<button
key={f.id}
onClick={() => onSelectFolder(f.id)}
style={folderButton(T, activeFolder === f.id)}
>
<span style={{ fontSize: 14 }}>{f.icon}</span>
<span>{f.name}</span>
{f.id === "all" && <span style={countBadge(T, T.faint)}>{activeNotes.length}</span>}
{f.id === "trash" && trashedCount > 0 && (
<span style={countBadge(T, T.danger)}>{trashedCount}</span>
)}
</button>
))}
</div>
<div style={{ padding: "14px 10px 4px" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 12px",
marginBottom: 6,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
color: T.faint,
textTransform: "uppercase",
letterSpacing: "0.08em",
}}
>
Notebooks
</span>
<button
onClick={onNewNotebook}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "none",
background: T.accentSoft,
cursor: "pointer",
color: T.accent,
}}
>
{I.plus}
</button>
</div>
{notebooks.map((nb) => (
<div
key={nb.id}
draggable={!isMobile}
onDragStart={(e) => dnd.onDragStart(e, nb.id)}
onDragOver={(e) => dnd.onDragOver(e, nb.id)}
onDrop={(e) => dnd.onDrop(e, nb.id)}
onDragEnd={dnd.onDragEnd}
onContextMenu={(e) => onNotebookContextMenu(e, nb)}
style={{
display: "flex",
alignItems: "center",
borderRadius: 10,
transition: "all .15s",
background:
dnd.overId === nb.id
? T.accentSoft
: activeFolder === nb.id
? T.sidebarActive
: "transparent",
borderTop: dnd.overId === nb.id ? `2px solid ${T.accent}` : "2px solid transparent",
}}
>
{!isMobile && (
<span
style={{
padding: "0 2px 0 6px",
color: T.faint,
cursor: "grab",
display: "flex",
alignItems: "center",
opacity: 0.5,
}}
>
{I.grip}
</span>
)}
<button
onClick={() => onSelectFolder(nb.id)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
flex: 1,
padding: "7px 10px 7px " + (isMobile ? "10px" : "4px"),
borderRadius: 10,
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
textAlign: "left",
background: "transparent",
color: activeFolder === nb.id ? T.accentText : T.textSec,
}}
>
<div
style={{
width: 20,
height: 14,
borderRadius: 3,
background: COVERS[nb.cover % COVERS.length].bg,
flexShrink: 0,
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
<span>{nb.name}</span>
<span style={{ marginLeft: "auto", fontSize: 11, color: T.faint }}>{notesIn(nb.id)}</span>
</button>
</div>
))}
</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>
)}
<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>
{!online && (
<span
style={{
fontSize: 10,
padding: "2px 6px",
borderRadius: 6,
background: T.faint + "22",
color: T.faint,
marginLeft: "auto",
}}
>
offline
</span>
)}
</div>
</div>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
/** Shared visual constants: palette, notebook covers, sidebar folders. */
export const COLORS = [
"#0d1117",
"#e94560",
"#0f3460",
"#533483",
"#e07c24",
"#21bf73",
"#fd5e53",
"#3282b8",
"#ff6b6b",
"#4ecdc4",
"#f7dc6f",
"#bb8fce",
"#85c1e9",
"#f8c471",
"#82e0aa",
"#f1948a",
"#aeb6bf",
"#ffffff",
"#f39c12",
"#000000",
];
export const COVERS = [
{ bg: "linear-gradient(135deg,#0f0c29,#302b63,#24243e)", spine: "#1a1540", accent: "#6c63ff" },
{ bg: "linear-gradient(135deg,#1a1a2e,#16213e,#0f3460)", spine: "#0a1628", accent: "#4fc3f7" },
{ bg: "linear-gradient(135deg,#134e5e,#71b280)", spine: "#0e3d4a", accent: "#a5d6a7" },
{ bg: "linear-gradient(135deg,#8e2de2,#4a00e0)", spine: "#3a0099", accent: "#ce93d8" },
{ bg: "linear-gradient(135deg,#fc4a1a,#f7b733)", spine: "#c43d15", accent: "#ffe082" },
{ bg: "linear-gradient(135deg,#c94b4b,#4b134f)", spine: "#3d0f40", accent: "#f48fb1" },
{ bg: "linear-gradient(135deg,#11998e,#38ef7d)", spine: "#0d7a71", accent: "#b9f6ca" },
{ bg: "linear-gradient(135deg,#373b44,#4286f4)", spine: "#2a2e35", accent: "#90caf9" },
{ bg: "linear-gradient(135deg,#141e30,#243b55)", spine: "#0e1520", accent: "#78909c" },
{ bg: "linear-gradient(135deg,#f12711,#f5af19)", spine: "#c01f0d", accent: "#fff59d" },
{ bg: "linear-gradient(135deg,#2c3e50,#3498db)", spine: "#1e2b38", accent: "#81d4fa" },
{ bg: "linear-gradient(135deg,#0f2027,#203a43,#2c5364)", spine: "#0a171c", accent: "#80cbc4" },
{ 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" },
];
export const DEFAULT_FOLDERS = [
{ id: "all", name: "All Notes", icon: "📋" },
{ id: "starred", name: "Starred", icon: "⭐" },
{ id: "trash", name: "Trash", icon: "🗑️" },
];
+8
View File
@@ -0,0 +1,8 @@
/** Timestamp shown on note cards and list rows. */
export const fmtDate = (d: string): string =>
new Date(d).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
+469
View File
@@ -0,0 +1,469 @@
/** The app's icon set. Inline SVG so they inherit `currentColor`. */
export const I = {
plus: (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
),
search: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
),
bold: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
</svg>
),
italic: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="19" y1="4" x2="10" y2="4" />
<line x1="14" y1="20" x2="5" y2="20" />
<line x1="15" y1="4" x2="9" y2="20" />
</svg>
),
underline: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3" />
<line x1="4" y1="21" x2="20" y2="21" />
</svg>
),
list: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="4" cy="6" r="1" fill="currentColor" />
<circle cx="4" cy="12" r="1" fill="currentColor" />
<circle cx="4" cy="18" r="1" fill="currentColor" />
</svg>
),
listOrd: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="10" y1="6" x2="21" y2="6" />
<line x1="10" y1="12" x2="21" y2="12" />
<line x1="10" y1="18" x2="21" y2="18" />
<text x="2" y="8" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">
1
</text>
<text x="2" y="14" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">
2
</text>
<text x="2" y="20" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">
3
</text>
</svg>
),
check: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M9 12l2 2 4-4" />
</svg>
),
trash: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
),
star: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
),
starFill: (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
),
pen: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 19l7-7 3 3-7 7-3-3z" />
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" />
<path d="M2 2l7.586 7.586" />
<circle cx="11" cy="11" r="2" />
</svg>
),
eraser: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M20 20H7L3 16l9-9 8 8-4 4" />
<path d="M6 11l8 8" />
</svg>
),
highlight: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M15.5 2.5L22 9l-6 6-7-7 6.5-5.5z" />
<path d="M9 8l-5 5 4 4 5-5" />
<path d="M4 13l-2 7 7-2" />
</svg>
),
undo: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
),
redo: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
),
sun: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
),
moon: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
),
menu: (
<svg
width="20"
height="20"
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>
),
text: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="4" x2="12" y2="20" />
</svg>
),
draw: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
),
edit: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
),
move: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
),
restore: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
),
grip: (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="2" />
<circle cx="15" cy="5" r="2" />
<circle cx="9" cy="12" r="2" />
<circle cx="15" cy="12" r="2" />
<circle cx="9" cy="19" r="2" />
<circle cx="15" cy="19" r="2" />
</svg>
),
logout: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
),
user: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
x: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
),
history: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 3v5h5" />
<path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" />
<path d="M12 7v5l4 2" />
</svg>
),
save: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
),
download: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
),
back: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
),
};
+80
View File
@@ -0,0 +1,80 @@
/**
* The application palette.
*
* Colours live here rather than inline so that a change to, say, the danger red
* happens in one place. Modal components still carry small local palettes of
* their own — their key names mean different things (a modal's `bg` is a raised
* surface, the app's `bg` is the page behind it), so they are deliberately not
* folded in here.
*/
export interface Theme {
bg: string;
sidebar: string;
sidebarActive: string;
card: string;
toolbar: string;
border: string;
borderLight: string;
text: string;
textSec: string;
muted: string;
faint: string;
accent: string;
accentSoft: string;
accentText: string;
danger: string;
success: string;
warning: string;
inputBg: string;
dashBg: string;
}
const DARK: Theme = {
bg: "#0d1117",
sidebar: "#010409",
sidebarActive: "#161b22",
card: "#161b22",
toolbar: "#161b22",
border: "#30363d",
borderLight: "#21262d",
text: "#e6edf3",
textSec: "#c9d1d9",
muted: "#8b949e",
faint: "#484f58",
accent: "#388bfd",
accentSoft: "#388bfd22",
accentText: "#58a6ff",
danger: "#f85149",
success: "#3fb950",
warning: "#d29922",
inputBg: "#0d1117",
dashBg: "#0d1117",
};
const LIGHT: Theme = {
bg: "#f6f8fa",
sidebar: "#ffffff",
sidebarActive: "#eef2ff",
card: "#ffffff",
toolbar: "#ffffff",
border: "#d0d7de",
borderLight: "#e5e7eb",
text: "#1f2328",
textSec: "#424a53",
muted: "#656d76",
faint: "#afb8c1",
accent: "#0969da",
accentSoft: "#0969da18",
accentText: "#0550ae",
danger: "#cf222e",
success: "#1a7f37",
warning: "#9a6700",
inputBg: "#f6f8fa",
dashBg: "#f0f2f5",
};
export const makeTheme = (dark: boolean): Theme => (dark ? DARK : LIGHT);
export const FONT_UI = "'DM Sans',sans-serif";
export const FONT_SERIF = "'Newsreader',Georgia,serif";
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"allowJs": true,
"checkJs": false,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": false,
"allowImportingTsExtensions": true
},
"include": ["src"]
}
+12 -2
View File
@@ -1,10 +1,20 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// In Docker the backend is reachable under its service name, locally under localhost.
const API_TARGET = process.env.VITE_API_TARGET || 'http://localhost:4000'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000, port: 5173,
strictPort: true,
// Needed for file-watching to work through a bind mount inside Docker.
watch: { usePolling: true, interval: 300 },
proxy: {
'/api': { target: API_TARGET, changeOrigin: true },
'/ws': { target: API_TARGET, changeOrigin: true, ws: true },
},
}, },
}) })
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
include: ["src/**/*.test.{js,ts}"],
setupFiles: ["src/test/setup.js"],
},
});
+259 -26
View File
@@ -7,7 +7,12 @@ A cross-platform note-taking application with rich text editing, freehand drawin
## Features ## Features
### Core ### Core
- **Rich Text Notes** — Bold, italic, underline, headings, bullet/numbered lists, checkboxes - **Rich Text Notes** — Bold, italic, underline, headings, bullet/numbered lists, task lists, quotes, code blocks
- **Tables** — Insert, resize columns, add/remove rows and columns, merge cells, toggle header row
- **Note Links** — Type `@` to link to another note or notebook; every note shows what links back to it
- **Version History** — Past states of every note, restorable; restoring is itself reversible
- **Full-Text Search** — Searches note bodies, not just titles, and works offline
- **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) - **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes)
- **Undo/Redo** — Full history stack for drawing operations - **Undo/Redo** — Full history stack for drawing operations
- **PDF Import & Annotation** — Import PDFs, overlay drawing/highlighting, persistent annotations - **PDF Import & Annotation** — Import PDFs, overlay drawing/highlighting, persistent annotations
@@ -20,7 +25,8 @@ A cross-platform note-taking application with rich text editing, freehand drawin
- **Conflict-Aware Sync** — Vector clock–based conflict detection; notifies user of merge conflicts - **Conflict-Aware Sync** — Vector clock–based conflict detection; notifies user of merge conflicts
- **WebSocket Real-Time Push** — Changes appear on other devices instantly - **WebSocket Real-Time Push** — Changes appear on other devices instantly
- **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server - **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server
- **JWT Authentication** — Secure user accounts with bcrypt password hashing - **JWT Authentication** — Username + password accounts with bcrypt password hashing
- **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 ### Architecture
- **Self-Hosted Backend** — Node.js + Express + SQLite (or PostgreSQL) - **Self-Hosted Backend** — Node.js + Express + SQLite (or PostgreSQL)
@@ -33,43 +39,75 @@ A cross-platform note-taking application with rich text editing, freehand drawin
``` ```
inknote/ inknote/
├── note-app.jsx # Full React UI (artifact / web) ├── docker-compose.yml # Deploy from the Gitea registry
├── docker-compose.yml # Container orchestration ├── docker-compose.dev.yml # Build from source for local work
├── build.sh # Build & push images to Gitea
├── backend/ ├── backend/
│ ├── server.js # Express API + WebSocket server │ ├── server.js # Express API + WebSocket server
│ ├── sync-client.js # Client-side sync engine library │ ├── sync-client.js # Client-side sync engine library
│ ├── package.json # Node dependencies │ ├── package.json # Node dependencies
│ ├── Dockerfile # Server container │ ├── Dockerfile # Server container
│ └── .env.example # Environment variable template │ └── .env.example # Environment variable template
└── README.md # This file ├── frontend/
│ ├── src/
│ │ ├── App.jsx # Root: where the user is, which dialog is open
│ │ ├── types.ts # Shapes shared by server, IndexedDB and UI
│ │ ├── syncEngine.ts # Offline-first sync + API client
│ │ ├── offlineStore.ts # IndexedDB wrapper
│ │ ├── search.ts # Local full-text index
│ │ ├── hooks/ # Note data, search, layout, history, drag
│ │ ├── ui/ # Sidebar, dialogs, icons, palette
│ │ ├── features/notes/ # List, editor pane, version history
│ │ ├── features/notebooks/ # Dashboard
│ │ ├── features/drawing/ # Canonical stroke model + render layer
│ │ └── editor/ # TipTap schema, toolbar, note links, attachments
│ ├── public/sw.js # PWA service worker
│ ├── nginx.conf # Static serving + API/WS proxy
│ └── dockerfile # Web container
└── readme.md # This file
``` ```
--- ---
## Quick Start ## Quick Start
### 1. Run the UI (Web / Artifact) ### Where the data lives
The `note-app.jsx` file is a self-contained React component. It runs directly as a Claude artifact, or you can embed it in any React project: Both compose files use **bind mounts**, not named volumes, so everything sits in
a plain directory on the Docker host and is picked up by whatever backs that
host up:
```jsx ```
import NoteApp from './note-app'; $INKNOTE_DATA_DIR/
├── db/ # inknote.db — notes, drawings, version history
function App() { └── uploads/ # legacy raster drawings, PDFs
return <NoteApp />;
}
``` ```
### 2. Run the Backend Default is `./data` for development and `/srv/inknote` in production; override
with `INKNOTE_DATA_DIR`. The container runs as uid/gid `1000` so the files stay
owned by a normal user — set `INKNOTE_UID` / `INKNOTE_GID` if yours differ, and
create the directory with matching ownership before the first start.
> Back up the `.env` holding `ENCRYPTION_KEY` too. Note content is encrypted at
> rest, so a copy of the database without that key cannot be read.
### 1. Local development (Docker)
```bash ```bash
cd backend # Generate secrets once
cp .env.example .env # Edit secrets! printf 'JWT_SECRET=%s\nENCRYPTION_KEY=%s\n' "$(openssl rand -base64 32)" "$(openssl rand -hex 32)" > .env
npm install
npm start # → http://localhost:4000 # Build and run backend + nginx-served frontend
docker compose -f docker-compose.dev.yml up -d --build # → http://localhost:3000
# Same, plus a Vite dev server with hot reload
docker compose -f docker-compose.dev.yml --profile dev up -d --build # → http://localhost:5173
``` ```
### 3. Docker Deployment `docker-compose.dev.yml` builds from source and uses its own volumes, so it never
touches production data.
### 2. Docker Deployment
```bash ```bash
# Generate a secure encryption key # Generate a secure encryption key
@@ -89,8 +127,15 @@ The server runs on port `4000`, the web UI on port `3000`.
| Method | Endpoint | Body | Response | | Method | Endpoint | Body | Response |
|--------|-----------------------|-------------------------------|---------------------| |--------|-----------------------|-------------------------------|---------------------|
| POST | `/api/auth/register` | `{email, password, displayName}` | `{token, user}` | | POST | `/api/auth/register` | `{username, password, displayName}` | `{token, user}` |
| POST | `/api/auth/login` | `{email, password}` | `{token, user}` | | POST | `/api/auth/login` | `{username, password}` | `{token, user}` |
| GET | `/api/auth/me` | — | `{id, username, displayName}` |
| PUT | `/api/auth/username` | `{newUsername, password}` | updated user |
| PUT | `/api/auth/password` | `{currentPassword, newPassword}` | `{success}` |
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,
deriving each username from the local part of the address (logged to the console).
### Notebooks ### Notebooks
@@ -108,9 +153,11 @@ The server runs on port `4000`, the web UI on port `3000`.
| GET | `/api/notes` | List all notes (metadata) | | GET | `/api/notes` | List all notes (metadata) |
| GET | `/api/notes/:id` | Get note with decrypted content | | GET | `/api/notes/:id` | Get note with decrypted content |
| POST | `/api/notes` | Create note | | POST | `/api/notes` | Create note |
| PUT | `/api/notes/:id` | Update note (conflict-aware) | | PUT | `/api/notes/:id` | Update note (`{content, links}`) |
| GET | `/api/notes/:id/backlinks`| Notes linking to this one |
| GET | `/api/links` | Every link, for backlink counts |
| DELETE | `/api/notes/:id` | Soft-delete note | | DELETE | `/api/notes/:id` | Soft-delete note |
| PUT | `/api/notes/:id/drawing` | Save drawing data (base64) | | PUT | `/api/notes/:id/drawing` | Save vector strokes |
| POST | `/api/notes/pdf` | Upload PDF (multipart/form) | | POST | `/api/notes/pdf` | Upload PDF (multipart/form) |
### Sync ### Sync
@@ -138,7 +185,7 @@ import { SyncEngine } from './sync-client';
const sync = new SyncEngine('https://your-server.com', 'device-abc'); const sync = new SyncEngine('https://your-server.com', 'device-abc');
// Authenticate // Authenticate
await sync.authenticate('user@example.com', 'password'); await sync.authenticate('username', 'password');
// Listen for events // Listen for events
sync.on('status', (status) => console.log('Sync:', status)); sync.on('status', (status) => console.log('Sync:', status));
@@ -159,11 +206,196 @@ await sync.createNote({ type: 'text', title: 'Hello', content: '<p>World</p>' })
await sync.updateNote('note-id', { title: 'Updated' }); await sync.updateNote('note-id', { title: 'Updated' });
await sync.deleteNote('note-id'); await sync.deleteNote('note-id');
await sync.uploadPDF(fileBlob); await sync.uploadPDF(fileBlob);
await sync.saveDrawing('note-id', canvasDataUrl); await sync.saveDrawing('note-id', strokeDoc);
``` ```
--- ---
## Working on the frontend
```bash
npm run test # Vitest — store, search and content migration
npm run typecheck # tsc --noEmit
npm run format # Prettier
npm run build # production bundle
```
TypeScript is enabled with `allowJs`, so `.js` and `.ts` coexist and modules can
be converted one at a time. The whole data layer is typed — `types`,
`offlineStore`, `search`, `editor/content` and `syncEngine` — because that is
where the bugs have been: every serious one so far has been a shape bug, or a
failure distinguished by comparing an error's `.message` to a magic string.
Failures now carry a typed `SyncError.code`; `.message` still holds the same
string, so the remaining JavaScript callers are unaffected.
The 81 unit tests concentrate on the same places, and several exist because the
bug they cover actually happened:
- a queue entry that fails must stay queued, with an attempt counter
- `fullSync` must not drop notes created offline
- `hasPendingWrites` must cover the debounce window, not just requests in flight
- v1 note bodies must migrate without losing task state or smuggling in markup
### How the frontend is arranged
`App.jsx` holds only *where the user is* — the open notebook, the open note, the
search box, whichever dialog is up — and hands each of the three panes what it
needs. Everything else lives behind a hook:
| Hook | Owns |
| --- | --- |
| `useNoteData` | notes, notebooks, bodies, every mutation, background sync |
| `useSearchIndex` | the local index and the background backfill that fills it |
| `useResponsive` | phone vs desktop, sidebar and which pane a phone shows |
| `useNavigationHistory` | mapping the current location onto browser history |
| `useNotebookDnd` | drag-to-reorder, shared by sidebar and dashboard |
| `usePwaInstall` | the browser's install offer |
`useNoteData` deliberately has no confirmation prompts in it: whether to ask
before deleting something is a question for the interface, not the store. The
panes (`Sidebar`, `Dashboard`, `NoteList`, `EditorPane`) take props and hold no
state of their own, so they can be read on their own.
---
## Note Content
The body of a note is a **TipTap/ProseMirror document** stored as JSON:
```json
{ "v": 2, "doc": { "type": "doc", "content": [ … ] } }
```
Tables, task lists and note links are real nodes rather than HTML that happened
to survive `document.execCommand`. Notes written before this were
`{ html, tasks }`; they are converted to a document the first time they are
opened and rewritten in the new format on the next save, so nothing needs
migrating up front.
### Note links and backlinks
Typing `@` opens a search over notes and notebooks and inserts a `noteLink`
node holding the target's id — so a link survives the target being renamed, and
the label is refreshed from the current title whenever the note is opened.
The server cannot read encrypted note bodies, so it cannot discover links
itself. The client sends the ids it references alongside the content, and the
server maintains a `note_links` index from them. That index is what powers
`GET /api/notes/:id/backlinks` and the "Linked from N notes" bar under the
editor.
---
## Full-Text Search
Search runs entirely on the client. The server stores note bodies encrypted and
deliberately keeps no plaintext index, so it cannot search them — and a
server-side index would undo the point of encrypting at rest.
Bodies are normally fetched only when a note is opened, which would leave a
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.
Matching is AND across terms, case- and diacritic-insensitive (`kuhlschrank`
finds *Kühlschrank*), with title matches ranked above body matches. Results show
a snippet around the first match.
> Bodies of notes edited on another device are refreshed when that note is
> opened, so search results for untouched notes can lag behind until then.
---
## Attachments
Images used to be inlined into the note body as base64 data URIs — a few
screenshots turned one note into megabytes that were re-encrypted and re-uploaded
on every keystroke. They are now separate blobs; the body only keeps an id.
- Stored on disk under the SHA-256 of their bytes, so the same image pasted into
ten notes is kept once
- Uploads are type-checked against the actual bytes, not the declared MIME type
- Downloads require the owner's token, so `<img>` cannot fetch them directly —
the client fetches with its token, caches the blob in IndexedDB and renders a
blob URL, which also makes images work offline
- Files with no database row are swept up daily, one hour after last write
| Method | Endpoint | Description |
|--------|-------------------------|------------------------------------|
| 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.
---
## Version History
Every write snapshots the state it is about to replace into `note_versions`,
encrypted the same way the note itself is.
Saves arrive about once a second while typing, so snapshots **coalesce**: within
a 5-minute window the existing newest version already represents "before this
editing session". Two cases bypass the window, because they are exactly the ones
worth undoing:
- a write that shrinks a note to under half its size (`large-deletion`)
- clearing a drawing (`drawing-cleared`)
Restoring snapshots the current state first (`before-restore`), so picking the
wrong version is not a one-way door. A restored body also rebuilds its entries
in the backlink index.
**Retention** — the newest 50 versions per note, trimmed on every write, plus a
daily sweep of anything older than 90 days. Both are configurable:
| Variable | Default | Meaning |
|----------------------------|---------|------------------------------------------|
| `MAX_VERSIONS_PER_NOTE` | `50` | Versions kept per note |
| `VERSION_RETENTION_DAYS` | `90` | Age after which a version is dropped |
| `VERSION_COALESCE_MINUTES` | `5` | Window in which saves share one snapshot |
**Deleting a note** — moving one to the trash keeps its history, because the
note can still be restored. Deleting it permanently, or emptying the trash,
removes its versions along with it (`ON DELETE CASCADE`), leaving no orphaned
rows behind.
| Method | Endpoint | Description |
|--------|-------------------------------------------|----------------------------|
| GET | `/api/notes/:id/versions` | List (metadata only) |
| GET | `/api/notes/:id/versions/:versionId` | One version, decrypted |
| POST | `/api/notes/:id/versions/:versionId/restore` | Restore it |
> Version history protects against **overwritten** data. It does not protect
> against a lost disk, because it lives in the same database — that is what
> backing up the data directory is for.
---
## Drawings
Drawings are **vector strokes**, not bitmaps. Every stroke is recorded in one canonical
page space of 1200 × 1600 units:
```json
{ "v": 1, "w": 1200, "h": 1600, "strokes": [
{ "t": "pen", "c": "#e94560", "s": 5.5, "p": [100,100, 200,180, 300,140] }
]}
```
`t` is the tool (`pen` | `highlighter` | `eraser`), `c` the colour, `s` the width in
canonical units, and `p` a flat `x,y` point list. Each viewer scales that page to the
room it has and rasterizes at its own device pixel ratio, which is what makes a note
look the same on a phone and on a desktop — and stay crisp on both. Stroke data is
encrypted at rest like note content.
Notes drawn before this format still carry a PNG in `drawing_data_path`; it renders
underneath any newer strokes so nothing is lost.
---
## Conflict Resolution ## Conflict Resolution
InkNote uses **vector clocks** to detect conflicts: InkNote uses **vector clocks** to detect conflicts:
@@ -203,7 +435,8 @@ Wrap the React app directly in Electron for desktop. Add native file system acce
| Layer | Technology | | Layer | Technology |
|-----------|-------------------------------------| |-----------|-------------------------------------|
| Frontend | React (web), adaptable to RN/Flutter | | Frontend | React 18 + Vite |
| Editor | TipTap (ProseMirror) |
| Backend | Node.js, Express | | Backend | Node.js, Express |
| Database | SQLite (dev), PostgreSQL (prod) | | Database | SQLite (dev), PostgreSQL (prod) |
| Real-time | WebSocket (ws library) | | Real-time | WebSocket (ws library) |