1116 lines
53 KiB
React
1116 lines
53 KiB
React
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
|
|
|
/* ─────────────────────── helpers ─────────────────────── */
|
|
const uid = () => Math.random().toString(36).slice(2, 10);
|
|
const now = () => new Date().toISOString();
|
|
const fmtDate = (d) => {
|
|
const dt = new Date(d);
|
|
const opts = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" };
|
|
return dt.toLocaleDateString("en-US", opts);
|
|
};
|
|
|
|
/* ─────────────────────── default data ─────────────────────── */
|
|
const DEFAULT_FOLDERS = [
|
|
{ id: "all", name: "All Notes", icon: "📋", system: true },
|
|
{ id: "starred", name: "Starred", icon: "⭐", system: true },
|
|
{ id: "drawings", name: "Drawings", icon: "🎨", system: true },
|
|
{ id: "pdfs", name: "PDFs", icon: "📄", system: true },
|
|
{ id: "trash", name: "Trash", icon: "🗑️", system: true },
|
|
];
|
|
|
|
const COLORS = [
|
|
"#1a1a2e", "#e94560", "#0f3460", "#16213e", "#533483",
|
|
"#e07c24", "#21bf73", "#fd5e53", "#3282b8", "#ff6b6b",
|
|
"#4ecdc4", "#f7dc6f", "#bb8fce", "#85c1e9", "#f8c471",
|
|
"#82e0aa", "#f1948a", "#aeb6bf", "#ffffff", "#000000",
|
|
];
|
|
|
|
const PEN_SIZES = [1, 2, 3, 5, 8, 12, 20];
|
|
|
|
const COVER_GRADIENTS = [
|
|
"linear-gradient(135deg, #0f0c29, #302b63, #24243e)",
|
|
"linear-gradient(135deg, #e44d26, #f16529, #ffd700)",
|
|
"linear-gradient(135deg, #134e5e, #71b280)",
|
|
"linear-gradient(135deg, #8e2de2, #4a00e0)",
|
|
"linear-gradient(135deg, #fc4a1a, #f7b733)",
|
|
"linear-gradient(135deg, #00b4db, #0083b0)",
|
|
"linear-gradient(135deg, #2c3e50, #3498db)",
|
|
"linear-gradient(135deg, #c94b4b, #4b134f)",
|
|
"linear-gradient(135deg, #11998e, #38ef7d)",
|
|
"linear-gradient(135deg, #373b44, #4286f4)",
|
|
"linear-gradient(135deg, #141e30, #243b55)",
|
|
"linear-gradient(135deg, #f12711, #f5af19)",
|
|
];
|
|
|
|
/* ────────────────────── Icons (inline SVG) ────────────────────── */
|
|
const Icons = {
|
|
plus: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
),
|
|
search: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
),
|
|
bold: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>,
|
|
italic: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>,
|
|
underline: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>,
|
|
list: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="4" cy="6" r="1" fill="currentColor"/><circle cx="4" cy="12" r="1" fill="currentColor"/><circle cx="4" cy="18" r="1" fill="currentColor"/></svg>,
|
|
listOrdered: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="2" y="8" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="2" y="14" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="2" y="20" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>,
|
|
checkbox: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M9 12l2 2 4-4"/></svg>,
|
|
trash: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>,
|
|
star: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>,
|
|
starFill: <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>,
|
|
pen: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>,
|
|
eraser: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M20 20H7L3 16l9-9 8 8-4 4"/><path d="M6 11l8 8"/></svg>,
|
|
undo: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>,
|
|
redo: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>,
|
|
sun: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>,
|
|
moon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>,
|
|
folder: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>,
|
|
back: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>,
|
|
menu: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>,
|
|
download: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>,
|
|
highlight: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M15.5 2.5L22 9l-6 6-7-7 6.5-5.5z"/><path d="M9 8l-5 5 4 4 5-5"/><path d="M4 13l-2 7 7-2"/></svg>,
|
|
notebook: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
|
|
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
|
|
edit: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
|
|
sync: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>,
|
|
image: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>,
|
|
close: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
|
|
};
|
|
|
|
|
|
/* ────────────────────── Drawing Canvas Component ────────────────────── */
|
|
function DrawingCanvas({ drawingData, onSave, dark, standalone = false, overlayMode = false }) {
|
|
const canvasRef = useRef(null);
|
|
const ctxRef = useRef(null);
|
|
const [isDrawing, setIsDrawing] = useState(false);
|
|
const [tool, setTool] = useState("pen");
|
|
const [color, setColor] = useState("#1a1a2e");
|
|
const [size, setSize] = useState(3);
|
|
const [history, setHistory] = useState([]);
|
|
const [historyIdx, setHistoryIdx] = useState(-1);
|
|
const [showColorPicker, setShowColorPicker] = useState(false);
|
|
const [showSizePicker, setShowSizePicker] = useState(false);
|
|
const lastPoint = useRef(null);
|
|
|
|
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",
|
|
hover: dark ? "#1c2128" : "#eef1f4",
|
|
}), [dark]);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const parent = canvas.parentElement;
|
|
if (standalone || overlayMode) {
|
|
canvas.width = parent.clientWidth;
|
|
canvas.height = parent.clientHeight;
|
|
} else {
|
|
canvas.width = parent.clientWidth;
|
|
canvas.height = Math.max(600, parent.clientHeight);
|
|
}
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.lineCap = "round";
|
|
ctx.lineJoin = "round";
|
|
ctxRef.current = ctx;
|
|
|
|
if (drawingData && drawingData.length > 0) {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
ctx.drawImage(img, 0, 0);
|
|
saveToHistory();
|
|
};
|
|
img.src = drawingData;
|
|
} else {
|
|
saveToHistory();
|
|
}
|
|
}, []);
|
|
|
|
const saveToHistory = () => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const data = canvas.toDataURL();
|
|
setHistory((prev) => {
|
|
const next = prev.slice(0, historyIdx + 1 || prev.length);
|
|
next.push(data);
|
|
return next;
|
|
});
|
|
setHistoryIdx((prev) => prev + 1);
|
|
};
|
|
|
|
const undo = () => {
|
|
if (historyIdx <= 0) return;
|
|
const newIdx = historyIdx - 1;
|
|
setHistoryIdx(newIdx);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const ctx = ctxRef.current;
|
|
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
|
ctx.drawImage(img, 0, 0);
|
|
};
|
|
img.src = history[newIdx];
|
|
};
|
|
|
|
const redo = () => {
|
|
if (historyIdx >= history.length - 1) return;
|
|
const newIdx = historyIdx + 1;
|
|
setHistoryIdx(newIdx);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const ctx = ctxRef.current;
|
|
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
|
ctx.drawImage(img, 0, 0);
|
|
};
|
|
img.src = history[newIdx];
|
|
};
|
|
|
|
const getPos = (e) => {
|
|
const canvas = canvasRef.current;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
if (e.touches) {
|
|
return {
|
|
x: (e.touches[0].clientX - rect.left) * scaleX,
|
|
y: (e.touches[0].clientY - rect.top) * scaleY,
|
|
};
|
|
}
|
|
return {
|
|
x: (e.clientX - rect.left) * scaleX,
|
|
y: (e.clientY - rect.top) * scaleY,
|
|
};
|
|
};
|
|
|
|
const startDraw = (e) => {
|
|
e.preventDefault();
|
|
const pos = getPos(e);
|
|
lastPoint.current = pos;
|
|
setIsDrawing(true);
|
|
const ctx = ctxRef.current;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pos.x, pos.y);
|
|
if (tool === "eraser") {
|
|
ctx.globalCompositeOperation = "destination-out";
|
|
ctx.lineWidth = size * 4;
|
|
} else if (tool === "highlighter") {
|
|
ctx.globalCompositeOperation = "source-over";
|
|
ctx.strokeStyle = color + "55";
|
|
ctx.lineWidth = size * 6;
|
|
} else {
|
|
ctx.globalCompositeOperation = "source-over";
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = size;
|
|
}
|
|
};
|
|
|
|
const draw = (e) => {
|
|
if (!isDrawing) return;
|
|
e.preventDefault();
|
|
const pos = getPos(e);
|
|
const ctx = ctxRef.current;
|
|
ctx.lineTo(pos.x, pos.y);
|
|
ctx.stroke();
|
|
lastPoint.current = pos;
|
|
};
|
|
|
|
const stopDraw = (e) => {
|
|
if (!isDrawing) return;
|
|
e?.preventDefault();
|
|
setIsDrawing(false);
|
|
ctxRef.current.closePath();
|
|
ctxRef.current.globalCompositeOperation = "source-over";
|
|
saveToHistory();
|
|
if (onSave) {
|
|
onSave(canvasRef.current.toDataURL());
|
|
}
|
|
};
|
|
|
|
const clearCanvas = () => {
|
|
const ctx = ctxRef.current;
|
|
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
|
saveToHistory();
|
|
if (onSave) onSave(canvasRef.current.toDataURL());
|
|
};
|
|
|
|
const toolbarStyle = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "4px",
|
|
padding: "6px 10px",
|
|
background: T.toolbar,
|
|
borderBottom: `1px solid ${T.border}`,
|
|
flexWrap: "wrap",
|
|
position: overlayMode ? "absolute" : "relative",
|
|
top: overlayMode ? 0 : undefined,
|
|
left: overlayMode ? 0 : undefined,
|
|
right: overlayMode ? 0 : undefined,
|
|
zIndex: overlayMode ? 20 : undefined,
|
|
borderRadius: overlayMode ? "0 0 8px 8px" : 0,
|
|
};
|
|
|
|
const btnStyle = (active) => ({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: 8,
|
|
border: "none",
|
|
cursor: "pointer",
|
|
background: active ? T.active + "22" : "transparent",
|
|
color: active ? T.active : T.text,
|
|
transition: "all 0.15s",
|
|
});
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
|
|
<div style={toolbarStyle}>
|
|
<button style={btnStyle(tool === "pen")} onClick={() => setTool("pen")} title="Pen">{Icons.pen}</button>
|
|
<button style={btnStyle(tool === "highlighter")} onClick={() => setTool("highlighter")} title="Highlighter">{Icons.highlight}</button>
|
|
<button style={btnStyle(tool === "eraser")} onClick={() => setTool("eraser")} title="Eraser">{Icons.eraser}</button>
|
|
<div style={{ width: 1, height: 24, background: T.border, margin: "0 4px" }} />
|
|
<button style={btnStyle(false)} onClick={undo} title="Undo" disabled={historyIdx <= 0}>{Icons.undo}</button>
|
|
<button style={btnStyle(false)} onClick={redo} title="Redo" disabled={historyIdx >= history.length - 1}>{Icons.redo}</button>
|
|
<div style={{ width: 1, height: 24, background: T.border, margin: "0 4px" }} />
|
|
|
|
{/* Color */}
|
|
<div style={{ position: "relative" }}>
|
|
<button
|
|
style={{ ...btnStyle(showColorPicker), padding: 0 }}
|
|
onClick={() => { setShowColorPicker(!showColorPicker); setShowSizePicker(false); }}
|
|
>
|
|
<div style={{ width: 20, height: 20, borderRadius: "50%", background: color, border: `2px solid ${T.border}` }} />
|
|
</button>
|
|
{showColorPicker && (
|
|
<div style={{
|
|
position: "absolute", top: 40, left: 0, background: T.toolbar, border: `1px solid ${T.border}`,
|
|
borderRadius: 12, padding: 10, display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 4,
|
|
zIndex: 100, boxShadow: "0 8px 24px rgba(0,0,0,0.15)"
|
|
}}>
|
|
{COLORS.map((c) => (
|
|
<button key={c} onClick={() => { setColor(c); setShowColorPicker(false); }} style={{
|
|
width: 28, height: 28, borderRadius: "50%", background: c, border: color === c ? `3px solid ${T.active}` : `1px solid ${T.border}`,
|
|
cursor: "pointer", transition: "transform 0.1s",
|
|
}} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Size */}
|
|
<div style={{ position: "relative" }}>
|
|
<button
|
|
style={btnStyle(showSizePicker)}
|
|
onClick={() => { setShowSizePicker(!showSizePicker); setShowColorPicker(false); }}
|
|
>
|
|
<div style={{ width: Math.min(size * 2, 18), height: Math.min(size * 2, 18), borderRadius: "50%", background: T.text }} />
|
|
</button>
|
|
{showSizePicker && (
|
|
<div style={{
|
|
position: "absolute", top: 40, left: 0, background: T.toolbar, border: `1px solid ${T.border}`,
|
|
borderRadius: 12, padding: 10, display: "flex", flexDirection: "column", gap: 6, zIndex: 100,
|
|
boxShadow: "0 8px 24px rgba(0,0,0,0.15)"
|
|
}}>
|
|
{PEN_SIZES.map((s) => (
|
|
<button key={s} onClick={() => { setSize(s); setShowSizePicker(false); }} style={{
|
|
display: "flex", alignItems: "center", gap: 10, padding: "4px 8px", borderRadius: 6,
|
|
border: "none", cursor: "pointer", background: size === s ? T.active + "22" : "transparent",
|
|
}}>
|
|
<div style={{ width: Math.min(s * 2.5, 22), height: Math.min(s * 2.5, 22), borderRadius: "50%", background: T.text }} />
|
|
<span style={{ fontSize: 12, color: T.muted }}>{s}px</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ flex: 1 }} />
|
|
<button style={{ ...btnStyle(false), color: "#e5534b" }} onClick={clearCanvas} title="Clear Canvas">{Icons.trash}</button>
|
|
</div>
|
|
|
|
<div style={{ flex: 1, overflow: "hidden", position: "relative", cursor: tool === "eraser" ? "crosshair" : "crosshair" }}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
onMouseDown={startDraw}
|
|
onMouseMove={draw}
|
|
onMouseUp={stopDraw}
|
|
onMouseLeave={stopDraw}
|
|
onTouchStart={startDraw}
|
|
onTouchMove={draw}
|
|
onTouchEnd={stopDraw}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
touchAction: "none",
|
|
background: overlayMode ? "transparent" : (dark ? "#0d1117" : "#fff"),
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
/* ────────────────────── Rich Text Editor ────────────────────── */
|
|
function RichTextEditor({ content, onChange, dark }) {
|
|
const editorRef = useRef(null);
|
|
|
|
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",
|
|
}), [dark]);
|
|
|
|
useEffect(() => {
|
|
if (editorRef.current && editorRef.current.innerHTML !== content) {
|
|
editorRef.current.innerHTML = content || "";
|
|
}
|
|
}, [content]);
|
|
|
|
const exec = (cmd, val = null) => {
|
|
document.execCommand(cmd, false, val);
|
|
editorRef.current?.focus();
|
|
if (onChange) onChange(editorRef.current.innerHTML);
|
|
};
|
|
|
|
const handleInput = () => {
|
|
if (onChange) onChange(editorRef.current.innerHTML);
|
|
};
|
|
|
|
const btnStyle = {
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: 32, height: 32, borderRadius: 6, border: "none",
|
|
cursor: "pointer", background: "transparent", color: T.text,
|
|
transition: "all 0.15s",
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 2, padding: "6px 10px",
|
|
background: T.toolbar, borderBottom: `1px solid ${T.border}`, flexWrap: "wrap",
|
|
}}>
|
|
<button style={btnStyle} onClick={() => exec("bold")} title="Bold">{Icons.bold}</button>
|
|
<button style={btnStyle} onClick={() => exec("italic")} title="Italic">{Icons.italic}</button>
|
|
<button style={btnStyle} onClick={() => exec("underline")} title="Underline">{Icons.underline}</button>
|
|
<div style={{ width: 1, height: 20, background: T.border, margin: "0 4px" }} />
|
|
<button style={btnStyle} onClick={() => exec("insertUnorderedList")} title="Bullet List">{Icons.list}</button>
|
|
<button style={btnStyle} onClick={() => exec("insertOrderedList")} title="Ordered List">{Icons.listOrdered}</button>
|
|
<button style={btnStyle} onClick={() => {
|
|
const cb = `<div style="display:flex;align-items:center;gap:8px;margin:4px 0"><input type="checkbox" style="width:16px;height:16px;accent-color:${T.active}"/> <span>Task item</span></div>`;
|
|
exec("insertHTML", cb);
|
|
}} title="Checkbox">{Icons.checkbox}</button>
|
|
<div style={{ width: 1, height: 20, background: T.border, margin: "0 4px" }} />
|
|
<select
|
|
onChange={(e) => exec("formatBlock", e.target.value)}
|
|
style={{ border: `1px solid ${T.border}`, borderRadius: 6, padding: "4px 8px", background: T.toolbar, color: T.text, fontSize: 12 }}
|
|
defaultValue=""
|
|
>
|
|
<option value="" disabled>Heading</option>
|
|
<option value="h1">Heading 1</option>
|
|
<option value="h2">Heading 2</option>
|
|
<option value="h3">Heading 3</option>
|
|
<option value="p">Paragraph</option>
|
|
</select>
|
|
</div>
|
|
<div
|
|
ref={editorRef}
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
onInput={handleInput}
|
|
style={{
|
|
flex: 1, padding: "20px 24px", outline: "none", fontSize: 15, lineHeight: 1.7,
|
|
color: T.text, overflowY: "auto", minHeight: 200,
|
|
fontFamily: "'Newsreader', 'Georgia', serif",
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
/* ────────────────────── PDF Viewer with Annotation ────────────────────── */
|
|
function PDFViewer({ pdfData, annotations, onAnnotationSave, dark }) {
|
|
const [drawMode, setDrawMode] = useState(false);
|
|
const [pageNum, setPageNum] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
const T = useMemo(() => ({
|
|
bg: dark ? "#0d1117" : "#f6f8fa",
|
|
toolbar: dark ? "#161b22" : "#ffffff",
|
|
border: dark ? "#30363d" : "#d0d7de",
|
|
text: dark ? "#e6edf3" : "#1f2328",
|
|
muted: dark ? "#8b949e" : "#656d76",
|
|
active: dark ? "#388bfd" : "#0969da",
|
|
}), [dark]);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 8, padding: "8px 14px",
|
|
background: T.toolbar, borderBottom: `1px solid ${T.border}`,
|
|
}}>
|
|
<button
|
|
onClick={() => setDrawMode(!drawMode)}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 6, padding: "6px 14px",
|
|
borderRadius: 8, border: `1px solid ${drawMode ? T.active : T.border}`,
|
|
background: drawMode ? T.active + "15" : "transparent",
|
|
color: drawMode ? T.active : T.text, cursor: "pointer", fontSize: 13,
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{Icons.pen} <span>Annotate</span>
|
|
</button>
|
|
<div style={{ flex: 1 }} />
|
|
<button onClick={() => setPageNum(Math.max(1, pageNum - 1))} disabled={pageNum <= 1}
|
|
style={{ padding: "4px 10px", borderRadius: 6, border: `1px solid ${T.border}`, background: T.toolbar, color: T.text, cursor: "pointer" }}>
|
|
←
|
|
</button>
|
|
<span style={{ fontSize: 13, color: T.muted }}>Page {pageNum} / {totalPages}</span>
|
|
<button onClick={() => setPageNum(Math.min(totalPages, pageNum + 1))} disabled={pageNum >= totalPages}
|
|
style={{ padding: "4px 10px", borderRadius: 6, border: `1px solid ${T.border}`, background: T.toolbar, color: T.text, cursor: "pointer" }}>
|
|
→
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ flex: 1, overflow: "auto", background: T.bg, position: "relative" }}>
|
|
{pdfData ? (
|
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
|
<div style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
padding: 40, color: T.muted, fontSize: 14,
|
|
flexDirection: "column", gap: 12,
|
|
}}>
|
|
<div style={{ fontSize: 48 }}>📄</div>
|
|
<div>PDF loaded — {pdfData.name}</div>
|
|
<div style={{ fontSize: 12, maxWidth: 400, textAlign: "center", lineHeight: 1.6 }}>
|
|
In a full deployment, PDF.js renders pages here. The annotation canvas overlays each page for freehand drawing, highlighting, and text annotations.
|
|
</div>
|
|
</div>
|
|
{drawMode && (
|
|
<div style={{ position: "absolute", inset: 0, top: 0 }}>
|
|
<DrawingCanvas
|
|
drawingData={annotations}
|
|
onSave={onAnnotationSave}
|
|
dark={dark}
|
|
overlayMode
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center", height: "100%",
|
|
flexDirection: "column", gap: 16, color: T.muted,
|
|
}}>
|
|
<div style={{ fontSize: 64 }}>📄</div>
|
|
<div style={{ fontSize: 15 }}>No PDF loaded</div>
|
|
<div style={{ fontSize: 13 }}>Import a PDF to view and annotate it</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
/* ────────────────────── Create Notebook Modal ────────────────────── */
|
|
function CreateNotebookModal({ onClose, onCreate, dark }) {
|
|
const [name, setName] = useState("");
|
|
const [selectedCover, setSelectedCover] = useState(0);
|
|
|
|
const T = useMemo(() => ({
|
|
bg: dark ? "#161b22" : "#ffffff",
|
|
border: dark ? "#30363d" : "#d0d7de",
|
|
text: dark ? "#e6edf3" : "#1f2328",
|
|
muted: dark ? "#8b949e" : "#656d76",
|
|
active: dark ? "#388bfd" : "#0969da",
|
|
}), [dark]);
|
|
|
|
return (
|
|
<div style={{
|
|
position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", display: "flex",
|
|
alignItems: "center", justifyContent: "center", zIndex: 1000, backdropFilter: "blur(4px)",
|
|
}} onClick={onClose}>
|
|
<div style={{
|
|
background: T.bg, borderRadius: 16, padding: 28, width: 420, maxWidth: "90vw",
|
|
boxShadow: "0 24px 48px rgba(0,0,0,0.25)", border: `1px solid ${T.border}`,
|
|
}} onClick={(e) => e.stopPropagation()}>
|
|
<h3 style={{ margin: "0 0 20px", color: T.text, fontSize: 18, fontWeight: 600, fontFamily: "'Newsreader', Georgia, serif" }}>
|
|
New Notebook
|
|
</h3>
|
|
|
|
<div style={{
|
|
width: "100%", height: 140, borderRadius: 12, background: COVER_GRADIENTS[selectedCover],
|
|
display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 20,
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
|
}}>
|
|
<span style={{ color: "#fff", fontSize: 20, fontWeight: 600, fontFamily: "'Newsreader', Georgia, serif", textShadow: "0 2px 8px rgba(0,0,0,0.3)" }}>
|
|
{name || "Notebook"}
|
|
</span>
|
|
</div>
|
|
|
|
<input
|
|
type="text" value={name} onChange={(e) => setName(e.target.value)}
|
|
placeholder="Notebook name..."
|
|
style={{
|
|
width: "100%", padding: "10px 14px", borderRadius: 10, border: `1px solid ${T.border}`,
|
|
background: dark ? "#0d1117" : "#f6f8fa", color: T.text, fontSize: 14,
|
|
outline: "none", marginBottom: 16, boxSizing: "border-box",
|
|
}}
|
|
/>
|
|
|
|
<div style={{ marginBottom: 20 }}>
|
|
<div style={{ fontSize: 13, color: T.muted, marginBottom: 8 }}>Choose cover</div>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 8 }}>
|
|
{COVER_GRADIENTS.map((g, i) => (
|
|
<button key={i} onClick={() => setSelectedCover(i)} style={{
|
|
width: "100%", aspectRatio: "3/2", borderRadius: 8, background: g, border: selectedCover === i ? `3px solid ${T.active}` : `2px solid transparent`,
|
|
cursor: "pointer", transition: "all 0.15s",
|
|
}} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
|
<button onClick={onClose} style={{
|
|
padding: "8px 20px", borderRadius: 10, border: `1px solid ${T.border}`, background: "transparent",
|
|
color: T.text, cursor: "pointer", fontSize: 13, fontWeight: 500,
|
|
}}>Cancel</button>
|
|
<button onClick={() => { if (name.trim()) onCreate({ name: name.trim(), cover: selectedCover }); }} style={{
|
|
padding: "8px 20px", borderRadius: 10, border: "none", background: T.active,
|
|
color: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500,
|
|
}}>Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
/* ────────────────────────── MAIN APP ────────────────────────── */
|
|
export default function NoteApp() {
|
|
const [dark, setDark] = useState(true);
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
const [activeFolder, setActiveFolder] = useState("all");
|
|
const [activeNote, setActiveNote] = useState(null);
|
|
const [view, setView] = useState("notes"); // notes | drawing | pdf | settings
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [showNewNotebook, setShowNewNotebook] = useState(false);
|
|
const [syncStatus, setSyncStatus] = useState("synced"); // synced | syncing | offline
|
|
const fileInputRef = useRef(null);
|
|
|
|
const [notebooks, setNotebooks] = useState([
|
|
{ id: "nb1", name: "Personal", cover: 0, createdAt: now() },
|
|
{ id: "nb2", name: "Work", cover: 2, createdAt: now() },
|
|
{ id: "nb3", name: "Ideas", cover: 4, createdAt: now() },
|
|
]);
|
|
|
|
const [notes, setNotes] = useState([
|
|
{
|
|
id: "n1", type: "text", title: "Welcome to InkNote", folderId: "nb1",
|
|
content: "<h2>Welcome to InkNote ✨</h2><p>Your cross-platform note-taking companion.</p><p>Features include:</p><ul><li><b>Rich text</b> editing with formatting</li><li>Freehand <i>drawing</i> with pen & highlighter</li><li>PDF import & annotation</li><li>Notebook organization with custom covers</li><li>Dark & light themes</li></ul><p>Start creating! Click <b>+</b> to add a new note.</p>",
|
|
starred: true, createdAt: "2025-06-10T10:00:00Z", updatedAt: now(),
|
|
},
|
|
{
|
|
id: "n2", type: "drawing", title: "Quick Sketch", folderId: "nb3",
|
|
drawingData: null, starred: false, createdAt: "2025-06-11T08:30:00Z", updatedAt: now(),
|
|
},
|
|
{
|
|
id: "n3", type: "text", title: "Meeting Notes", folderId: "nb2",
|
|
content: "<h3>Sprint Planning</h3><p>Discussed roadmap for Q3. Key points:</p><ol><li>Sync engine improvements</li><li>Mobile gesture support</li><li>PDF annotation export</li></ol>",
|
|
starred: false, createdAt: "2025-06-12T14:00:00Z", updatedAt: now(),
|
|
},
|
|
]);
|
|
|
|
const [pdfs, setPdfs] = useState([]);
|
|
|
|
// Theme
|
|
const T = useMemo(() => dark ? {
|
|
bg: "#0d1117", sidebar: "#010409", sidebarHover: "#161b22", sidebarActive: "#161b22",
|
|
card: "#161b22", cardHover: "#1c2128", toolbar: "#161b22",
|
|
border: "#30363d", borderLight: "#21262d",
|
|
text: "#e6edf3", textSecondary: "#c9d1d9", muted: "#8b949e", faint: "#484f58",
|
|
accent: "#388bfd", accentSoft: "#388bfd22", accentText: "#58a6ff",
|
|
danger: "#f85149", dangerSoft: "#f8514922",
|
|
success: "#3fb950", successSoft: "#3fb95022",
|
|
warning: "#d29922",
|
|
shadow: "0 8px 24px rgba(0,0,0,0.4)",
|
|
inputBg: "#0d1117",
|
|
} : {
|
|
bg: "#f6f8fa", sidebar: "#ffffff", sidebarHover: "#f3f4f6", sidebarActive: "#eef2ff",
|
|
card: "#ffffff", cardHover: "#f9fafb", toolbar: "#ffffff",
|
|
border: "#d0d7de", borderLight: "#e5e7eb",
|
|
text: "#1f2328", textSecondary: "#424a53", muted: "#656d76", faint: "#afb8c1",
|
|
accent: "#0969da", accentSoft: "#0969da18", accentText: "#0550ae",
|
|
danger: "#cf222e", dangerSoft: "#cf222e15",
|
|
success: "#1a7f37", successSoft: "#1a7f3715",
|
|
warning: "#9a6700",
|
|
shadow: "0 8px 24px rgba(0,0,0,0.08)",
|
|
inputBg: "#f6f8fa",
|
|
}, [dark]);
|
|
|
|
// Filtered notes
|
|
const filteredNotes = useMemo(() => {
|
|
let list = notes;
|
|
if (activeFolder === "starred") list = list.filter((n) => n.starred);
|
|
else if (activeFolder === "drawings") list = list.filter((n) => n.type === "drawing");
|
|
else if (activeFolder === "pdfs") return pdfs;
|
|
else if (activeFolder === "trash") return [];
|
|
else if (activeFolder !== "all") list = list.filter((n) => n.folderId === activeFolder);
|
|
if (searchQuery) {
|
|
const q = searchQuery.toLowerCase();
|
|
list = list.filter((n) => n.title.toLowerCase().includes(q) || (n.content || "").toLowerCase().includes(q));
|
|
}
|
|
return list.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
}, [notes, pdfs, activeFolder, searchQuery]);
|
|
|
|
const currentNote = activeNote ? notes.find((n) => n.id === activeNote) || pdfs.find((p) => p.id === activeNote) : null;
|
|
|
|
// Actions
|
|
const createNote = (type = "text") => {
|
|
const fid = ["all", "starred", "drawings", "pdfs", "trash"].includes(activeFolder) ? (notebooks[0]?.id || "nb1") : activeFolder;
|
|
const note = {
|
|
id: uid(), type, title: type === "drawing" ? "New Drawing" : "Untitled Note",
|
|
folderId: fid, content: "", drawingData: null, starred: false,
|
|
createdAt: now(), updatedAt: now(),
|
|
};
|
|
setNotes((prev) => [note, ...prev]);
|
|
setActiveNote(note.id);
|
|
setView(type === "drawing" ? "drawing" : "notes");
|
|
simulateSync();
|
|
};
|
|
|
|
const updateNote = (id, updates) => {
|
|
setNotes((prev) => prev.map((n) => n.id === id ? { ...n, ...updates, updatedAt: now() } : n));
|
|
simulateSync();
|
|
};
|
|
|
|
const deleteNote = (id) => {
|
|
setNotes((prev) => prev.filter((n) => n.id !== id));
|
|
if (activeNote === id) setActiveNote(null);
|
|
simulateSync();
|
|
};
|
|
|
|
const toggleStar = (id) => {
|
|
setNotes((prev) => prev.map((n) => n.id === id ? { ...n, starred: !n.starred } : n));
|
|
};
|
|
|
|
const importPDF = (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const pdf = {
|
|
id: uid(), type: "pdf", title: file.name, name: file.name,
|
|
pdfData: { name: file.name, size: file.size },
|
|
annotations: null, starred: false,
|
|
createdAt: now(), updatedAt: now(),
|
|
};
|
|
setPdfs((prev) => [pdf, ...prev]);
|
|
setActiveNote(pdf.id);
|
|
setView("pdf");
|
|
setActiveFolder("pdfs");
|
|
simulateSync();
|
|
};
|
|
|
|
const createNotebook = ({ name, cover }) => {
|
|
const nb = { id: uid(), name, cover, createdAt: now() };
|
|
setNotebooks((prev) => [...prev, nb]);
|
|
setShowNewNotebook(false);
|
|
setActiveFolder(nb.id);
|
|
};
|
|
|
|
const simulateSync = () => {
|
|
setSyncStatus("syncing");
|
|
setTimeout(() => setSyncStatus("synced"), 1200);
|
|
};
|
|
|
|
// Font import
|
|
useEffect(() => {
|
|
const link = document.createElement("link");
|
|
link.href = "https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap";
|
|
link.rel = "stylesheet";
|
|
document.head.appendChild(link);
|
|
}, []);
|
|
|
|
/* ─── SIDEBAR ─── */
|
|
const renderSidebar = () => (
|
|
<div style={{
|
|
width: sidebarOpen ? 260 : 0, minWidth: sidebarOpen ? 260 : 0,
|
|
background: T.sidebar, borderRight: `1px solid ${T.border}`,
|
|
display: "flex", flexDirection: "column", overflow: "hidden",
|
|
transition: "all 0.25s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
fontFamily: "'DM Sans', sans-serif",
|
|
}}>
|
|
{/* Logo area */}
|
|
<div style={{
|
|
padding: "20px 18px 12px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
}}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<div style={{
|
|
width: 32, height: 32, borderRadius: 10, background: `linear-gradient(135deg, ${T.accent}, #a855f7)`,
|
|
display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16,
|
|
}}>✏️</div>
|
|
<span style={{ fontWeight: 700, fontSize: 17, color: T.text, letterSpacing: "-0.02em" }}>InkNote</span>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
<button onClick={() => setDark(!dark)} style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center", width: 32, height: 32,
|
|
borderRadius: 8, border: "none", background: "transparent", cursor: "pointer",
|
|
color: T.muted, transition: "color 0.15s",
|
|
}}>
|
|
{dark ? Icons.sun : Icons.moon}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div style={{ padding: "0 14px 12px" }}>
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 8, padding: "8px 12px",
|
|
borderRadius: 10, background: T.inputBg, border: `1px solid ${T.borderLight}`,
|
|
}}>
|
|
<span style={{ color: T.faint }}>{Icons.search}</span>
|
|
<input
|
|
type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search notes..."
|
|
style={{
|
|
border: "none", background: "transparent", outline: "none", flex: 1,
|
|
color: T.text, fontSize: 13, fontFamily: "'DM Sans', sans-serif",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* System folders */}
|
|
<div style={{ padding: "0 10px" }}>
|
|
{DEFAULT_FOLDERS.map((f) => (
|
|
<button key={f.id} onClick={() => { setActiveFolder(f.id); setActiveNote(null); }} style={{
|
|
display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "9px 12px",
|
|
borderRadius: 10, border: "none", cursor: "pointer", fontSize: 13, fontWeight: 500,
|
|
background: activeFolder === f.id ? T.sidebarActive : "transparent",
|
|
color: activeFolder === f.id ? T.accentText : T.textSecondary,
|
|
transition: "all 0.12s", fontFamily: "'DM Sans', sans-serif", textAlign: "left",
|
|
}}>
|
|
<span style={{ fontSize: 15 }}>{f.icon}</span>
|
|
<span>{f.name}</span>
|
|
{f.id === "all" && <span style={{ marginLeft: "auto", fontSize: 11, color: T.faint, background: T.accentSoft, padding: "2px 7px", borderRadius: 10 }}>{notes.length}</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Notebooks */}
|
|
<div style={{ padding: "16px 10px 4px" }}>
|
|
<div style={{
|
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
padding: "0 12px", marginBottom: 6,
|
|
}}>
|
|
<span style={{ fontSize: 11, fontWeight: 600, color: T.faint, textTransform: "uppercase", letterSpacing: "0.08em" }}>Notebooks</span>
|
|
<button onClick={() => setShowNewNotebook(true)} style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: 22, height: 22, borderRadius: 6, border: "none",
|
|
background: T.accentSoft, cursor: "pointer", color: T.accent,
|
|
}}>
|
|
{Icons.plus}
|
|
</button>
|
|
</div>
|
|
{notebooks.map((nb) => (
|
|
<button key={nb.id} onClick={() => { setActiveFolder(nb.id); setActiveNote(null); }} style={{
|
|
display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "8px 12px",
|
|
borderRadius: 10, border: "none", cursor: "pointer", fontSize: 13, fontWeight: 500,
|
|
background: activeFolder === nb.id ? T.sidebarActive : "transparent",
|
|
color: activeFolder === nb.id ? T.accentText : T.textSecondary,
|
|
transition: "all 0.12s", fontFamily: "'DM Sans', sans-serif", textAlign: "left",
|
|
}}>
|
|
<div style={{
|
|
width: 22, height: 16, borderRadius: 4,
|
|
background: COVER_GRADIENTS[nb.cover], flexShrink: 0,
|
|
}} />
|
|
<span>{nb.name}</span>
|
|
<span style={{ marginLeft: "auto", fontSize: 11, color: T.faint }}>
|
|
{notes.filter((n) => n.folderId === nb.id).length}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Bottom actions */}
|
|
<div style={{ marginTop: "auto", padding: "12px 14px", borderTop: `1px solid ${T.borderLight}` }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6, color: T.muted, fontSize: 12 }}>
|
|
<span style={{ color: syncStatus === "synced" ? T.success : syncStatus === "syncing" ? T.warning : T.danger }}>●</span>
|
|
<span>{syncStatus === "synced" ? "All changes synced" : syncStatus === "syncing" ? "Syncing..." : "Offline"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
/* ─── NOTE LIST ─── */
|
|
const renderNoteList = () => {
|
|
const currentFolderName = (() => {
|
|
const sf = DEFAULT_FOLDERS.find((f) => f.id === activeFolder);
|
|
if (sf) return sf.name;
|
|
const nb = notebooks.find((n) => n.id === activeFolder);
|
|
return nb ? nb.name : "Notes";
|
|
})();
|
|
|
|
return (
|
|
<div style={{
|
|
width: 320, minWidth: 320, borderRight: `1px solid ${T.border}`,
|
|
display: "flex", flexDirection: "column", background: T.bg,
|
|
fontFamily: "'DM Sans', sans-serif",
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{
|
|
padding: "16px 18px 12px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
borderBottom: `1px solid ${T.borderLight}`,
|
|
}}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<button onClick={() => setSidebarOpen(!sidebarOpen)} style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: 32, height: 32, borderRadius: 8, border: "none",
|
|
background: "transparent", cursor: "pointer", color: T.muted,
|
|
}}>
|
|
{Icons.menu}
|
|
</button>
|
|
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: "'Newsreader', Georgia, serif" }}>
|
|
{currentFolderName}
|
|
</h2>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 4 }}>
|
|
<button onClick={() => createNote("text")} title="New Note" style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: 34, height: 34, borderRadius: 10, border: "none",
|
|
background: T.accent, cursor: "pointer", color: "#fff",
|
|
boxShadow: `0 2px 8px ${T.accent}44`,
|
|
}}>
|
|
{Icons.plus}
|
|
</button>
|
|
<button onClick={() => createNote("drawing")} title="New Drawing" style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: 34, height: 34, borderRadius: 10, border: `1px solid ${T.border}`,
|
|
background: "transparent", cursor: "pointer", color: T.text,
|
|
}}>
|
|
{Icons.pen}
|
|
</button>
|
|
<button onClick={() => fileInputRef.current?.click()} title="Import PDF" style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: 34, height: 34, borderRadius: 10, border: `1px solid ${T.border}`,
|
|
background: "transparent", cursor: "pointer", color: T.text, fontSize: 16,
|
|
}}>
|
|
📄
|
|
</button>
|
|
<input ref={fileInputRef} type="file" accept=".pdf" onChange={importPDF} style={{ display: "none" }} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Note cards */}
|
|
<div style={{ flex: 1, overflowY: "auto", padding: "8px" }}>
|
|
{filteredNotes.length === 0 ? (
|
|
<div style={{
|
|
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
|
height: "100%", color: T.faint, gap: 12, padding: 30,
|
|
}}>
|
|
<div style={{ fontSize: 40 }}>{activeFolder === "pdfs" ? "📄" : "📝"}</div>
|
|
<div style={{ fontSize: 14, textAlign: "center" }}>
|
|
{activeFolder === "pdfs" ? "No PDFs yet. Import one to get started." : "No notes yet. Create one to get started."}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
filteredNotes.map((note) => (
|
|
<button
|
|
key={note.id}
|
|
onClick={() => {
|
|
setActiveNote(note.id);
|
|
setView(note.type === "drawing" ? "drawing" : note.type === "pdf" ? "pdf" : "notes");
|
|
}}
|
|
style={{
|
|
display: "flex", flexDirection: "column", width: "100%", padding: "14px 16px",
|
|
borderRadius: 12, border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
|
background: activeNote === note.id ? T.accentSoft : "transparent",
|
|
cursor: "pointer", marginBottom: 4, textAlign: "left",
|
|
transition: "all 0.12s", fontFamily: "'DM Sans', sans-serif",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4, width: "100%" }}>
|
|
<span style={{ fontSize: 14 }}>
|
|
{note.type === "drawing" ? "🎨" : note.type === "pdf" ? "📄" : "📝"}
|
|
</span>
|
|
<span style={{
|
|
fontSize: 14, fontWeight: 600, color: T.text, flex: 1,
|
|
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
}}>
|
|
{note.title}
|
|
</span>
|
|
{note.starred && <span style={{ color: "#f0b429", fontSize: 12 }}>⭐</span>}
|
|
</div>
|
|
{note.type === "text" && note.content && (
|
|
<div style={{
|
|
fontSize: 12, color: T.muted, lineHeight: 1.4,
|
|
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
maxWidth: "100%",
|
|
}}>
|
|
{note.content.replace(/<[^>]*>/g, "").slice(0, 80)}
|
|
</div>
|
|
)}
|
|
<div style={{ fontSize: 11, color: T.faint, marginTop: 6 }}>
|
|
{fmtDate(note.updatedAt)}
|
|
</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ─── EDITOR AREA ─── */
|
|
const renderEditor = () => {
|
|
if (!currentNote) {
|
|
return (
|
|
<div style={{
|
|
flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
|
background: T.bg, color: T.faint, fontFamily: "'DM Sans', sans-serif", gap: 16,
|
|
}}>
|
|
<div style={{
|
|
width: 80, height: 80, borderRadius: 24, background: T.accentSoft,
|
|
display: "flex", alignItems: "center", justifyContent: "center", fontSize: 36,
|
|
}}>✏️</div>
|
|
<div style={{ fontSize: 16, fontWeight: 500, color: T.muted }}>Select a note to edit</div>
|
|
<div style={{ fontSize: 13, color: T.faint }}>Or create a new one</div>
|
|
<div style={{ display: "flex", gap: 10, marginTop: 8 }}>
|
|
<button onClick={() => createNote("text")} style={{
|
|
display: "flex", alignItems: "center", gap: 8, padding: "10px 20px",
|
|
borderRadius: 12, border: "none", background: T.accent, color: "#fff",
|
|
cursor: "pointer", fontSize: 13, fontWeight: 600, fontFamily: "'DM Sans', sans-serif",
|
|
}}>
|
|
{Icons.plus} New Note
|
|
</button>
|
|
<button onClick={() => createNote("drawing")} style={{
|
|
display: "flex", alignItems: "center", gap: 8, padding: "10px 20px",
|
|
borderRadius: 12, border: `1px solid ${T.border}`, background: "transparent",
|
|
color: T.text, cursor: "pointer", fontSize: 13, fontWeight: 600, fontFamily: "'DM Sans', sans-serif",
|
|
}}>
|
|
{Icons.pen} Drawing
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{
|
|
flex: 1, display: "flex", flexDirection: "column", background: T.bg,
|
|
fontFamily: "'DM Sans', sans-serif",
|
|
}}>
|
|
{/* Note header */}
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 8, padding: "10px 18px",
|
|
borderBottom: `1px solid ${T.borderLight}`,
|
|
}}>
|
|
<input
|
|
type="text"
|
|
value={currentNote.title}
|
|
onChange={(e) => {
|
|
if (currentNote.type === "pdf") {
|
|
setPdfs((prev) => prev.map((p) => p.id === currentNote.id ? { ...p, title: e.target.value } : p));
|
|
} else {
|
|
updateNote(currentNote.id, { title: e.target.value });
|
|
}
|
|
}}
|
|
style={{
|
|
flex: 1, border: "none", background: "transparent", outline: "none",
|
|
fontSize: 17, fontWeight: 600, color: T.text,
|
|
fontFamily: "'Newsreader', Georgia, serif",
|
|
}}
|
|
/>
|
|
<button onClick={() => toggleStar(currentNote.id)} style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: 32, height: 32, borderRadius: 8, border: "none",
|
|
background: "transparent", cursor: "pointer",
|
|
color: currentNote.starred ? "#f0b429" : T.faint,
|
|
}}>
|
|
{currentNote.starred ? Icons.starFill : Icons.star}
|
|
</button>
|
|
<button onClick={() => deleteNote(currentNote.id)} style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
width: 32, height: 32, borderRadius: 8, border: "none",
|
|
background: "transparent", cursor: "pointer", color: T.danger,
|
|
}}>
|
|
{Icons.trash}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Editor content */}
|
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
|
{currentNote.type === "drawing" ? (
|
|
<DrawingCanvas
|
|
drawingData={currentNote.drawingData}
|
|
onSave={(data) => updateNote(currentNote.id, { drawingData: data })}
|
|
dark={dark}
|
|
standalone
|
|
/>
|
|
) : currentNote.type === "pdf" ? (
|
|
<PDFViewer
|
|
pdfData={currentNote.pdfData}
|
|
annotations={currentNote.annotations}
|
|
onAnnotationSave={(data) => {
|
|
setPdfs((prev) => prev.map((p) => p.id === currentNote.id ? { ...p, annotations: data, updatedAt: now() } : p));
|
|
}}
|
|
dark={dark}
|
|
/>
|
|
) : (
|
|
<RichTextEditor
|
|
content={currentNote.content}
|
|
onChange={(html) => updateNote(currentNote.id, { content: html })}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ─── MAIN RENDER ─── */
|
|
return (
|
|
<div style={{
|
|
display: "flex", height: "100vh", width: "100vw", overflow: "hidden",
|
|
background: T.bg, color: T.text, fontFamily: "'DM Sans', sans-serif",
|
|
}}>
|
|
<style>{`
|
|
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap');
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
::-webkit-scrollbar { width: 6px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: ${T.borderLight}; border-radius: 3px; }
|
|
::-webkit-scrollbar-thumb:hover { background: ${T.faint}; }
|
|
button:hover { opacity: 0.85; }
|
|
input::placeholder { color: ${T.faint}; }
|
|
[contenteditable]:empty:before {
|
|
content: 'Start writing...';
|
|
color: ${T.faint};
|
|
pointer-events: none;
|
|
}
|
|
`}</style>
|
|
|
|
{renderSidebar()}
|
|
{renderNoteList()}
|
|
{renderEditor()}
|
|
|
|
{showNewNotebook && (
|
|
<CreateNotebookModal
|
|
onClose={() => setShowNewNotebook(false)}
|
|
onCreate={createNotebook}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
} |