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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user