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

+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>
);
}