diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fcc805 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +backend/data/ +backend/uploads/ +/data/ diff --git a/backend/server.js b/backend/server.js index 8b8fce7..f93fc0e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -35,8 +35,36 @@ const http = require("http"); /* ─────────── Config ─────────── */ 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 UPLOAD_DIR = path.join(__dirname, "uploads"); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); @@ -50,7 +78,7 @@ db.pragma("foreign_keys = ON"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, display_name TEXT, created_at TEXT DEFAULT (datetime('now')), @@ -110,30 +138,128 @@ db.exec(` 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 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_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_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 ─────────── */ +/* 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) { - const iv = crypto.randomBytes(16); - const key = Buffer.from(ENCRYPTION_KEY, "hex"); - const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); - let encrypted = cipher.update(text, "utf8", "hex"); - encrypted += cipher.final("hex"); - return iv.toString("hex") + ":" + encrypted; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv); + const enc = Buffer.concat([cipher.update(String(text), "utf8"), cipher.final()]); + return `v2:${iv.toString("hex")}:${cipher.getAuthTag().toString("hex")}:${enc.toString("hex")}`; } function decrypt(data) { - const [ivHex, encryptedHex] = data.split(":"); - const iv = Buffer.from(ivHex, "hex"); - const key = Buffer.from(ENCRYPTION_KEY, "hex"); - const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); - let decrypted = decipher.update(encryptedHex, "hex", "utf8"); - decrypted += decipher.final("utf8"); - return decrypted; + const s = String(data); + if (s.startsWith("v2:")) { + const [, ivHex, tagHex, encHex] = s.split(":"); + const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, Buffer.from(ivHex, "hex")); + decipher.setAuthTag(Buffer.from(tagHex, "hex")); + return decipher.update(encHex, "hex", "utf8") + decipher.final("utf8"); + } + 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 = "") { @@ -188,9 +314,44 @@ function authMiddleware(req, res, next) { /* ─────────── Express app ─────────── */ const app = express(); +app.set("trust proxy", 1); // nginx sits in front; needed for real client IPs app.use(helmet()); -app.use(cors()); -app.use(express.json({ limit: "50mb" })); +app.use(cors(ALLOWED_ORIGINS.length ? { origin: ALLOWED_ORIGINS } : { origin: false })); +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({ dest: UPLOAD_DIR, @@ -202,66 +363,88 @@ const upload = multer({ }); /* ─── Auth routes ─── */ -app.post("/api/auth/register", (req, res) => { - const { email, password, displayName } = req.body; - if (!email || !password) return res.status(400).json({ error: "Email and password required" }); +app.post("/api/auth/register", async (req, res, next) => { + try { + 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); - if (existing) return res.status(409).json({ error: "Email already registered" }); + const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(name); + if (existing) return res.status(409).json({ error: "Username already taken" }); - const id = uuid(); - const hash = bcrypt.hashSync(password, 12); - db.prepare("INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, email, hash, displayName || email.split("@")[0]); + const id = uuid(); + const hash = await bcrypt.hash(password, 12); + 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" }); - res.json({ token, user: { id, email, displayName: displayName || email.split("@")[0] } }); + const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" }); + res.json({ token, user: { id, username: name, displayName: displayName || name } }); + } catch (err) { next(err); } }); -app.post("/api/auth/login", (req, res) => { - const { email, password } = req.body; - const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email); - if (!user || !bcrypt.compareSync(password, user.password_hash)) { - return res.status(401).json({ error: "Invalid credentials" }); - } - const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" }); - res.json({ token, user: { id: user.id, email: user.email, displayName: user.display_name } }); +app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => { + try { + const { username, password } = req.body; + const name = String(username ?? "").trim(); + const user = name ? db.prepare("SELECT * FROM users WHERE username = ?").get(name) : null; + // Always run a comparison so timing does not leak whether the user exists. + const ok = await bcrypt.compare(String(password ?? ""), user ? user.password_hash : DUMMY_HASH); + if (!user || !ok) return res.status(401).json({ error: "Invalid credentials" }); + + 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 ─── */ 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" }); - 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) => { const { displayName } = req.body; 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); - const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId); - res.json({ id: user.id, email: user.email, displayName: user.display_name }); + const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId); + res.json({ id: user.id, username: user.username, displayName: user.display_name }); }); -app.put("/api/auth/email", authMiddleware, (req, res) => { - const { newEmail, password } = req.body; - if (!newEmail || !password) return res.status(400).json({ error: "Email and password required" }); +app.put("/api/auth/username", authMiddleware, async (req, res, next) => { + try { + const { newUsername, password } = req.body; + const name = normalizeUsername(newUsername); + if (!name || !password) return res.status(400).json({ error: "Username and password required" }); 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" }); - const exists = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(newEmail, req.userId); - if (exists) return res.status(409).json({ error: "Email already in use" }); - db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?").run(newEmail, req.userId); - res.json({ id: user.id, email: newEmail, displayName: user.display_name }); + if (!user) return res.status(404).json({ error: "User not found" }); + if (!(await bcrypt.compare(String(password), user.password_hash))) return res.status(401).json({ error: "Wrong password" }); + const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId); + if (exists) return res.status(409).json({ error: "Username already taken" }); + db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(name, req.userId); + res.json({ id: user.id, username: name, displayName: user.display_name }); + } catch (err) { next(err); } }); -app.put("/api/auth/password", authMiddleware, (req, res) => { - const { currentPassword, newPassword } = req.body; - if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" }); - if (newPassword.length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" }); - const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); - if (!bcrypt.compareSync(currentPassword, user.password_hash)) return res.status(401).json({ error: "Current password is wrong" }); - const hash = bcrypt.hashSync(newPassword, 12); - db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId); - res.json({ success: true }); +app.put("/api/auth/password", authMiddleware, async (req, res, next) => { + try { + const { currentPassword, newPassword } = req.body; + if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" }); + if (String(newPassword).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" }); + const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); + if (!user) return res.status(404).json({ error: "User not found" }); + if (!(await bcrypt.compare(String(currentPassword), user.password_hash))) return res.status(401).json({ error: "Current password is wrong" }); + 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 ─── */ @@ -301,6 +484,61 @@ app.delete("/api/notebooks/:id", authMiddleware, (req, res) => { 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 ─── */ 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); @@ -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); 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) { try { note.content = decrypt(note.content_encrypted.toString()); - } catch { - note.content = ""; + } catch (err) { + 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; - // 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)) { try { const raw = fs.readFileSync(note.drawing_data_path); @@ -359,8 +613,24 @@ app.post("/api/notes", authMiddleware, (req, res) => { 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) => { - 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); 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 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 title = COALESCE(?, title), content_encrypted = COALESCE(?, content_encrypted), @@ -394,10 +673,106 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => { 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); + 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); 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) ─── */ 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); @@ -425,30 +800,55 @@ app.delete("/api/notes/:id/permanent", authMiddleware, (req, res) => { }); /* ─── Drawing data route ─── */ +const MAX_STROKES = 20000; + 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); if (!note) return res.status(404).json({ error: "Not found" }); - if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "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 {} + if (drawingStrokes !== undefined) { + // Vector strokes in the note's canonical coordinate space — the current format. + if (drawingStrokes !== null) { + if (typeof drawingStrokes !== "object" || !Array.isArray(drawingStrokes.strokes)) + return res.status(400).json({ error: "Invalid strokes format" }); + if (drawingStrokes.strokes.length > MAX_STROKES) + return res.status(413).json({ error: "Too many strokes" }); } + 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', ?, ?)") .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); @@ -456,6 +856,69 @@ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => { 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 ─── */ app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => { 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 userConnections = new Map(); // userId -> Set -wss.on("connection", (ws, req) => { - let userId = null; +const AUTH_GRACE_MS = 10000; +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) => { try { const msg = JSON.parse(data); - if (msg.type === "auth") { - const payload = jwt.verify(msg.token, JWT_SECRET); - userId = payload.userId; - const deviceId = msg.deviceId || "unknown"; + if (msg.type !== "auth") return; + const payload = jwt.verify(msg.token, JWT_SECRET); + ws._userId = payload.userId; + ws._deviceId = msg.deviceId || "unknown"; + clearTimeout(authTimer); - if (!userConnections.has(userId)) userConnections.set(userId, new Map()); - userConnections.get(userId).set(deviceId, ws); - ws._deviceId = deviceId; + // A Set, not a Map keyed by device: two tabs on one machine share a + // device id, and keying by it meant the second tab silently replaced the + // 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 { - ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); + try { ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); } catch {} } }); ws.on("close", () => { - if (userId && userConnections.has(userId)) { - userConnections.get(userId).delete(ws._deviceId); - if (userConnections.get(userId).size === 0) userConnections.delete(userId); - } + clearTimeout(authTimer); + const set = ws._userId && userConnections.get(ws._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) { - if (!userConnections.has(userId)) return; - const connections = userConnections.get(userId); - for (const [deviceId, ws] of connections) { - if (deviceId !== excludeDeviceId && ws.readyState === 1) { - ws.send(JSON.stringify(message)); + const conns = userConnections.get(userId); + if (!conns) return; + const payload = JSON.stringify(message); + for (const ws of conns) { + 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() }); }); +/* ─── 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 ─── */ server.listen(PORT, () => { console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`); 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")}\n`); + console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}`); + 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")); diff --git a/build.sh b/build.sh old mode 100755 new mode 100644 index 9189000..67e0bc1 --- a/build.sh +++ b/build.sh @@ -2,7 +2,7 @@ set -e REGISTRY="gitea.honeyy.duckdns.org" -OWNER="docker" +OWNER="${GITEA_OWNER:-marc}" TAG="${1:-latest}" FRONTEND_IMAGE="${REGISTRY}/${OWNER}/inknote-web:${TAG}" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..e93c65f --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index f1d7253..e30d9b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,9 +5,15 @@ version: "3.8" # Pull images from Gitea container registry # # Usage: +# mkdir -p /srv/inknote/db /srv/inknote/uploads +# chown -R 1000:1000 /srv/inknote # docker login gitea.honeyy.duckdns.org -# docker-compose pull -# docker-compose up -d +# docker compose pull +# 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: @@ -20,9 +26,13 @@ services: - JWT_SECRET=${JWT_SECRET} - ENCRYPTION_KEY=${ENCRYPTION_KEY} - 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: - - inknote-data:/app/data - - inknote-uploads:/app/uploads + - ${INKNOTE_DATA_DIR:-/srv/inknote}/db:/app/data + - ${INKNOTE_DATA_DIR:-/srv/inknote}/uploads:/app/uploads healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] interval: 30s diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..1a99321 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +package-lock.json diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..a46f9fc --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "printWidth": 110, + "singleQuote": false, + "semi": true, + "trailingComma": "es5", + "arrowParens": "always", + "bracketSameLine": false +} diff --git a/frontend/index.html b/frontend/index.html index d89647b..393c4fa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,7 +11,7 @@ - + InkNote diff --git a/frontend/nginx.conf b/frontend/nginx.conf index da34936..6f12c7f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -29,8 +29,8 @@ server { try_files $uri =404; } - # Icons - location ~* ^/icon-.*\.png$ { + # Icons (icon-*.png and apple-touch-icon.png) + location ~* ^/(icon-.*|apple-touch-icon)\.png$ { add_header Cache-Control "public, max-age=86400"; try_files $uri =404; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b164984 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3956 @@ +{ + "name": "inknote-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "inknote-frontend", + "version": "1.0.0", + "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-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.3.1", + "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" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tiptap/core": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", + "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", + "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", + "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", + "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", + "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", + "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", + "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", + "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", + "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", + "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", + "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", + "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", + "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", + "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", + "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz", + "integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", + "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", + "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", + "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", + "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", + "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz", + "integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", + "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.2.tgz", + "integrity": "sha512-pDbhOpT5phZkcsyPjGBQlXv0+0hmdrvqHJ+dJjkGcCtlfy2pHiEIhmIItOFagc7wXy8G9iUFZ9Jie4zvDf+brg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.2.tgz", + "integrity": "sha512-9Lk46MjZMFzVZfOj9Kd7VgC6Odt6vmEhlCYVumErShUY7EkFqCw3b2IYoUtQkntfOEx/Afnhff/okNQwPsJeUA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.2.tgz", + "integrity": "sha512-ZEb6lbG0NbbodWLV0b4BS/QrDIPlUbCcuOsUxzqVvlMUY1Vg6Fj6fKwLaBcsIUDHi8sxZDBEgYEDw3BR/zcO6A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.2.tgz", + "integrity": "sha512-Nw9+tA56Y5HtLVP01NGCZSUuTQhJPtfK9OfmDgGgcxynn2cRVdEtj+9FNZqRhQ1iRVaAI+Rd4xRvX9qYePMOxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-task-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.27.2.tgz", + "integrity": "sha512-ZBSqj/dygB/Rp5K9qOxRVwASTZCmKVoTq8C59KvMgD/aFjJxhq/w2dZaWkCUEXEep+NmvJqo0kfeAEMY5UDnGg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-task-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.27.2.tgz", + "integrity": "sha512-5nupAewdzZ9F3599oAcaK0WkDH04wdACAVBPM4zG7InlIpkbho3txB7zWmm64OxfhCMIMGKiXY1q0bw9i0QBGQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", + "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", + "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.27.2.tgz", + "integrity": "sha512-gPOsbAcw1S07ezpAISwoO8f0RxpjcSH7VsHEFDVuXm4ODE32nhvSinvHQjv2icRLOXev+bnA7oIBu7Oy859gWQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/html": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.27.2.tgz", + "integrity": "sha512-WiZgAvFjUprjyAczxAHLN9k++w7klwsCJ7EJDljtW/QXQ476Q0GqNtaHG4WRuW25cBH7c7AWZFptALeak2OE4Q==", + "license": "MIT", + "dependencies": { + "zeed-dom": "^0.15.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", + "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz", + "integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==", + "license": "MIT", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.27.2", + "@tiptap/extension-floating-menu": "^2.27.2", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", + "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.27.2", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-dropcursor": "^2.27.2", + "@tiptap/extension-gapcursor": "^2.27.2", + "@tiptap/extension-hard-break": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-history": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/pm": "^2.27.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/suggestion": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.27.2.tgz", + "integrity": "sha512-dQyvCIg0hcAVeh4fCIVCxogvbp+bF+GpbUb8sNlgnGrmHXnapGxzkvrlHnvneXZxLk/j7CxmBPKJNnm4Pbx4zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.5.tgz", + "integrity": "sha512-ac8trNQ01ybKDRTcfUc56LZufG3oYyU4N25qSXgp8dS0U4JtzzCj7oQlKu5v09VSmS5IseYoQ2yDkTbo7f7D8Q==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zeed-dom": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zeed-dom/-/zeed-dom-0.15.1.tgz", + "integrity": "sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==", + "license": "MIT", + "dependencies": { + "css-what": "^6.1.0", + "entities": "^5.0.0" + }, + "engines": { + "node": ">=14.13.1" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/holtwick" + } + }, + "node_modules/zeed-dom/node_modules/entities": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz", + "integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 794e5f5..81d0f0a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,14 +6,40 @@ "scripts": { "dev": "vite", "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": { + "@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-dom": "^18.3.1" }, "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", "@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" } -} \ No newline at end of file +} diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..99135c1 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..696f1a1 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..8c69383 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon-maskable-192.png b/frontend/public/icon-maskable-192.png new file mode 100644 index 0000000..b93f1e3 Binary files /dev/null and b/frontend/public/icon-maskable-192.png differ diff --git a/frontend/public/icon-maskable-512.png b/frontend/public/icon-maskable-512.png new file mode 100644 index 0000000..ed07243 Binary files /dev/null and b/frontend/public/icon-maskable-512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index aca96dd..e3d560e 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -9,7 +9,10 @@ "background_color": "#0d1117", "theme_color": "#0d1117", "orientation": "any", - "categories": ["productivity", "utilities"], + "categories": [ + "productivity", + "utilities" + ], "icons": [ { "src": "./icon-192.png", @@ -24,17 +27,17 @@ "purpose": "any" }, { - "src": "./icon-192.png", + "src": "./icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "./icon-512.png", + "src": "./icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "prefer_related_applications": false -} \ No newline at end of file +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js index efeb1bc..aa1e20a 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'inknote-v6'; +const CACHE_NAME = 'inknote-v7'; // Install — don't precache, just activate immediately self.addEventListener('install', () => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..9e8d6e1 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+ Loading... +
+ ); + + if (!user) return ; + + // 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 ( +
+ + + 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" ? ( + setSidebarOpen(!sidebarOpen)} + onOpenNotebook={openFolder} + onOpenNote={(note) => { + setActiveFolder(note.folderId); + openNote(note.id); + }} + onNewNotebook={() => setShowNewNb(true)} + onNotebookContextMenu={notebookContextMenu} + dnd={dnd} + /> + ) : ( + <> + {showList && ( + setSidebarOpen(!sidebarOpen)} + onSelectNote={openNote} + onNoteContextMenu={noteContextMenu} + onCreateNote={createNote} + onEmptyTrash={emptyTrash} + /> + )} + {showEditor && ( + data.updateNote(activeNote, u)} + onToggleStar={() => data.toggleStar(activeNote)} + onTrash={() => trashNote(activeNote)} + onRestore={() => restoreNote(activeNote)} + onPermanentDelete={() => permanentDeleteNote(activeNote)} + onOpenHistory={() => setHistoryFor(activeNote)} + onCreateNote={createNote} + /> + )} + + )} + + {showNewNb && ( + setShowNewNb(false)} onCreate={createNotebook} dark={dark} /> + )} + {editingNb && ( + setEditingNb(null)} + onSave={(u) => updateNotebook(editingNb.id, u)} + dark={dark} + /> + )} + {showAccount && ( + setShowAccount(false)} + onLogout={() => { + setShowAccount(false); + onLogout(); + }} + onUserUpdate={setUser} + dark={dark} + /> + )} + {historyFor && ( + n.id === historyFor)?.title} + dark={dark} + onClose={() => setHistoryFor(null)} + onRestored={(content) => { + data.setNoteContent(historyFor, content); + setRestoreEpoch((e) => e + 1); + }} + renderDrawing={(strokes) => ( + + )} + /> + )} + {confirmDialog && ( + setConfirmDialog(null)} + dark={dark} + /> + )} + {ctxMenu && ( + setCtxMenu(null)} + dark={dark} + /> + )} +
+ ); +} diff --git a/frontend/src/NoteApp.jsx b/frontend/src/NoteApp.jsx deleted file mode 100644 index afaceb9..0000000 --- a/frontend/src/NoteApp.jsx +++ /dev/null @@ -1,1094 +0,0 @@ -import { useState, useRef, useEffect, useMemo } from "react"; -import * as sync from "./syncEngine.js"; - -const uid = () => Math.random().toString(36).slice(2, 10); -const now = () => new Date().toISOString(); -const fmtDate = (d) => new Date(d).toLocaleDateString("en-US",{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}); - -const COLORS = ["#0d1117","#e94560","#0f3460","#533483","#e07c24","#21bf73","#fd5e53","#3282b8","#ff6b6b","#4ecdc4","#f7dc6f","#bb8fce","#85c1e9","#f8c471","#82e0aa","#f1948a","#aeb6bf","#ffffff","#f39c12","#000000"]; -const PEN_SIZES = [1,2,3,5,8,12,20]; -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"}, -]; -const DEFAULT_FOLDERS = [{id:"all",name:"All Notes",icon:"📋"},{id:"starred",name:"Starred",icon:"⭐"},{id:"trash",name:"Trash",icon:"🗑️"}]; -const I = { - plus:, - search:, - bold:, - italic:, - underline:, - list:, - listOrd:123, - check:, - trash:, - star:, - starFill:, - pen:, - eraser:, - highlight:, - undo:, - redo:, - sun:, - moon:, - menu:, - text:, - draw:, - edit:, - move:, - restore:, - grip:, - logout:, - user:, - x:, - save:, -}; -/* ═══════ Shared Components ═══════ */ -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(
-
e.stopPropagation()}> -
{title}
-
{message}
-
- - -
-
-
); -} - -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(
e.stopPropagation()}> - {items.map((item,i)=>item.divider?
:item.submenu?:( - - ))} -
); -} - -function SubMenu({item,T,onClose}){ - const [open,setOpen]=useState(false); - return(
setOpen(true)} onMouseLeave={()=>setOpen(false)}> -
- {item.icon&&{item.icon}}{item.label}▸ -
- {open&&
- {item.submenu.map((sub,j)=>)} -
} -
); -} - -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(
-
e.stopPropagation()}> -

Edit Notebook

- 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});}}/> -
-
Cover style
-
- {COVERS.map((c,i)=>)} -
-
-
- - -
-
-
); -} - -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(
-
e.stopPropagation()}> -

New Notebook

- 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});}}/> -
-
Choose cover style
-
- {COVERS.map((c,i)=>)} -
-
-
- - -
-
-
); -} - - -/* ═══════ ACCOUNT MENU ═══════ */ -function AccountMenu({user,onClose,onLogout,onUserUpdate,dark}){ - const [tab,setTab]=useState("profile"); // profile | email | password - const [displayName,setDisplayName]=useState(user?.displayName||""); - const [newEmail,setNewEmail]=useState(user?.email||""); - const [emailPass,setEmailPass]=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 saveEmail=async()=>{if(!newEmail||!emailPass)return;clear();setSaving(true); - try{const u=await sync.updateEmail(newEmail,emailPass);onUserUpdate(u);setEmailPass("");setMsg("Email 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(
-
e.stopPropagation()}> -
-

Account

- -
-
- - - -
-
- {msg&&
{msg}
} - {err&&
{err}
} - - {tab==="profile"&&<> - - setDisplayName(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")saveProfile();}}/> - - } - - {tab==="email"&&<> - - setNewEmail(e.target.value)} style={inp}/> - - setEmailPass(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")saveEmail();}}/> - - } - - {tab==="password"&&<> - - setCurPass(e.target.value)} style={inp}/> - - setNewPass(e.target.value)} style={inp}/> - - setNewPass2(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")savePassword();}}/> - - } -
-
- -
-
-
); -} - -/* ═══════ AUTH SCREEN ═══════ */ -function AuthScreen({onLogin,dark}){ - const [isLogin,setIsLogin]=useState(true); - const [email,setEmail]=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()=>{ - if(!email||!pass){setErr("Email and password required");return;} - setLoading(true);setErr(""); - try{ - const user=isLogin?await sync.login(email,pass):await sync.register(email,pass,name||undefined); - onLogin(user); - }catch(e){setErr(e.message==="AUTH"?"Invalid credentials":e.message);} - finally{setLoading(false);} - }; - return(
-
-
-
✏️
-
InkNote
-
{isLogin?"Sign in to sync your notes":"Create your account"}
-
- {!isLogin&&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"}}/>} - setEmail(e.target.value)} placeholder="Email" autoFocus 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"}}/> - setPass(e.target.value)} placeholder="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&&
{err}
} - -
- {isLogin?"Don't have an account? ":"Already have an account? "} - -
-
-
); -} -/* ═══════ TASK LIST (proper React state, not contentEditable) ═══════ */ -function TaskList({tasks,onChange,dark}){ - const T=useMemo(()=>({border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",active:dark?"#388bfd":"#0969da",faint:dark?"#484f58":"#afb8c1",done:dark?"#484f58":"#afb8c1"}),[dark]); - const inputRefs=useRef({}); - const toggle=(i)=>{const t=[...tasks];t[i]={...t[i],done:!t[i].done};onChange(t);}; - const setText=(i,text)=>{const t=[...tasks];t[i]={...t[i],text};onChange(t);}; - const addAfter=(i)=>{const t=[...tasks];const nid=uid();t.splice(i+1,0,{id:nid,text:"",done:false});onChange(t);setTimeout(()=>inputRefs.current[nid]?.focus(),0);}; - const remove=(i)=>{ - if(tasks.length<=1&&tasks[0].text==="")return; - const t=[...tasks];t.splice(i,1);onChange(t); - if(t.length>0){const fi=Math.max(0,i-1);setTimeout(()=>{const el=inputRefs.current[t[fi]?.id];if(el){el.focus();el.selectionStart=el.value.length;}},0);} - }; - const handleKey=(e,i)=>{ - if(e.key==="Enter"){e.preventDefault();if(tasks[i].text===""){remove(i);}else{addAfter(i);}} - else if(e.key==="Backspace"&&tasks[i].text===""&&tasks.length>1){e.preventDefault();remove(i);} - }; - if(!tasks||tasks.length===0)return null; - return(
-
Tasks ({tasks.filter(t=>t.done).length}/{tasks.length})
- {tasks.map((task,i)=>( -
- toggle(i)} style={{width:16,height:16,accentColor:T.active,cursor:"pointer",flexShrink:0}}/> - inputRefs.current[task.id]=el} type="text" value={task.text} onChange={e=>setText(i,e.target.value)} - onKeyDown={e=>handleKey(e,i)} placeholder="Task..." - style={{flex:1,border:"none",background:"transparent",outline:"none",fontSize:14,color:task.done?T.done:T.text,textDecoration:task.done?"line-through":"none",fontFamily:"'DM Sans',sans-serif"}}/> - -
- ))} - -
); -} - -/* ═══════ NOTE EDITOR ═══════ */ -function NoteEditor({note,onUpdate,dark}){ - const [mode,setMode]=useState("text"); - const editorRef=useRef(null);const canvasRef=useRef(null);const ctxRef=useRef(null); - const [isDrawing,setIsDrawing]=useState(false); - const [tool,setTool]=useState("pen");const [penColor,setPenColor]=useState(dark?"#e6edf3":"#1a1a2e");const [penSize,setPenSize]=useState(3); - const [undoStack,setUndoStack]=useState([]);const [redoStack,setRedoStack]=useState([]); - const [showColors,setShowColors]=useState(false);const [showSizes,setShowSizes]=useState(false); - const [selectedImg,setSelectedImg]=useState(null); - 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 CW=1200,CH=1600; // Canonical drawing resolution shared by all devices. - const SAVE_DEBOUNCE_MS=600; - const [stageSize,setStageSize]=useState({w:CW,h:CH}); - - const parsed=useMemo(()=>{try{const p=JSON.parse(note.content);return{html:p.html||"",tasks:p.tasks||[]};}catch{return{html:note.content||"",tasks:[]};}},[note.content,note.id]); - 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]); - - useEffect(()=>{ - const el=editorRef.current; - if(!el)return; - const prevNoteId=el.dataset.noteId; - const isNewNote=prevNoteId!==note.id; - if(isNewNote){ - el.innerHTML=parsed.html; - el.dataset.noteId=note.id; - return; - } - // Late content arrival for the same note (mobile/network): fill only if editor is still empty. - const hasLocalText=(el.textContent||"").trim().length>0; - if(!hasLocalText && parsed.html){ - el.innerHTML=parsed.html; - } - },[note.id,parsed.html]); - - 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;i0.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*CH)/CW); - 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(!canvasRef.current||!dirtyRef.current)return; - onUpdate({drawingData:canvasRef.current.toDataURL("image/png")}); - dirtyRef.current=false; - }; - const scheduleDrawingSave=()=>{ - if(saveTimerRef.current)clearTimeout(saveTimerRef.current); - saveTimerRef.current=setTimeout(()=>{saveTimerRef.current=null;saveDrawingNow();},SAVE_DEBOUNCE_MS); - }; - - /* ── DRAWING: init canvas once on mode switch ── */ - useEffect(()=>{ - if(mode!=="draw")return; - const c=canvasRef.current;if(!c)return; - c.width=CW;c.height=CH; - const ctx=c.getContext("2d");ctx.lineCap="round";ctx.lineJoin="round";ctxRef.current=ctx; - ctx.clearRect(0,0,CW,CH); - dirtyRef.current=false; - if(note.drawingData){ - const img=new Image();img.onload=()=>{ - const t=document.createElement("canvas");t.width=CW;t.height=CH; - const tctx=t.getContext("2d",{willReadFrequently:true});if(!tctx){ctx.drawImage(img,0,0,CW,CH);return;} - tctx.drawImage(img,0,0,CW,CH); - try{ - const sc=document.createElement("canvas");sc.width=40;sc.height=40; - const sctx=sc.getContext("2d",{willReadFrequently:true});if(!sctx)throw new Error("ctx"); - sctx.drawImage(t,0,0,40,40); - const sample=sctx.getImageData(0,0,40,40).data; - let solid=0,black=0; - for(let i=0;i0.9){ - const im=tctx.getImageData(0,0,CW,CH);const d=im.data; - for(let i=0;i245)d[i+3]=0; - } - tctx.putImageData(im,0,0); - } - }catch{} - ctx.drawImage(t,0,0,CW,CH); - };img.src=note.drawingData; - } - },[mode]); - - /* Save drawing on mode switch and debounced stroke-end updates. */ - const switchMode=(m)=>{ - if(mode==="draw"&&m==="text"&&dirtyRef.current&&canvasRef.current){ - if(saveTimerRef.current){clearTimeout(saveTimerRef.current);saveTimerRef.current=null;} - saveDrawingNow(); - } - setMode(m);setShowColors(false);setShowSizes(false);setSelectedImg(null); - }; - - const pushUndo=()=>{if(!canvasRef.current)return;const d=canvasRef.current.toDataURL("image/png");setUndoStack(p=>[...p.slice(-15),d]);setRedoStack([]);}; - const undo=()=>{if(!undoStack.length)return;const s=undoStack[undoStack.length-1];setUndoStack(p=>p.slice(0,-1)); - setRedoStack(p=>[...p,canvasRef.current.toDataURL("image/png")]); - const i=new Image();i.onload=()=>{ctxRef.current.clearRect(0,0,CW,CH);ctxRef.current.drawImage(i,0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();};i.src=s;}; - const redo=()=>{if(!redoStack.length)return;const s=redoStack[redoStack.length-1];setRedoStack(p=>p.slice(0,-1)); - setUndoStack(p=>[...p,canvasRef.current.toDataURL("image/png")]); - const i=new Image();i.onload=()=>{ctxRef.current.clearRect(0,0,CW,CH);ctxRef.current.drawImage(i,0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();};i.src=s;}; - const clearCanvas=()=>{pushUndo();ctxRef.current?.clearRect(0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();}; - - const getPos=(e)=>{const c=canvasRef.current,r=c.getBoundingClientRect();const t=e.touches?e.touches[0]:e;return{x:(t.clientX-r.left)*(CW/r.width),y:(t.clientY-r.top)*(CH/r.height)};}; - const startDraw=(e)=>{e.preventDefault();pushUndo();const p=getPos(e);setIsDrawing(true); - const ctx=ctxRef.current;ctx.beginPath();ctx.moveTo(p.x,p.y); - if(tool==="eraser"){ctx.globalCompositeOperation="destination-out";ctx.lineWidth=penSize*5;} - else if(tool==="highlighter"){ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor+"44";ctx.lineWidth=penSize*6;} - else{ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor;ctx.lineWidth=penSize;}}; - const doDraw=(e)=>{if(!isDrawing)return;e.preventDefault();const p=getPos(e);ctxRef.current.lineTo(p.x,p.y);ctxRef.current.stroke();}; - const stopDraw=(e)=>{if(!isDrawing)return;e?.preventDefault();setIsDrawing(false);ctxRef.current.closePath();ctxRef.current.globalCompositeOperation="source-over";dirtyRef.current=true;scheduleDrawingSave();}; - - useEffect(()=>()=>{if(saveTimerRef.current)clearTimeout(saveTimerRef.current);saveDrawingNow();},[]); - - /* ── TEXT ── */ - const saveContent=(html,tasks)=>{onUpdate({content:JSON.stringify({html,tasks})});}; - const onHtmlChange=()=>{if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; - const onTasksChange=(tasks)=>{saveContent(parsed.html,tasks);}; - const addTaskList=()=>{if(parsed.tasks.length===0)onTasksChange([{id:uid(),text:"",done:false}]);}; - const exec=(cmd,val=null)=>{document.execCommand(cmd,false,val);editorRef.current?.focus();onHtmlChange();}; - - /* ── IMAGE PASTE ── */ - useEffect(()=>{ - const el=editorRef.current;if(!el||mode!=="text")return; - const h=(e)=>{ - const items=e.clipboardData?.items;if(!items)return; - for(const item of items){ - if(item.type.startsWith("image/")){ - e.preventDefault(); - const blob=item.getAsFile();if(!blob)return; - const reader=new FileReader(); - reader.onload=(ev)=>{ - document.execCommand("insertHTML",false, - ``); - if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks); - }; - reader.readAsDataURL(blob);return; - } - } - }; - el.addEventListener("paste",h);return()=>el.removeEventListener("paste",h); - },[mode,parsed.tasks]); - - /* ── IMAGE SELECT + DRAG RESIZE ── */ - useEffect(()=>{ - const el=editorRef.current;if(!el||mode!=="text")return; - - const onClick=(e)=>{ - const img=e.target.closest("img[data-rid]"); - setSelectedImg(img||null); - }; - - const onPointerDown=(e)=>{ - const img=e.target.closest("img[data-rid]");if(!img)return; - const rect=img.getBoundingClientRect(); - const px=e.clientX||(e.touches?.[0]?.clientX); - // Only start resize if clicking within 20px of right edge - if(px{ - ev.preventDefault(); - const mx=ev.clientX||(ev.touches?.[0]?.clientX); - const nw=Math.max(40,Math.min(cW,startW+(mx-startX))); - const pct=Math.round(nw/cW*100); - img.style.width=pct+"%";img.style.maxWidth=pct+"%"; - }; - const up=()=>{ - document.removeEventListener("mousemove",move);document.removeEventListener("mouseup",up); - document.removeEventListener("touchmove",move);document.removeEventListener("touchend",up); - if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks); - }; - document.addEventListener("mousemove",move);document.addEventListener("mouseup",up); - document.addEventListener("touchmove",move,{passive:false});document.addEventListener("touchend",up); - }; - - // Cursor hint near right edge - const onMove=(e)=>{ - const img=e.target.closest("img[data-rid]");if(!img){return;} - const r=img.getBoundingClientRect(); - img.style.cursor=e.clientX>r.right-20?"ew-resize":"pointer"; - }; - - el.addEventListener("click",onClick); - el.addEventListener("mousedown",onPointerDown); - el.addEventListener("touchstart",onPointerDown,{passive:false}); - el.addEventListener("mousemove",onMove); - return()=>{el.removeEventListener("click",onClick);el.removeEventListener("mousedown",onPointerDown); - el.removeEventListener("touchstart",onPointerDown);el.removeEventListener("mousemove",onMove);}; - },[mode,parsed.tasks]); - - // Blue outline on selected image - useEffect(()=>{ - const el=editorRef.current;if(!el)return; - el.querySelectorAll("img[data-rid]").forEach(img=>{ - if(img===selectedImg){img.style.outline=`3px solid ${T.active}`;img.style.outlineOffset="2px";} - else{img.style.outline="none";img.style.outlineOffset="0";} - }); - },[selectedImg,T.active]); - - // Deselect on outside click - useEffect(()=>{if(!selectedImg)return; - const h=(e)=>{if(!editorRef.current?.contains(e.target)&&!e.target.closest("[data-imgtb]"))setSelectedImg(null);}; - document.addEventListener("pointerdown",h);return()=>document.removeEventListener("pointerdown",h); - },[selectedImg]); - - const setImgSize=(p)=>{if(!selectedImg)return;selectedImg.style.width=p+"%";selectedImg.style.maxWidth=p+"%";if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; - const deleteImg=()=>{if(!selectedImg)return;selectedImg.remove();setSelectedImg(null);if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; - - /* ── 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)=>; - - return(
- {/* Main toolbar */} -
- {mb("text",I.text,"Write")}{mb("draw",I.draw,"Draw")} -
- {mode==="text"?(<> - - - -
- - - -
- - ):(<> - - - -
- -
-
- - {showColors&&
- {COLORS.map(c=>
} -
-
- - {showSizes&&
- {PEN_SIZES.map(s=>)} -
} -
-
- )} -
- - {/* Image toolbar — appears when an image is selected */} - {selectedImg&&mode==="text"&&
- Size: - {[25,33,50,75,100].map(p=>)} -
- - · drag right edge to resize -
} - - {/* Content area */} -
-
-
-
- {note.drawingData&&!hideDrawing&&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}}/>} - {mode==="draw"&&} - {mode==="text"&&} -
-
-
-
); -} -/* ═══════ NOTEBOOK COVER ═══════ */ -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(); -} - - -/* ════════════════════════════ MAIN APP (offline-first) ════════════════════════════ */ -export default function NoteApp(){ - const [dark,setDark]=useState(true); - const [user,setUser]=useState(null);const [loading,setLoading]=useState(true); - const [sidebarOpen,setSidebarOpen]=useState(window.innerWidth>768); - const [activeFolder,setActiveFolder]=useState("dashboard"); - const [activeNote,setActiveNote]=useState(null); - const [searchQuery,setSearchQuery]=useState(""); - const [showNewNb,setShowNewNb]=useState(false); - const [syncStatus,setSyncStatus]=useState("synced"); - const [ctxMenu,setCtxMenu]=useState(null); - const [confirmDialog,setConfirmDialog]=useState(null); - const [editingNb,setEditingNb]=useState(null); - const [showAccount,setShowAccount]=useState(false); - const [dragNbId,setDragNbId]=useState(null);const [dragOverNbId,setDragOverNbId]=useState(null); - const [notebooks,setNotebooks]=useState([]);const [notes,setNotes]=useState([]); - const [noteContents,setNoteContents]=useState({}); - const [isMobile,setIsMobile]=useState(window.innerWidth<768); - const [mobileView,setMobileView]=useState("list"); // list | editor - const [installPrompt,setInstallPrompt]=useState(null); - - const T=useMemo(()=>dark?{ - 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", - }:{ - 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", - },[dark]); - - // ── Responsive ── - useEffect(()=>{const h=()=>{const m=window.innerWidth<768;setIsMobile(m);if(!m)setSidebarOpen(true);};window.addEventListener("resize",h);return()=>window.removeEventListener("resize",h);},[]); - - // ── PWA install prompt ── - useEffect(()=>{ - const h=(e)=>{e.preventDefault();setInstallPrompt(e);}; - window.addEventListener("beforeinstallprompt",h); - return()=>window.removeEventListener("beforeinstallprompt",h); - },[]); - const handleInstall=async()=>{if(!installPrompt)return;installPrompt.prompt();const r=await installPrompt.userChoice;if(r.outcome==="accepted")setInstallPrompt(null);}; - - // ── Sync status listener ── - useEffect(()=>sync.onSyncStatus(setSyncStatus),[]); - - // ── Data change listener — reload from IndexedDB ── - useEffect(()=>sync.onDataChange(async()=>{ - setNotebooks(await sync.getNotebooks()); - setNotes(await sync.getNotes()); - // Keep loaded note content cached to avoid editor flicker/stalls on periodic sync - }),[]); - - // ── Startup: restore session → load local data → sync ── - useEffect(()=>{(async()=>{ - try{ - const u=await sync.restoreSession(); - if(u){setUser(u);await reloadLocal(); - // Background sync if online - try{await sync.fullSync();await reloadLocal();}catch(e){if(e.message==="AUTH"){setUser(null);}} - } - }catch{} - setLoading(false); - })();},[]); - - // Reload from IndexedDB - const reloadLocal=async()=>{ - setNotebooks(await sync.getNotebooks()); - setNotes(await sync.getNotes()); - }; - - const onLogin=async(u)=>{setUser(u); - try{await sync.fullSync();}catch{} - await reloadLocal(); - }; - const onLogout=async()=>{await sync.logout();setUser(null);setNotes([]);setNotebooks([]);setNoteContents({});setActiveNote(null);setActiveFolder("dashboard");}; - - const activeNotes=useMemo(()=>notes.filter(n=>!n.trashed),[notes]); - const trashedNotes=useMemo(()=>notes.filter(n=>n.trashed),[notes]); - const filteredNotes=useMemo(()=>{ - let list; - if(activeFolder==="trash")list=trashedNotes; - else if(activeFolder==="starred")list=activeNotes.filter(n=>n.starred); - else if(activeFolder==="all")list=activeNotes; - else if(activeFolder!=="dashboard")list=activeNotes.filter(n=>n.folderId===activeFolder); - else list=activeNotes; - if(searchQuery){const q=searchQuery.toLowerCase();list=list.filter(n=>n.title.toLowerCase().includes(q));} - return list.sort((a,b)=>new Date(b.updatedAt)-new Date(a.updatedAt)); - },[notes,activeFolder,searchQuery,activeNotes,trashedNotes]); - - const currentNoteMeta=activeNote?notes.find(n=>n.id===activeNote):null; - const currentNoteContent=activeNote?noteContents[activeNote]:null; - const activeNoteHasContent=!!currentNoteContent; - const currentNote=currentNoteMeta&¤tNoteContent?{...currentNoteMeta,...currentNoteContent}:null; - const currentNoteSafe=currentNote||( - currentNoteMeta - ? {...currentNoteMeta,content:currentNoteContent?.content||"",drawingData:currentNoteContent?.drawingData||null} - : null - ); - - // Load note content on demand — fetches server-first on cache miss (page load = all cache empty) - useEffect(()=>{ - if(!activeNote || activeNoteHasContent) return; - let cancelled=false; - sync.loadNoteContent(activeNote) - .then(c=>{ if(!cancelled) setNoteContents(p=>({...p,[activeNote]:c})); }) - .catch(()=>{ if(!cancelled) setNoteContents(p=>({...p,[activeNote]:{content:"",drawingData:null}})); }); - return ()=>{cancelled=true;}; - },[activeNote,activeNoteHasContent]); - - // Hard fallback: never stay in loading state forever. - useEffect(()=>{ - if(!activeNote || activeNoteHasContent) return; - const t=setTimeout(()=>{ - setNoteContents(p=>p[activeNote]?p:{...p,[activeNote]:{content:"",drawingData:null}}); - },5000); - return ()=>clearTimeout(t); - },[activeNote,activeNoteHasContent]); - - // Keep currently open note content fresh across devices. - useEffect(()=>{ - if(!user||!activeNote)return; - const i=setInterval(()=>{ - if(!sync.isOnline())return; - sync.loadNoteContent(activeNote,{forceRemote:true}).then(c=>{ - setNoteContents(p=>{ - const prev=p[activeNote]; - if(prev&&prev.content===c.content&&prev.drawingData===c.drawingData)return p; - return {...p,[activeNote]:c}; - }); - }).catch(()=>{}); - },6000); - return()=>clearInterval(i); - },[user,activeNote]); - - // ── CRUD — all go through sync engine ── - const createNote=async()=>{ - const fid=["all","starred","trash","dashboard"].includes(activeFolder)?(notebooks[0]?.id):activeFolder;if(!fid)return; - const note=await sync.createNote(fid); - setNotes(p=>[note,...p]);setNoteContents(p=>({...p,[note.id]:{content:"",drawingData:null}})); - setActiveNote(note.id);if(activeFolder==="dashboard")setActiveFolder(fid); - if(isMobile)setMobileView("editor"); - }; - - const updateNote=async(id,u)=>{ - if(u.title!==undefined){setNotes(p=>p.map(n=>n.id===id?{...n,title:u.title,updatedAt:now()}:n));sync.updateNoteTitle(id,u.title);} - if(u.content!==undefined){setNoteContents(p=>({...p,[id]:{...p[id],content:u.content}}));sync.updateNoteContent(id,u.content);} - if(u.drawingData!==undefined){setNoteContents(p=>({...p,[id]:{...p[id],drawingData:u.drawingData}}));sync.updateNoteDrawing(id,u.drawingData);} - }; - - const toggleStar=async(id)=>{const nv=await sync.toggleNoteStar(id);setNotes(p=>p.map(n=>n.id===id?{...n,starred:nv}:n));}; - - const trashNoteAction=(id)=>{const note=notes.find(n=>n.id===id); - setConfirmDialog({title:"Move to Trash",message:`Move "${note?.title}" to trash?`,confirmLabel:"Move to Trash", - onConfirm:async()=>{await sync.trashNote(id);setNotes(p=>p.map(n=>n.id===id?{...n,trashed:true}:n)); - if(activeNote===id){setActiveNote(null);if(isMobile)setMobileView("list");}setConfirmDialog(null);}}); - }; - - const restoreNote=async(id)=>{await sync.restoreNoteLocal(id);setNotes(p=>p.map(n=>n.id===id?{...n,trashed:false}:n));if(activeNote===id){setActiveNote(null);if(isMobile)setMobileView("list");}}; - - const permanentDeleteNote=(id)=>{const note=notes.find(n=>n.id===id); - setConfirmDialog({title:"Delete Permanently",message:`Permanently delete "${note?.title}"? This cannot be undone.`, - onConfirm:async()=>{await sync.permanentDeleteNoteLocal(id);setNotes(p=>p.filter(n=>n.id!==id)); - if(activeNote===id){setActiveNote(null);if(isMobile)setMobileView("list");}setConfirmDialog(null);}}); - }; - - const emptyTrash=()=>{ - setConfirmDialog({title:"Empty Trash",message:`Permanently delete all ${trashedNotes.length} trashed notes?`, - onConfirm:async()=>{await sync.emptyTrashLocal();setNotes(p=>p.filter(n=>!n.trashed));setActiveNote(null);setConfirmDialog(null);}}); - }; - - const moveNoteAction=async(id,newFolderId)=>{await sync.moveNote(id,newFolderId);setNotes(p=>p.map(n=>n.id===id?{...n,folderId:newFolderId}:n));}; - - const createNb=async({name,cover})=>{const nb=await sync.createNotebook({name,cover});setNotebooks(p=>[...p,nb]);setShowNewNb(false);setActiveFolder(nb.id);}; - const updateNb=async(id,updates)=>{await sync.updateNotebook(id,updates);setNotebooks(p=>p.map(nb=>nb.id===id?{...nb,...updates}:nb));setEditingNb(null);}; - - const requestDeleteNb=(id)=>{const nb=notebooks.find(n=>n.id===id);const nc=activeNotes.filter(n=>n.folderId===id).length; - setConfirmDialog({title:"Delete Notebook",message:`Delete "${nb?.name}"${nc>0?` and trash its ${nc} notes`:""} permanently?`, - onConfirm:async()=>{await sync.deleteNotebookLocal(id); - setNotes(p=>p.map(n=>n.folderId===id?{...n,trashed:true}:n));setNotebooks(p=>p.filter(n=>n.id!==id)); - if(activeFolder===id)setActiveFolder("dashboard");setConfirmDialog(null);}}); - }; - - const handleNbDragStart=(e,id)=>{setDragNbId(id);e.dataTransfer.effectAllowed="move";}; - const handleNbDragOver=(e,id)=>{e.preventDefault();if(dragNbId&&dragNbId!==id)setDragOverNbId(id);}; - const handleNbDrop=async(e,targetId)=>{e.preventDefault();if(!dragNbId||dragNbId===targetId)return; - setNotebooks(prev=>{const a=[...prev];const fi=a.findIndex(n=>n.id===dragNbId);const ti=a.findIndex(n=>n.id===targetId);if(fi<0||ti<0)return prev;const[item]=a.splice(fi,1);a.splice(ti,0,item); - const ids=a.map(n=>n.id);sync.reorderNotebooks(ids);return a.map((n,i)=>({...n,sortOrder:i}));}); - setDragNbId(null);setDragOverNbId(null);}; - const handleNbDragEnd=()=>{setDragNbId(null);setDragOverNbId(null);}; - - const handleNbContextMenu=(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:()=>requestDeleteNb(nb.id)}]});}; - - const handleNoteContextMenu=(e,note)=>{e.preventDefault();e.stopPropagation(); - if(note.trashed){setCtxMenu({x:e.clientX,y:e.clientY,items:[{label:"Restore",icon:I.restore,action:()=>restoreNote(note.id)},{divider:true},{label:"Delete Permanently",icon:I.trash,danger:true,action:()=>permanentDeleteNote(note.id)}]});} - else{const subs=notebooks.filter(nb=>nb.id!==note.folderId).map(nb=>({label:nb.name,colorDot:COVERS[nb.cover%COVERS.length].bg,action:()=>moveNoteAction(note.id,nb.id)})); - setCtxMenu({x:e.clientX,y:e.clientY,items:[{label:note.starred?"Unstar":"Star",icon:note.starred?I.starFill:I.star,action:()=>toggleStar(note.id)},...(subs.length>0?[{label:"Move to...",icon:I.move,submenu:subs}]:[]),{divider:true},{label:"Move to Trash",icon:I.trash,danger:true,action:()=>trashNoteAction(note.id)}]});} - }; - - useEffect(()=>{const l=document.createElement("link");l.href="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";l.rel="stylesheet";document.head.appendChild(l);},[]); - - // ── Periodic sync (every 30s if online) ── - useEffect(()=>{const i=setInterval(()=>{if(sync.isOnline())sync.fullSync().then(()=>reloadLocal()).catch(()=>{});},30000);return()=>clearInterval(i);},[]); - - // ── History API — browser/phone back button ── - const navStateRef=useRef({folder:"dashboard",note:null,view:"list"}); - useEffect(()=>{ - const cur=navStateRef.current; - if(cur.folder!==activeFolder||cur.note!==activeNote||cur.view!==mobileView){ - const state={folder:activeFolder,note:activeNote,view:mobileView}; - try{window.history.pushState(state,"");}catch{} - navStateRef.current=state; - } - },[activeFolder,activeNote,mobileView]); - - useEffect(()=>{ - const handler=(e)=>{ - const s=e.state; - if(s){setActiveFolder(s.folder||"dashboard");setActiveNote(s.note||null);if(isMobile)setMobileView(s.view||"list");} - else{if(activeNote){setActiveNote(null);if(isMobile)setMobileView("list");}else if(activeFolder!=="dashboard"){setActiveFolder("dashboard");setActiveNote(null);}} - }; - window.addEventListener("popstate",handler);return()=>window.removeEventListener("popstate",handler); - },[activeNote,activeFolder,isMobile]); - - if(loading)return
Loading...
; - if(!user)return; - const isTrashView=activeFolder==="trash"; - - /* ── Back button for mobile ── */ - const backBtn=(label,onClick)=>; - - /* ── Sidebar ── */ - const renderSidebar=()=>( -
- {isMobile&&sidebarOpen&&
setSidebarOpen(false)} style={{position:"absolute",inset:0,background:"rgba(0,0,0,0.4)",zIndex:-1}}/>} -
-
-
-
✏️
- InkNote -
-
- - -
-
-
-
- {I.search} - setSearchQuery(e.target.value)} placeholder="Search notes..." style={{border:"none",background:"transparent",outline:"none",flex:1,color:T.text,fontSize:13,fontFamily:"'DM Sans',sans-serif"}}/> -
-
-
- -
-
- {DEFAULT_FOLDERS.map(f=>( - - ))} -
-
-
- Notebooks - -
- {notebooks.map(nb=>( -
handleNbDragStart(e,nb.id)} onDragOver={e=>handleNbDragOver(e,nb.id)} onDrop={e=>handleNbDrop(e,nb.id)} onDragEnd={handleNbDragEnd} onContextMenu={e=>handleNbContextMenu(e,nb)} - style={{display:"flex",alignItems:"center",borderRadius:10,transition:"all .15s",background:dragOverNbId===nb.id?T.accentSoft:activeFolder===nb.id?T.sidebarActive:"transparent",borderTop:dragOverNbId===nb.id?`2px solid ${T.accent}`:"2px solid transparent"}}> - {!isMobile&&{I.grip}} - -
- ))} -
-
- {installPrompt&&} -
- ● - {syncStatus==="synced"?"All synced":syncStatus==="syncing"?"Syncing...":syncStatus==="offline"?"Offline mode":syncStatus==="pending"?"Changes pending":"Sync error"} - {!sync.isOnline()&&offline} -
-
-
-
- ); - - /* ── Dashboard ── */ - const renderDashboard=()=>( -
-
-
- -

My Notebooks

-
-
-
- {notebooks.map(nb=>( - n.folderId===nb.id).length} - onClick={()=>{setActiveFolder(nb.id);setActiveNote(null);}} onContextMenu={e=>handleNbContextMenu(e,nb)} - isActive={activeFolder===nb.id} isDragOver={dragOverNbId===nb.id} draggable={!isMobile} - onDragStart={e=>handleNbDragStart(e,nb.id)} onDragEnd={handleNbDragEnd} onDragOver={e=>handleNbDragOver(e,nb.id)} onDrop={e=>handleNbDrop(e,nb.id)}/> - ))} - -
-
-

Recent Notes

-
- {activeNotes.slice(0,6).map(note=>{const nb=notebooks.find(b=>b.id===note.folderId);return( - - );})} -
-
-
- ); - - /* ── Note List ── */ - const renderNoteList=()=>{ - const fname=(()=>{const sf=DEFAULT_FOLDERS.find(f=>f.id===activeFolder);if(sf)return sf.name;const nb=notebooks.find(n=>n.id===activeFolder);return nb?nb.name:"Notes";})(); - // On mobile, hide list when editor is open - if(isMobile&&mobileView==="editor"&&activeNote)return null; - return( -
-
-
- -

{fname}

-
-
- {!isTrashView&&} - {isTrashView&&trashedNotes.length>0&&} -
-
-
- {filteredNotes.length===0?( -
-
{isTrashView?"🗑️":"📝"}
{isTrashView?"Trash is empty":"No notes yet"}
- {!isTrashView&&} -
- ):filteredNotes.map(note=>( - - ))} -
-
- ); - }; - - /* ── Editor ── */ - const renderEditor=()=>{ - // On mobile, hide editor when list is shown - if(isMobile&&mobileView!=="editor")return null; - if(!currentNoteSafe)return isMobile?null:( -
-
✏️
-
Select a note to edit
- {!isTrashView&&} -
- ); - if(isTrashView&¤tNoteMeta)return( -
-
- {isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})} - {currentNoteMeta.title} - - -
-
Trashed notes are read-only. Restore to edit.
-
- ); - return( -
-
- {isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})} - updateNote(currentNoteSafe.id,{title:e.target.value})} - style={{flex:1,border:"none",background:"transparent",outline:"none",fontSize:isMobile?15:17,fontWeight:600,color:T.text,fontFamily:"'Newsreader',Georgia,serif",minWidth:0}}/> - - -
-
- updateNote(currentNoteSafe.id,u)} dark={dark}/> -
-
- ); - }; - - return( -
- - {renderSidebar()} - {activeFolder==="dashboard"?renderDashboard():(<>{renderNoteList()}{renderEditor()})} - {showNewNb&&setShowNewNb(false)} onCreate={createNb} dark={dark}/>} - {editingNb&&setEditingNb(null)} onSave={u=>updateNb(editingNb.id,u)} dark={dark}/>} - {showAccount&&setShowAccount(false)} onLogout={()=>{setShowAccount(false);onLogout();}} onUserUpdate={u=>setUser(u)} dark={dark}/>} - {confirmDialog&&setConfirmDialog(null)} dark={dark}/>} - {ctxMenu&&setCtxMenu(null)} dark={dark}/>} -
- ); -} diff --git a/frontend/src/editor/EditorToolbar.jsx b/frontend/src/editor/EditorToolbar.jsx new file mode 100644 index 0000000..b8a929f --- /dev/null +++ b/frontend/src/editor/EditorToolbar.jsx @@ -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 = () => ( + + + + + + +); +const LinkIcon = () => ( + + + + +); +const QuoteIcon = () => ( + + + + +); +const CodeIcon = () => ( + + + + +); +const AtIcon = () => ( + + + + +); + +/** + * 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 = ( +
+ ); + 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 ( + <> + + + + {divider} + + + + {divider} + + {/* Tables */} +
+ + {showTable && ( +
+ + {inTable && ( + <> +
+ + + + +
+ + +
+ + + + + )} +
+ )} +
+ + + + + {!compact && ( + <> + {divider} + + + + )} + + {divider} + + + {divider} + + + + ); +} + +export default EditorToolbar; diff --git a/frontend/src/editor/VersionPreview.jsx b/frontend/src/editor/VersionPreview.jsx new file mode 100644 index 0000000..44c1d38 --- /dev/null +++ b/frontend/src/editor/VersionPreview.jsx @@ -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 ( +
+ +
+ ); +} + +export default VersionPreview; diff --git a/frontend/src/editor/attachmentImage.js b/frontend/src/editor/attachmentImage.js new file mode 100644 index 0000000..a604286 --- /dev/null +++ b/frontend/src/editor/attachmentImage.js @@ -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; diff --git a/frontend/src/editor/attachments.js b/frontend/src/editor/attachments.js new file mode 100644 index 0000000..3967a98 --- /dev/null +++ b/frontend/src/editor/attachments.js @@ -0,0 +1,73 @@ +/** + * Attachment plumbing for the editor. + * + * An `` 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 + +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(); +} diff --git a/frontend/src/editor/content.test.ts b/frontend/src/editor/content.test.ts new file mode 100644 index 0000000..93ea93c --- /dev/null +++ b/frontend/src/editor/content.test.ts @@ -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: "

Title

Text with bold and italic.

  • one
", + 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: "

body

", + 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: "", done: false }] }) + ); + expect(typesIn(doc)).not.toContain("script"); + expect(docToText(doc)).toContain("" })); + expect(text).toContain("safe"); + expect(text).not.toContain("