import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx"; import { EditorToolbar } from "../../editor/EditorToolbar.jsx"; import { tableMenuItems } from "../../editor/tableMenu.js"; import { ContextMenu } from "../../ui/ContextMenu.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, sheetDocOf, noteSheets, sheetStrokes, sheetsWith, drawnSheets, 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, noteFont, onOpenNote, searchLinkTargets, resolveLinkLabel, resolveLinkIcon, backlinks, }) { const [mode, setMode] = useState("text"); 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 [tableMenu, setTableMenu] = useState(null); 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 }); /* How many pages the writing fills, and which one is being looked at. * * Pages here are a reading aid, not a change to the document: the note stays * one continuous body, and Markdown has no concept of a page anyway. The * height of one is the same 3:4 the canvas uses, so "page 2" means the same * thing whether you are writing or drawing. */ const [pages, setPages] = useState({ count: 1, current: 1 }); const scrollerRef = useRef(null); /* ── DRAWING STATE ── * sheetsRef is what the pointer handlers read and write synchronously; * `sheets` mirrors it so React can repaint the read-only layers. Both are the * drawing's pages: page number → strokes, with drawn-on pages only. */ const sheetsRef = useRef({}); const [sheets, setSheets] = useState({}); const liveRef = useRef(null); // stroke currently under the pointer const drawingRef = useRef(false); const drawPageRef = useRef(1); // the page that stroke is being drawn on const canvasesRef = useRef(new Map()); // page number → its canvas const committedRef = useRef(null); // offscreen copy of the page being drawn on const appliedDocRef = useRef(undefined); const pendingTokenRef = useRef(null); const drawnPages = useMemo(() => drawnSheets({ sheets }), [sheets]); const hasStrokes = drawnPages.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; /* The gap between two sheets. * * The same idea as the PDF reader's, which is the point: a page in a note and * a page in a document should read as the same kind of thing. The text flow * knows about it too — `editor/pagination.js` holds it open inside the * document so no paragraph is ever cut in half by it. */ const PAGE_GAP = isMobileViewport ? 10 : 16; const pitch = stageSize.h + PAGE_GAP; /* Handed to the editor, and read fresh on every measurement rather than * captured, so resizing the window re-breaks the text without rebuilding the * editor and losing the cursor. */ const pageGeometry = useMemo( () => ({ pageHeight: stageSize.h, gap: PAGE_GAP }), [stageSize.h, PAGE_GAP] ); const commitSheets = (next) => { sheetsRef.current = next; setSheets(next); }; const pageStrokes = (page) => sheetsRef.current[String(page)] ?? []; 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, pageGeometry, 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. * * Measured *before* the first paint, not after. The stage starts at the * canonical page — 1200 × 1600 — and this runs on every note, because the * editor is built fresh for each one. As an ordinary effect it ran a frame * too late, so opening a note drew one frame of a page sixteen hundred pixels * tall before the measurement pulled it back: the sheet shot up long and * narrow and then righted itself, every single time. */ useLayoutEffect(() => { 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 = sheetDocOf(sheetsRef.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); }; /* Recounted whenever the text could have changed height — on every editor * transaction, on resize, and once the fonts have actually loaded, since a * fallback face measures differently from the real one. */ useEffect(() => { const surface = () => scrollerRef.current?.querySelector(".ProseMirror"); const recount = () => { const el = surface(); if (!el || !stageSize.h) return; const height = el.scrollHeight; const scroller = scrollerRef.current; /* Counted in whole pitches — a page and the gap after it — because that is * what the text is broken against. Drawn pages count too: ink put on page * four of a two-page note is on the note, and a count that ignored it * would report two pages while showing four, leaving the fourth with no * canvas to be redrawn on. */ const written = Math.max(1, Math.floor(Math.max(0, height - 1) / pitch) + 1); const count = Math.max(written, drawnPages[drawnPages.length - 1] ?? 1); /* The page under the middle of the viewport is the one you are reading — * the same rule the PDF reader uses. Measuring from the top instead said * "page 1" while you were looking at the foot of a two-page note, because * a window is shorter than a page. */ const middle = scroller ? scroller.scrollTop + scroller.clientHeight / 2 : 0; const current = Math.max(1, Math.min(count, Math.floor(middle / pitch) + 1)); setPages((p) => (p.count === count && p.current === current ? p : { count, current })); }; recount(); const scroller = scrollerRef.current; scroller?.addEventListener("scroll", recount, { passive: true }); document.fonts?.ready?.then(recount).catch(() => {}); if (!textEditor || textEditor.isDestroyed) return () => scroller?.removeEventListener("scroll", recount); textEditor.on("transaction", recount); return () => { scroller?.removeEventListener("scroll", recount); textEditor.off("transaction", recount); }; }, [textEditor, stageSize.h, pitch, mode, drawnPages]); /* ── DRAWING: painting ── * Only the page under the pointer is repainted while a stroke is being drawn; * the others cannot have changed, and repainting four canvases per pointer * move on a phone is felt. */ const renderFrame = () => { const c = canvasesRef.current.get(drawPageRef.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 one page's offscreen canvas from scratch, then show it. Cheap enough // at stroke boundaries, and it keeps eraser strokes ordered correctly. const repaintPage = (page) => { const c = canvasesRef.current.get(page); if (!c) return; const { bw, bh } = sizeCanvas(c, stageSize.w, stageSize.h); if (page === drawPageRef.current) { 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(pageStrokes(page)), bw, bh); renderFrame(); return; } paintStrokeDoc(c.getContext("2d"), strokeDocOf(pageStrokes(page)), bw, bh); }; const repaintAll = () => { for (const page of canvasesRef.current.keys()) repaintPage(page); }; /* 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; } commitSheets(noteSheets(doc)); setUndoStack([]); setRedoStack([]); repaintAll(); }, [note.id, note.drawingStrokes]); useEffect(() => { if (mode === "draw") repaintAll(); }, [mode, stageSize.w, stageSize.h, pages.count]); /* ── DRAWING: input ── * Coordinates are canonical *within the page drawn on*, so a stroke at the top * of page three is at the top of page three on every other screen too. */ const canonPos = (e, page) => { const c = canvasesRef.current.get(page); if (!c) return { x: 0, y: 0 }; const r = c.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 = (page) => (e) => { if (mode !== "draw") return; e.preventDefault(); // The offscreen copy belongs to whichever page is being drawn on, so it has // to be rebuilt before the first frame — otherwise the previous page's ink // would appear on this one. if (drawPageRef.current !== page) { drawPageRef.current = page; committedRef.current = null; repaintPage(page); } const p = canonPos(e, page); 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, drawPageRef.current), 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; } const page = drawPageRef.current; pushUndo(); commitSheets(sheetsWith({ sheets: sheetsRef.current }, page, [...pageStrokes(page), live])); dirtyRef.current = true; repaintPage(page); scheduleDrawingSave(); }; /* ── DRAWING: history ── */ /** * Right-click inside a table opens the table menu; anywhere else the * browser's own menu is left alone, because for ordinary text that one is * more useful than anything offered here. */ const onTableContextMenu = (e) => { if (mode !== "text" || !textEditor) return; const at = textEditor.view.posAtCoords({ left: e.clientX, top: e.clientY }); // Put the caret in the cell that was clicked, so the commands act on it and // not on wherever the cursor last sat. A selection spanning several cells is // left intact — collapsing it would take "merge cells" away in the moment // the user reached for it. const sel = textEditor.state.selection; const isCellSelection = !!sel.$anchorCell; const clickedInside = at && at.pos >= sel.from && at.pos <= sel.to; if (at && !(isCellSelection && clickedInside)) { textEditor.commands.setTextSelection(at.pos); } const items = tableMenuItems(textEditor); if (!items) return; e.preventDefault(); // Without this the same event carries on to window, where the menu that is // about to mount reads it as a click elsewhere and closes again instantly. e.stopPropagation(); setTableMenu({ x: e.clientX, y: e.clientY, items }); }; /* History holds whole drawings, not single pages. Undo after a stroke on page * three has to be able to put page three back exactly as it was, and a stack * kept per page would also make one Undo button mean four different things. */ const pushUndo = () => { setUndoStack((p) => [...p.slice(-30), sheetsRef.current]); setRedoStack([]); }; const applyHistory = (next) => { commitSheets(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, sheetsRef.current]); applyHistory(prev); }; const redo = () => { if (!redoStack.length) return; const next = redoStack[redoStack.length - 1]; setRedoStack((p) => p.slice(0, -1)); setUndoStack((p) => [...p, sheetsRef.current]); applyHistory(next); }; /* Clears the page being looked at, not the whole drawing. Emptying four pages * because the bin was pressed on one of them is not what anyone means by it. */ const clearCanvas = () => { pushUndo(); applyHistory(sheetsWith({ sheets: sheetsRef.current }, pages.current, [])); }; /* 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) => ( ); /* While drawing there is always one blank page past the end. * * Pages otherwise come from how far the writing runs, which would leave a * drawing with nowhere to go: to draw on page three you would first have to * write three pages. The spare page costs an empty canvas and becomes a real * page the moment something is drawn on it. */ const canvasPages = mode === "draw" ? pages.count + 1 : pages.count; const registerCanvas = (page) => (el) => { if (el) canvasesRef.current.set(page, el); else canvasesRef.current.delete(page); }; return (
{/* Main toolbar */}
{mb("text", I.text, "Write")} {mb("draw", I.draw, "Draw")} {/* Same wording as the PDF reader, because it means the same thing. */} Page {pages.current} of {pages.count}
{mode === "text" ? ( ) : ( <>
{showColors && (
{COLORS.map((c) => (
)}
{showSizes && (
{PEN_SIZES.map((s, i) => ( ))}
)}
)}
{editorNotice && (
{editorNotice}
)} {/* Content area */}
{/* The paper. One sheet per page with a gap between them, the way the PDF reader draws its pages — and the reason the writing sits on something instead of floating in the middle of an empty pane. Behind the text and deaf to the pointer: it is what is written on, not part of what is written. */} {Array.from({ length: canvasPages }, (_, i) => { /* The last sheet in Draw mode is the spare one, not a page. Drawn as an outline rather than as paper: it appeared as a full second page the moment Draw was pressed, while the count still said "of 1" — which reads as the app inventing a page. It is an offer, and should look like one until something is on it. */ const spare = mode === "draw" && i + 1 > pages.count; return (
{spare && (
Draw here to add a page
)}
); })}
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */} {legacyRaster && ( 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. One layer per drawn page; a page nobody has drawn on needs no canvas at all. */} {mode !== "draw" && drawnPages.map((page) => (
))} {mode === "draw" && Array.from({ length: canvasPages }, (_, i) => i + 1).map((page) => ( ))}
{mode === "text" && } {tableMenu && ( setTableMenu(null)} dark={dark} /> )}
); }