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:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14354
-1826
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user