Grey letters ran down the bar while dragging a column edge. The editor marks the column under the pointer by putting a small div in each of its cells; drawn along the cell's right edge, that absolutely positioned child sent Chromium down another painting path and the cell's own text came out a second time, squeezed into the two pixels under the bar, one letter to a line. It appeared with the bar and went with it, which named the culprit -- measured, there was no text there at all, only three empty markers, and a forced repaint did not clear it. The marker is display:none now and only says which cells the column is made of; the line is an inset shadow on those cells, matched with :has(). Two suspects were tried and reverted first: the table as its own paint region, and separate borders. Neither changed anything, so neither stayed. Opening a note flashed a long narrow page. The drawing stage starts at the canonical page, 1600px tall, and the measurement that fits it to the pane ran as an ordinary effect -- a frame too late. useLayoutEffect runs between the DOM being built and the browser painting it, which is exactly the gap that flash lived in. The test samples the sheet's height every frame across a note switch, because one frame is all it was. The mark above a note did not stand in the same column as the tools below it. Vertically it was already exact -- measured, symbol centre 20, cap centre 20 -- but it began six pixels right of the first tool, and the title thirty. The mark's box is only as wide as the mark now, so the mark lands on that column, and the title follows nine pixels closer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
970 lines
35 KiB
React
970 lines
35 KiB
React
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) => (
|
||
<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>
|
||
);
|
||
|
||
/* 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 (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
height: "100%",
|
||
/* Fill the pane rather than the toolbar.
|
||
*
|
||
* As a flex item with the default `flex: 0 1 auto` this box was as wide
|
||
* as its widest row — the toolbar. Switching to Draw swaps a dozen
|
||
* formatting buttons for six drawing ones, the toolbar's natural width
|
||
* collapses, and the page took it with it: the paper measurably shrank
|
||
* from 733px to 460px on the same screen. Growing to the pane makes the
|
||
* width a property of the window, which is what it always looked like. */
|
||
flex: 1,
|
||
minWidth: 0,
|
||
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")}
|
||
{/* Same wording as the PDF reader, because it means the same thing. */}
|
||
<span
|
||
data-note-position
|
||
style={{ fontSize: 11.5, color: T.muted, whiteSpace: "nowrap", flexShrink: 0, marginLeft: 2 }}
|
||
>
|
||
Page {pages.current} of {pages.count}
|
||
</span>
|
||
<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}
|
||
dark={dark}
|
||
/>
|
||
) : (
|
||
<>
|
||
<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
|
||
data-clear-page
|
||
title={`Clear page ${pages.current}`}
|
||
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}
|
||
onContextMenu={onTableContextMenu}
|
||
style={{ flex: 1, position: "relative", overflow: "hidden" }}
|
||
>
|
||
<div
|
||
ref={scrollerRef}
|
||
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,
|
||
maxWidth: "100%",
|
||
// Every sheet plus the gaps between them, but not a gap trailing
|
||
// off the last one.
|
||
minHeight: canvasPages * pitch - PAGE_GAP,
|
||
margin: "0 auto",
|
||
}}
|
||
>
|
||
{/* 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 (
|
||
<div
|
||
key={i}
|
||
aria-hidden
|
||
data-page-sheet={i + 1}
|
||
data-spare-page={spare ? "yes" : undefined}
|
||
style={{
|
||
position: "absolute",
|
||
left: 0,
|
||
right: 0,
|
||
top: i * pitch,
|
||
height: stageSize.h,
|
||
pointerEvents: "none",
|
||
background: spare ? "transparent" : T.bg,
|
||
border: isMobileViewport && !spare ? "none" : `1px dashed ${T.border}`,
|
||
borderStyle: spare ? "dashed" : "solid",
|
||
borderWidth: isMobileViewport && !spare ? 0 : 1,
|
||
borderColor: T.border,
|
||
borderRadius: isMobileViewport && !spare ? 0 : 4,
|
||
boxShadow: isMobileViewport || spare ? "none" : "0 2px 10px rgba(0,0,0,0.22)",
|
||
opacity: spare ? 0.5 : 1,
|
||
}}
|
||
>
|
||
{spare && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: 12,
|
||
left: 0,
|
||
right: 0,
|
||
textAlign: "center",
|
||
fontSize: 11,
|
||
letterSpacing: 0.5,
|
||
color: T.faint,
|
||
}}
|
||
>
|
||
Draw here to add a page
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
zIndex: 1,
|
||
minHeight: stageSize.h,
|
||
width: "100%",
|
||
pointerEvents: mode === "text" ? "auto" : "none",
|
||
}}
|
||
>
|
||
<EditorSurface
|
||
editor={textEditor}
|
||
dark={dark}
|
||
font={noteFont}
|
||
minHeight={stageSize.h}
|
||
onOpenNote={onOpenNote}
|
||
resolveLinkLabel={resolveLinkLabel}
|
||
resolveLinkIcon={resolveLinkIcon}
|
||
/>
|
||
</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. One layer per drawn page; a page nobody has drawn on
|
||
needs no canvas at all. */}
|
||
{mode !== "draw" &&
|
||
drawnPages.map((page) => (
|
||
<div
|
||
key={page}
|
||
data-drawn-page={page}
|
||
style={{
|
||
position: "absolute",
|
||
left: 0,
|
||
right: 0,
|
||
top: (page - 1) * pitch,
|
||
height: stageSize.h,
|
||
/* Deaf to the pointer, or this box lies over the writing and
|
||
swallows every click meant for the text — which is exactly
|
||
what it did: a note with a drawing could not be typed in
|
||
at all. The canvas inside says the same thing, but a
|
||
transparent wrapper is still a wrapper. */
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
<DrawingLayer
|
||
strokes={sheetStrokes({ sheets }, page)}
|
||
width={stageSize.w}
|
||
height={stageSize.h}
|
||
zIndex={2}
|
||
/>
|
||
</div>
|
||
))}
|
||
{mode === "draw" &&
|
||
Array.from({ length: canvasPages }, (_, i) => i + 1).map((page) => (
|
||
<canvas
|
||
key={page}
|
||
ref={registerCanvas(page)}
|
||
data-draw-page={page}
|
||
onMouseDown={startDraw(page)}
|
||
onMouseMove={doDraw}
|
||
onMouseUp={stopDraw}
|
||
onMouseLeave={stopDraw}
|
||
onTouchStart={startDraw(page)}
|
||
onTouchMove={doDraw}
|
||
onTouchEnd={stopDraw}
|
||
style={{
|
||
position: "absolute",
|
||
left: 0,
|
||
top: (page - 1) * pitch,
|
||
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} />}
|
||
{tableMenu && (
|
||
<ContextMenu
|
||
x={tableMenu.x}
|
||
y={tableMenu.y}
|
||
items={tableMenu.items}
|
||
onClose={() => setTableMenu(null)}
|
||
dark={dark}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|