From 791e809222d758b31531d1716f93d4492737e15f Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 12 Aug 2026 08:29:20 -0400 Subject: [PATCH] Add Draw mode for Apple Pencil annotation on scripture panel Adds a third layout option alongside Stacked and Split. In Draw mode a canvas overlay covers the scripture panel; the Pointer Events API routes Apple Pencil input to perfect-freehand strokes while letting finger touches fall through for normal scrolling. A floating toolbar provides pen, highlighter, and eraser tools, six color swatches, S/M/L sizes, undo, clear, and a Done button to return to Stacked view. Strokes are stored as inkStrokes[] on each chunk and rendered as a read-only transparent canvas overlay (InkLayer) in Stacked/Split modes. migrateChunk is updated so all existing chunks get inkStrokes: [] on first load. Also adds a simple in-memory rate limiter on auth endpoints (10 attempts per 15 min per IP). Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 7 ++ package.json | 1 + server/index.js | 21 ++++ src/App.jsx | 10 +- src/pages/DrawCanvas.jsx | 264 +++++++++++++++++++++++++++++++++++++++ src/pages/StudyPage.jsx | 34 +++-- src/utils/inkRender.js | 63 ++++++++++ 7 files changed, 390 insertions(+), 10 deletions(-) create mode 100644 src/pages/DrawCanvas.jsx create mode 100644 src/utils/inkRender.js diff --git a/package-lock.json b/package-lock.json index ec5a360..49063a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "express-session": "^1.19.0", "mammoth": "^1.12.0", "otplib": "^12.0.1", + "perfect-freehand": "^1.2.3", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -4970,6 +4971,12 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-freehand": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz", + "integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 2a7e179..8c0931a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "express-session": "^1.19.0", "mammoth": "^1.12.0", "otplib": "^12.0.1", + "perfect-freehand": "^1.2.3", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/server/index.js b/server/index.js index a99f11f..dcc7b9c 100644 --- a/server/index.js +++ b/server/index.js @@ -31,6 +31,22 @@ if (isProd && !process.env.SESSION_SECRET) { // Trust the reverse proxy (needed for secure cookies to work behind nginx/etc). app.set('trust proxy', 1); +// Simple in-memory rate limiter for auth endpoints — 10 attempts per 15 min per IP. +const _authAttempts = new Map(); +setInterval(() => { + const now = Date.now(); + for (const [ip, e] of _authAttempts) if (now > e.resetAt) _authAttempts.delete(ip); +}, 60 * 60 * 1000); +function checkAuthRateLimit(ip) { + const now = Date.now(); + const window = 15 * 60 * 1000; + const e = _authAttempts.get(ip) ?? { count: 0, resetAt: now + window }; + if (now > e.resetAt) { e.count = 0; e.resetAt = now + window; } + e.count += 1; + _authAttempts.set(ip, e); + return e.count > 10; +} + app.use(express.json({ limit: '10mb' })); app.use(session({ store: new SqliteSessionStore(), @@ -59,6 +75,7 @@ app.get('/api/health', (_req, res) => { app.post('/api/auth/register', async (req, res) => { try { + if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' }); const email = String(req.body?.email ?? '').trim().toLowerCase(); const password = String(req.body?.password ?? ''); @@ -93,6 +110,7 @@ app.post('/api/auth/register', async (req, res) => { app.post('/api/auth/login', async (req, res) => { try { + if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' }); const email = String(req.body?.email ?? '').trim().toLowerCase(); const password = String(req.body?.password ?? ''); @@ -121,6 +139,7 @@ app.post('/api/auth/login', async (req, res) => { app.post('/api/auth/mfa/verify', async (req, res) => { try { + if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' }); const pendingUserId = req.session?.pendingUserId; if (!pendingUserId) { return res.status(400).json({ error: 'No sign-in in progress.' }); @@ -314,6 +333,8 @@ app.put('/api/projects/:id', requireAuth, (req, res) => { if (body.id !== req.params.id) { return res.status(400).json({ error: 'URL id does not match body id.' }); } + if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' }); + if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' }); const saved = upsertProject(body, req.session.userId); if (!saved) { return res.status(403).json({ error: 'That project belongs to a different account.' }); diff --git a/src/App.jsx b/src/App.jsx index ae9edf9..3513279 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -168,6 +168,7 @@ export function migrateChunk(chunk) { episodeTitle: chunk.episodeTitle ?? '', finalScript: chunk.finalScript ?? '', tags: chunk.tags ?? [], + inkStrokes: chunk.inkStrokes ?? [], }; // already new format } return { @@ -177,6 +178,7 @@ export function migrateChunk(chunk) { application: '', crossReferences: [], tags: [], + inkStrokes: [], spilloverEndVerse: null, generalNotes: '', episodeNumber: '', @@ -1283,7 +1285,10 @@ const App = () => { }, []); const [studyLayout, setStudyLayout] = useState( () => localStorage.getItem('studyLayout') || 'stacked', - ); // 'stacked' | 'split' + ); // 'stacked' | 'split' | 'annotate' + const [drawTool, setDrawTool] = useState('pen'); + const [drawColor, setDrawColor] = useState('#0f172a'); + const [drawSize, setDrawSize] = useState(0.012); const [activeStudyTab, setActiveStudyTab] = useState( () => localStorage.getItem('activeStudyTab') || 'notes', ); @@ -3751,6 +3756,9 @@ const deleteProject = (id) => { selectedChunkVerses, selectedChunkGlobalIndex, studyLayout, setStudyLayout, + drawTool, setDrawTool, + drawColor, setDrawColor, + drawSize, setDrawSize, activeStudyTab, setActiveStudyTab, mobileStudyTab, setMobileStudyTab, collapsedSections, setCollapsedSections, diff --git a/src/pages/DrawCanvas.jsx b/src/pages/DrawCanvas.jsx new file mode 100644 index 0000000..308277f --- /dev/null +++ b/src/pages/DrawCanvas.jsx @@ -0,0 +1,264 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { useApp } from '../context/AppContext.js'; +import { renderInkToCanvas } from '../utils/inkRender.js'; + +const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7']; +const INK_SIZES = [ + { label: 'S', value: 0.006 }, + { label: 'M', value: 0.012 }, + { label: 'L', value: 0.025 }, +]; + +// Read-only ink overlay shown in Stacked / Split mode. +export function InkLayer({ strokes }) { + const canvasRef = useRef(null); + const strokesRef = useRef(strokes); + strokesRef.current = strokes; + + const render = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + renderInkToCanvas(canvas, strokesRef.current); + }, []); + + useEffect(() => { render(); }, [strokes, render]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const obs = new ResizeObserver(render); + obs.observe(canvas); + return () => obs.disconnect(); + }, [render]); + + if (!strokes.length) return null; + return ( + + ); +} + +// Interactive draw canvas overlay for Draw mode. +export default function DrawCanvas() { + const { + selectedChunk, + updateChunk, + drawTool, setDrawTool, + drawColor, setDrawColor, + drawSize, setDrawSize, + setStudyLayout, + } = useApp(); + + const canvasRef = useRef(null); + const activeStrokeRef = useRef([]); + + // Refs prevent stale closures in ResizeObserver callback + const strokesRef = useRef([]); + const drawToolRef = useRef(drawTool); + const drawColorRef = useRef(drawColor); + const drawSizeRef = useRef(drawSize); + strokesRef.current = selectedChunk?.inkStrokes ?? []; + drawToolRef.current = drawTool; + drawColorRef.current = drawColor; + drawSizeRef.current = drawSize; + + const render = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + renderInkToCanvas( + canvas, + strokesRef.current, + activeStrokeRef.current, + drawToolRef.current, + drawColorRef.current, + drawSizeRef.current, + ); + }, []); + + useEffect(() => { render(); }, [selectedChunk?.inkStrokes, drawTool, drawColor, drawSize, render]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const obs = new ResizeObserver(render); + obs.observe(canvas); + return () => obs.disconnect(); + }, [render]); + + const getPoint = useCallback((e) => { + const canvas = canvasRef.current; + if (!canvas) return [0, 0, 0.5]; + const rect = canvas.getBoundingClientRect(); + return [ + (e.clientX - rect.left) / rect.width, + (e.clientY - rect.top) / rect.height, + e.pressure ?? 0.5, + ]; + }, []); + + const onPointerDown = useCallback((e) => { + if (e.pointerType === 'touch') return; + e.preventDefault(); + canvasRef.current?.setPointerCapture(e.pointerId); + activeStrokeRef.current = [getPoint(e)]; + render(); + }, [getPoint, render]); + + const onPointerMove = useCallback((e) => { + if (e.pointerType === 'touch') return; + if (!canvasRef.current?.hasPointerCapture(e.pointerId)) return; + e.preventDefault(); + const pt = getPoint(e); + activeStrokeRef.current = [...activeStrokeRef.current, pt]; + + if (drawToolRef.current === 'eraser') { + const [ex, ey] = pt; + const r = drawSizeRef.current * 3; + const remaining = strokesRef.current.filter((s) => + !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r), + ); + if (remaining.length !== strokesRef.current.length) { + updateChunk(selectedChunk.id, { inkStrokes: remaining }); + } + } + render(); + }, [getPoint, render, updateChunk, selectedChunk]); + + const onPointerUp = useCallback((e) => { + if (e.pointerType === 'touch') return; + const pts = activeStrokeRef.current; + if (drawToolRef.current !== 'eraser' && pts.length > 1) { + updateChunk(selectedChunk.id, { + inkStrokes: [ + ...strokesRef.current, + { + id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`, + tool: drawToolRef.current, + color: drawColorRef.current, + size: drawSizeRef.current, + points: pts, + }, + ], + }); + } + activeStrokeRef.current = []; + render(); + }, [render, updateChunk, selectedChunk]); + + const strokes = selectedChunk?.inkStrokes ?? []; + + const undo = () => { + if (strokes.length > 0) + updateChunk(selectedChunk.id, { inkStrokes: strokes.slice(0, -1) }); + }; + + const clear = () => { + if (strokes.length > 0 && window.confirm('Clear all annotations on this chunk?')) + updateChunk(selectedChunk.id, { inkStrokes: [] }); + }; + + return ( + <> + + + {/* Floating toolbar */} +
+ {/* Tool buttons */} + {[ + { id: 'pen', label: '✒', title: 'Pen' }, + { id: 'highlighter', label: '▐', title: 'Highlighter' }, + { id: 'eraser', label: '⌫', title: 'Eraser' }, + ].map((t) => ( + + ))} + +
+ + {/* Color swatches */} + {INK_COLORS.map((c) => ( + + ))} + +
+ + + + +
+ + +
+ + ); +} diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx index 1c03b9e..a19cc23 100644 --- a/src/pages/StudyPage.jsx +++ b/src/pages/StudyPage.jsx @@ -2,6 +2,7 @@ import { createPortal } from 'react-dom'; import DOMPurify from 'dompurify'; import { useApp } from '../context/AppContext.js'; import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../App.jsx'; +import DrawCanvas, { InkLayer } from './DrawCanvas.jsx'; export default function StudyPage() { const { @@ -192,14 +193,26 @@ export default function StudyPage() { Choose a chunk to study.
)} - +
+ {[ + { id: 'stacked', label: '☰ Stacked' }, + { id: 'split', label: '◫ Split' }, + { id: 'annotate', label: '✏ Draw' }, + ].map((m) => ( + + ))} +
@@ -232,7 +245,7 @@ export default function StudyPage() {
{/* Scripture */} -
+

Scripture

@@ -251,6 +264,9 @@ export default function StudyPage() { ))}
+ + {studyLayout === 'annotate' && } + {/* Interlinear */}