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

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

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

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

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

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

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

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
dist/
node_modules/
package-lock.json
+8
View File
@@ -0,0 +1,8 @@
{
"printWidth": 110,
"singleQuote": false,
"semi": true,
"trailingComma": "es5",
"arrowParens": "always",
"bracketSameLine": false
}
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="description" content="Write & draw — notes that sync everywhere" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="./manifest.json" />
<link rel="apple-touch-icon" href="./icon-192.png" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✏️</text></svg>" />
<title>InkNote</title>
+2 -2
View File
@@ -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;
}
+3956
View File
File diff suppressed because it is too large. Load diff
+29 -3
View File
@@ -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"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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