Let a drawing run over as many pages as the note
The ink was stuck on page one while the writing carried on without it, which stopped making sense the moment a note could run to four pages. Every page is exported anyway, so every page can be drawn on. Pages are numbered the way the PDF reader numbers them, and only pages that carry ink are stored, so the keys are the drawn pages. While drawing there is always one blank page past the end: pages otherwise come from how far the writing runs, and to draw on page three you would first have had to write three pages. A drawing that lives entirely on page one is still written in the old single-page shape. Nothing requires that — it means an ordinary note is stored exactly as it always was, and a device running an older build still shows it. The bin clears the page being looked at. Undo and redo work on the drawing as a whole, because one Undo button that means four different things depending on where you scrolled is not a button anyone can use. And a real bug found on the way out: the exporter has written drawings/<note>.png since the interface was put into English, while the importer was still looking for zeichnungen/<note>.png. A drawing exported by this very app came back from its own archive as nothing at all. Both names are read now, and so are the further pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b8c5c42217
commit
228552d453
9 files changed
+671
-136
No files matched your search
@@ -10,6 +10,15 @@ export const CANON_W = 1200,
|
||||
CANON_H = 1600;
|
||||
export const PREVIEW_DRAWING_W = 260; // history preview thumbnail
|
||||
export const STROKES_VERSION = 1;
|
||||
/* A drawing spread over more than one page.
|
||||
*
|
||||
* A note used to have exactly one sheet to draw on, which stopped making sense
|
||||
* once the writing could run to four pages: the ink was stuck on the first one
|
||||
* while the text carried on without it. Pages are numbered the way the PDF
|
||||
* reader numbers them, and each keeps its own strokes in the same canonical
|
||||
* space — so page 3 of a note means the same thing on a phone as on a desktop.
|
||||
*/
|
||||
export const SHEETS_VERSION = 2;
|
||||
|
||||
// Widths are canonical units, so a "Medium" pen covers the same fraction of the
|
||||
// page everywhere. At the 760px desktop page width, M lands at roughly 3.5 CSS px.
|
||||
@@ -41,10 +50,86 @@ export const strokeDocOf = (strokes, t) => ({
|
||||
strokes,
|
||||
...(t ? { t } : {}),
|
||||
});
|
||||
export const docStrokes = (doc) => (Array.isArray(doc?.strokes) ? doc.strokes : []);
|
||||
export const sameStrokeDoc = (a, b) =>
|
||||
a === b ||
|
||||
(docStrokes(a).length === docStrokes(b).length && docStrokes(a).every((s, i) => s === docStrokes(b)[i]));
|
||||
/** The strokes on one canvas — page one, for a drawing that has several. */
|
||||
export const docStrokes = (doc) => sheetStrokes(doc, 1);
|
||||
|
||||
/**
|
||||
* A drawing's pages, keyed by 1-based page number.
|
||||
*
|
||||
* Reads either shape, so a note drawn before there were pages needs no
|
||||
* migration: its single list of strokes is page one, which is where it was.
|
||||
* Empty pages are dropped rather than stored, so "which pages are drawn on" is
|
||||
* simply the keys.
|
||||
*/
|
||||
export function noteSheets(doc) {
|
||||
if (!doc || typeof doc !== "object") return {};
|
||||
const source =
|
||||
doc.sheets && typeof doc.sheets === "object"
|
||||
? doc.sheets
|
||||
: Array.isArray(doc.strokes)
|
||||
? { 1: doc.strokes }
|
||||
: {};
|
||||
const out = {};
|
||||
for (const [key, strokes] of Object.entries(source)) {
|
||||
const page = Number(key);
|
||||
if (!Number.isInteger(page) || page < 1) continue;
|
||||
if (!Array.isArray(strokes) || strokes.length === 0) continue;
|
||||
out[String(page)] = strokes;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const sheetStrokes = (doc, page) => noteSheets(doc)[String(page)] ?? [];
|
||||
|
||||
/** Page numbers carrying ink, in reading order. */
|
||||
export const drawnSheets = (doc) =>
|
||||
Object.keys(noteSheets(doc))
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
/** Every stroke in the drawing — for "is there anything here at all". */
|
||||
export const allStrokes = (doc) => {
|
||||
const sheets = noteSheets(doc);
|
||||
return drawnSheets(doc).flatMap((page) => sheets[String(page)]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a stroke document from pages.
|
||||
*
|
||||
* A drawing that lives entirely on page one is written in the old single-page
|
||||
* shape. Nothing needs it to be — but it means an ordinary note's drawing is
|
||||
* stored exactly as it always was, and a client that has not been updated yet
|
||||
* still shows it.
|
||||
*/
|
||||
export function sheetDocOf(sheets, t) {
|
||||
const clean = noteSheets({ sheets });
|
||||
const pages = Object.keys(clean);
|
||||
if (pages.length === 0 || (pages.length === 1 && pages[0] === "1")) {
|
||||
return strokeDocOf(clean["1"] ?? [], t);
|
||||
}
|
||||
return { v: SHEETS_VERSION, w: CANON_W, h: CANON_H, sheets: clean, ...(t ? { t } : {}) };
|
||||
}
|
||||
|
||||
/** The pages of `doc` with one page replaced — an empty page is removed. */
|
||||
export function sheetsWith(doc, page, strokes) {
|
||||
const sheets = { ...noteSheets(doc) };
|
||||
if (strokes?.length) sheets[String(page)] = strokes;
|
||||
else delete sheets[String(page)];
|
||||
return sheets;
|
||||
}
|
||||
|
||||
/* Compared page by page. Flattening first would call a stroke moved from page
|
||||
* two to page three "unchanged", and the move would never be saved. */
|
||||
export const sameStrokeDoc = (a, b) => {
|
||||
if (a === b) return true;
|
||||
const x = noteSheets(a),
|
||||
y = noteSheets(b);
|
||||
const keys = Object.keys(x);
|
||||
if (keys.length !== Object.keys(y).length) return false;
|
||||
return keys.every(
|
||||
(k) => y[k] && x[k].length === y[k].length && x[k].every((s, i) => s === y[k][i])
|
||||
);
|
||||
};
|
||||
|
||||
export function paintStroke(ctx, s) {
|
||||
const p = s?.p;
|
||||
@@ -77,8 +162,10 @@ export function paintStroke(ctx, s) {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
// Replays a whole drawing into a context of targetW × targetH device pixels.
|
||||
export function paintStrokeDoc(ctx, doc, targetW, targetH) {
|
||||
// Replays one page of a drawing into a context of targetW × targetH device
|
||||
// pixels. Single-page drawings are page one, which is what every caller that
|
||||
// never heard of pages asks for.
|
||||
export function paintStrokeDoc(ctx, doc, targetW, targetH, page = 1) {
|
||||
const w = doc?.w || CANON_W,
|
||||
h = doc?.h || CANON_H;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
@@ -86,7 +173,7 @@ export function paintStrokeDoc(ctx, doc, targetW, targetH) {
|
||||
ctx.scale(targetW / w, targetH / h);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const s of docStrokes(doc)) paintStroke(ctx, s);
|
||||
for (const s of sheetStrokes(doc, page)) paintStroke(ctx, s);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CANON_W,
|
||||
CANON_H,
|
||||
STROKES_VERSION,
|
||||
SHEETS_VERSION,
|
||||
allStrokes,
|
||||
docStrokes,
|
||||
drawnSheets,
|
||||
noteSheets,
|
||||
sameStrokeDoc,
|
||||
sheetDocOf,
|
||||
sheetStrokes,
|
||||
sheetsWith,
|
||||
strokeDocOf,
|
||||
} from "./strokes.js";
|
||||
|
||||
/* `strokes.js` is untyped, so what comes back is a union TypeScript cannot see
|
||||
* through. These two keep the tests about behaviour rather than about casts. */
|
||||
interface Stroke {
|
||||
label: string;
|
||||
}
|
||||
interface Doc {
|
||||
v: number;
|
||||
t?: string;
|
||||
strokes?: Stroke[];
|
||||
sheets?: Record<string, Stroke[]>;
|
||||
}
|
||||
const stroke = (label: string): Stroke =>
|
||||
({ t: "pen", c: "#000", s: 3, p: [0, 0, 1, 1], label }) as Stroke;
|
||||
const asDoc = (doc: unknown) => doc as Doc;
|
||||
|
||||
describe("reading a drawing", () => {
|
||||
it("treats a note drawn before there were pages as page one", () => {
|
||||
const a = stroke("a");
|
||||
const doc = strokeDocOf([a]);
|
||||
expect(noteSheets(doc)).toEqual({ 1: [a] });
|
||||
expect(sheetStrokes(doc, 1)).toEqual([a]);
|
||||
expect(sheetStrokes(doc, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads a drawing kept per page", () => {
|
||||
const doc = { v: SHEETS_VERSION, w: CANON_W, h: CANON_H, sheets: { 1: [stroke("a")], 3: [stroke("c")] } };
|
||||
expect(drawnSheets(doc)).toEqual([1, 3]);
|
||||
expect(sheetStrokes(doc, 3)).toEqual([stroke("c")]);
|
||||
});
|
||||
|
||||
it("has no pages at all for nothing, an empty list or rubbish", () => {
|
||||
for (const doc of [null, undefined, {}, { strokes: [] }, { sheets: {} }, 7, "x"]) {
|
||||
expect(drawnSheets(doc)).toEqual([]);
|
||||
expect(allStrokes(doc)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores pages that are not pages", () => {
|
||||
const good = stroke("good");
|
||||
const doc = { sheets: { 1: [good], 0: [stroke("zero")], "-2": [stroke("neg")], x: [stroke("x")], 2: "no" } };
|
||||
expect(drawnSheets(doc)).toEqual([1]);
|
||||
expect(allStrokes(doc)).toEqual([good]);
|
||||
});
|
||||
|
||||
it("puts every stroke in reading order, page ten after page nine", () => {
|
||||
const doc = { sheets: { 10: [stroke("j")], 9: [stroke("i")], 1: [stroke("a")] } };
|
||||
expect(allStrokes(doc).map((s: Stroke) => s.label)).toEqual(["a", "i", "j"]);
|
||||
});
|
||||
|
||||
it("still answers page one for callers that never heard of pages", () => {
|
||||
const a = stroke("a");
|
||||
expect(docStrokes({ sheets: { 1: [a], 2: [stroke("b")] } })).toEqual([a]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writing a drawing", () => {
|
||||
it("keeps the old single-page shape when everything is on page one", () => {
|
||||
const doc = asDoc(sheetDocOf({ 1: [stroke("a")] }));
|
||||
expect(doc.v).toBe(STROKES_VERSION);
|
||||
expect(doc.strokes).toHaveLength(1);
|
||||
expect(doc.sheets).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes pages once there is more than the first", () => {
|
||||
const doc = asDoc(sheetDocOf({ 1: [stroke("a")], 2: [stroke("b")] }));
|
||||
expect(doc.v).toBe(SHEETS_VERSION);
|
||||
expect(Object.keys(doc.sheets!)).toEqual(["1", "2"]);
|
||||
expect(doc.strokes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes pages for ink that starts on page two", () => {
|
||||
const doc = asDoc(sheetDocOf({ 2: [stroke("b")] }));
|
||||
expect(doc.v).toBe(SHEETS_VERSION);
|
||||
expect(sheetStrokes(doc, 2)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is an empty single-page drawing when nothing is drawn", () => {
|
||||
const doc = asDoc(sheetDocOf({}));
|
||||
expect(doc.strokes).toEqual([]);
|
||||
expect(allStrokes(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries the save token through either shape", () => {
|
||||
expect(asDoc(sheetDocOf({ 1: [stroke("a")] }, "tok")).t).toBe("tok");
|
||||
expect(asDoc(sheetDocOf({ 1: [stroke("a")], 2: [stroke("b")] }, "tok")).t).toBe("tok");
|
||||
});
|
||||
|
||||
it("drops a page that has been emptied rather than storing it", () => {
|
||||
const doc = { sheets: { 1: [stroke("a")], 2: [stroke("b")] } };
|
||||
expect(drawnSheets({ sheets: sheetsWith(doc, 2, []) })).toEqual([1]);
|
||||
expect(drawnSheets({ sheets: sheetsWith(doc, 1, []) })).toEqual([2]);
|
||||
});
|
||||
|
||||
it("replaces one page and leaves the others alone", () => {
|
||||
const doc = { sheets: { 1: [stroke("a")], 2: [stroke("b")] } };
|
||||
const next = sheetsWith(doc, 2, [stroke("c"), stroke("d")]) as Record<string, Stroke[]>;
|
||||
expect(next["1"]).toEqual([stroke("a")]);
|
||||
expect(next["2"]).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("noticing a change", () => {
|
||||
it("calls a drawing the same as itself", () => {
|
||||
const doc = sheetDocOf({ 1: [stroke("a")] });
|
||||
expect(sameStrokeDoc(doc, doc)).toBe(true);
|
||||
});
|
||||
|
||||
it("sees the same strokes through either shape", () => {
|
||||
const a = stroke("a");
|
||||
expect(sameStrokeDoc(strokeDocOf([a]), sheetDocOf({ 1: [a] }))).toBe(true);
|
||||
});
|
||||
|
||||
it("sees a stroke moved to another page", () => {
|
||||
const a = stroke("a");
|
||||
// Flattening first would call this unchanged, and the move would never save.
|
||||
expect(sameStrokeDoc(sheetDocOf({ 2: [a] }), sheetDocOf({ 3: [a] }))).toBe(false);
|
||||
});
|
||||
|
||||
it("sees a page added and a page emptied", () => {
|
||||
const one = sheetDocOf({ 1: [stroke("a")] });
|
||||
expect(sameStrokeDoc(one, sheetDocOf({ 1: [stroke("a")], 2: [stroke("b")] }))).toBe(false);
|
||||
expect(sameStrokeDoc(one, sheetDocOf({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
MIN_POINT_DIST,
|
||||
newSaveToken,
|
||||
strokeDocOf,
|
||||
docStrokes,
|
||||
sheetDocOf,
|
||||
noteSheets,
|
||||
sheetStrokes,
|
||||
sheetsWith,
|
||||
drawnSheets,
|
||||
paintStroke,
|
||||
paintStrokeDoc,
|
||||
sizeCanvas,
|
||||
@@ -36,7 +40,6 @@ export function NoteEditor({
|
||||
backlinks,
|
||||
}) {
|
||||
const [mode, setMode] = useState("text");
|
||||
const canvasRef = useRef(null);
|
||||
const [tool, setTool] = useState("pen");
|
||||
const [penColor, setPenColor] = useState(dark ? "#e6edf3" : "#1a1a2e");
|
||||
const [penSizeIdx, setPenSizeIdx] = useState(DEFAULT_PEN_SIZE);
|
||||
@@ -63,16 +66,20 @@ export function NoteEditor({
|
||||
const scrollerRef = useRef(null);
|
||||
|
||||
/* ── DRAWING STATE ──
|
||||
* strokesRef is what the pointer handlers read and write synchronously;
|
||||
* `strokes` mirrors it so React can repaint the read-only layer. */
|
||||
const strokesRef = useRef([]);
|
||||
const [strokes, setStrokes] = useState([]);
|
||||
* 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 committedRef = useRef(null); // offscreen canvas holding all finished strokes
|
||||
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 hasStrokes = strokes.length > 0;
|
||||
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;
|
||||
@@ -86,10 +93,11 @@ export function NoteEditor({
|
||||
* be — and plain text is simply text. */
|
||||
const needsPage = mode === "draw" || hasStrokes || !!legacyRaster;
|
||||
|
||||
const commitStrokes = (next) => {
|
||||
strokesRef.current = next;
|
||||
setStrokes(next);
|
||||
const commitSheets = (next) => {
|
||||
sheetsRef.current = next;
|
||||
setSheets(next);
|
||||
};
|
||||
const pageStrokes = (page) => sheetsRef.current[String(page)] ?? [];
|
||||
|
||||
const T = useMemo(
|
||||
() => ({
|
||||
@@ -198,7 +206,7 @@ export function NoteEditor({
|
||||
const saveDrawingNow = () => {
|
||||
if (!dirtyRef.current) return;
|
||||
const token = newSaveToken();
|
||||
const doc = strokeDocOf(strokesRef.current, token);
|
||||
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;
|
||||
@@ -223,7 +231,11 @@ export function NoteEditor({
|
||||
if (!el || !pageH) return;
|
||||
const height = el.scrollHeight;
|
||||
const scroller = scrollerRef.current;
|
||||
const count = Math.max(1, Math.ceil(height / pageH));
|
||||
/* 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 — and the fourth would have no canvas to be redrawn on. */
|
||||
const written = Math.max(1, Math.ceil(height / pageH));
|
||||
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
|
||||
@@ -244,11 +256,14 @@ export function NoteEditor({
|
||||
scroller?.removeEventListener("scroll", recount);
|
||||
textEditor.off("transaction", recount);
|
||||
};
|
||||
}, [textEditor, stageSize.h, mode]);
|
||||
}, [textEditor, stageSize.h, mode, drawnPages]);
|
||||
|
||||
/* ── DRAWING: painting ── */
|
||||
/* ── 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 = canvasRef.current;
|
||||
const c = canvasesRef.current.get(drawPageRef.current);
|
||||
if (!c) return;
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
@@ -264,23 +279,30 @@ export function NoteEditor({
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
// Repaint the offscreen canvas from scratch, then show it. Cheap enough at
|
||||
// stroke boundaries, and it keeps eraser strokes ordered correctly.
|
||||
const repaintAll = () => {
|
||||
const c = canvasRef.current;
|
||||
// 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);
|
||||
let off = committedRef.current;
|
||||
if (!off) {
|
||||
off = document.createElement("canvas");
|
||||
committedRef.current = off;
|
||||
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;
|
||||
}
|
||||
if (off.width !== bw || off.height !== bh) {
|
||||
off.width = bw;
|
||||
off.height = bh;
|
||||
}
|
||||
paintStrokeDoc(off.getContext("2d"), strokeDocOf(strokesRef.current), bw, bh);
|
||||
renderFrame();
|
||||
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
|
||||
@@ -297,7 +319,7 @@ export function NoteEditor({
|
||||
pendingTokenRef.current = null; // our own save echoed back; in sync again
|
||||
return;
|
||||
}
|
||||
commitStrokes(docStrokes(doc));
|
||||
commitSheets(noteSheets(doc));
|
||||
setUndoStack([]);
|
||||
setRedoStack([]);
|
||||
repaintAll();
|
||||
@@ -305,11 +327,15 @@ export function NoteEditor({
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "draw") repaintAll();
|
||||
}, [mode, stageSize.w, stageSize.h]);
|
||||
}, [mode, stageSize.w, stageSize.h, pages.count]);
|
||||
|
||||
/* ── DRAWING: input ── */
|
||||
const canonPos = (e) => {
|
||||
const r = canvasRef.current.getBoundingClientRect();
|
||||
/* ── 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) };
|
||||
};
|
||||
@@ -321,10 +347,18 @@ export function NoteEditor({
|
||||
};
|
||||
const r1 = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
const startDraw = (e) => {
|
||||
const startDraw = (page) => (e) => {
|
||||
if (mode !== "draw") return;
|
||||
e.preventDefault();
|
||||
const p = canonPos(e);
|
||||
// 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();
|
||||
@@ -332,7 +366,7 @@ export function NoteEditor({
|
||||
const doDraw = (e) => {
|
||||
if (!drawingRef.current) return;
|
||||
e.preventDefault();
|
||||
const p = canonPos(e),
|
||||
const p = canonPos(e, drawPageRef.current),
|
||||
live = liveRef.current,
|
||||
n = live.p.length;
|
||||
const dx = p.x - live.p[n - 2],
|
||||
@@ -351,10 +385,11 @@ export function NoteEditor({
|
||||
renderFrame();
|
||||
return;
|
||||
}
|
||||
const page = drawPageRef.current;
|
||||
pushUndo();
|
||||
commitStrokes([...strokesRef.current, live]);
|
||||
commitSheets(sheetsWith({ sheets: sheetsRef.current }, page, [...pageStrokes(page), live]));
|
||||
dirtyRef.current = true;
|
||||
repaintAll();
|
||||
repaintPage(page);
|
||||
scheduleDrawingSave();
|
||||
};
|
||||
|
||||
@@ -388,12 +423,15 @@ export function NoteEditor({
|
||||
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), strokesRef.current]);
|
||||
setUndoStack((p) => [...p.slice(-30), sheetsRef.current]);
|
||||
setRedoStack([]);
|
||||
};
|
||||
const applyHistory = (next) => {
|
||||
commitStrokes(next);
|
||||
commitSheets(next);
|
||||
dirtyRef.current = true;
|
||||
repaintAll();
|
||||
scheduleDrawingSave();
|
||||
@@ -402,19 +440,21 @@ export function NoteEditor({
|
||||
if (!undoStack.length) return;
|
||||
const prev = undoStack[undoStack.length - 1];
|
||||
setUndoStack((p) => p.slice(0, -1));
|
||||
setRedoStack((p) => [...p, strokesRef.current]);
|
||||
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, strokesRef.current]);
|
||||
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([]);
|
||||
applyHistory(sheetsWith({ sheets: sheetsRef.current }, pages.current, []));
|
||||
};
|
||||
|
||||
/* Save drawing on mode switch and debounced stroke-end updates. */
|
||||
@@ -482,6 +522,18 @@ export function NoteEditor({
|
||||
</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={{
|
||||
@@ -678,7 +730,12 @@ export function NoteEditor({
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button style={{ ...tb(), color: "#e5534b" }} onClick={clearCanvas}>
|
||||
<button
|
||||
data-clear-page
|
||||
title={`Clear page ${pages.current}`}
|
||||
style={{ ...tb(), color: "#e5534b" }}
|
||||
onClick={clearCanvas}
|
||||
>
|
||||
{I.trash}
|
||||
</button>
|
||||
</>
|
||||
@@ -724,7 +781,7 @@ export function NoteEditor({
|
||||
// The text caps its own width in the stylesheet, so this is only
|
||||
// the page when there is one.
|
||||
width: needsPage ? stageSize.w : "100%",
|
||||
minHeight: needsPage ? stageSize.h : undefined,
|
||||
minHeight: needsPage ? canvasPages * stageSize.h : undefined,
|
||||
margin: "0 auto",
|
||||
background: needsPage ? T.bg : "transparent",
|
||||
border: needsPage && !isMobileViewport ? `1px solid ${T.border}` : "none",
|
||||
@@ -735,7 +792,7 @@ export function NoteEditor({
|
||||
{/* A hairline where each page ends, so the count in the toolbar
|
||||
corresponds to something you can see. Drawn behind the text and
|
||||
deaf to the pointer — it marks the paper, it is not part of it. */}
|
||||
{pages.count > 1 && mode === "text" && (
|
||||
{canvasPages > 1 && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
@@ -784,33 +841,50 @@ export function NoteEditor({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Saved drawing — re-rendered from vectors, so identical on every screen */}
|
||||
{hasStrokes && mode !== "draw" && (
|
||||
<DrawingLayer strokes={strokes} width={stageSize.w} height={stageSize.h} zIndex={2} />
|
||||
)}
|
||||
{mode === "draw" && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={startDraw}
|
||||
onMouseMove={doDraw}
|
||||
onMouseUp={stopDraw}
|
||||
onMouseLeave={stopDraw}
|
||||
onTouchStart={startDraw}
|
||||
onTouchMove={doDraw}
|
||||
onTouchEnd={stopDraw}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: "100%",
|
||||
height: stageSize.h,
|
||||
touchAction: "none",
|
||||
cursor: "crosshair",
|
||||
background: "transparent",
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 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) * stageSize.h, height: stageSize.h }}
|
||||
>
|
||||
<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) * stageSize.h,
|
||||
width: "100%",
|
||||
height: stageSize.h,
|
||||
touchAction: "none",
|
||||
cursor: "crosshair",
|
||||
background: "transparent",
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user