Add PWA support and expandable draw canvas
PWA: - manifest.json with name, icons, theme/background color, standalone display - PNG icons (192, 512, 180 apple-touch) generated via pure Node.js (no deps) - index.html: manifest link, theme-color, apple-mobile-web-app-* meta tags, viewport-fit=cover for iPhone notch/Dynamic Island - service worker extended to cache app shell (static assets + index.html) with network-first for navigation, cache-first for hashed assets, network-first-with-cache-fallback for external Bible APIs Expandable canvas: - "Add More Space" button below each draw canvas adds a full notebook page - Existing ink rescales to preserve visual pixel positions (no stretching) - Page count embedded as metadata in the strokes array so it survives navigation without a separate state key Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+16
-1
@@ -2,8 +2,23 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Bible Study Project</title>
|
<title>Bible Study Project</title>
|
||||||
|
|
||||||
|
<!-- PWA manifest -->
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
|
<!-- Theme / status bar -->
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
|
||||||
|
<!-- iOS PWA -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Bible Study" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<!-- Fallback icon for browsers -->
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-50 text-slate-900">
|
<body class="bg-slate-50 text-slate-900">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 694 B |
Binary file not shown.
|
After Width: | Height: | Size: 757 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "Bible Study Project",
|
||||||
|
"short_name": "Bible Study",
|
||||||
|
"description": "Your personal verse-by-verse Bible study companion",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#f8fafc",
|
||||||
|
"theme_color": "#0f172a",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+67
-23
@@ -1,36 +1,80 @@
|
|||||||
const CACHE = 'bible-api-v1';
|
const SHELL = 'bible-shell-v1';
|
||||||
const CACHEABLE = ['https://bible.helloao.org', 'https://bolls.life'];
|
const API = 'bible-api-v1';
|
||||||
|
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life'];
|
||||||
|
|
||||||
self.addEventListener('install', () => self.skipWaiting());
|
self.addEventListener('install', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open(SHELL)
|
||||||
|
.then(c => c.addAll(['/', '/index.html']))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (e) => {
|
||||||
event.waitUntil(
|
e.waitUntil(
|
||||||
caches.keys()
|
caches.keys()
|
||||||
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
|
.then(keys => Promise.all(
|
||||||
|
keys.filter(k => k !== SHELL && k !== API).map(k => caches.delete(k))
|
||||||
|
))
|
||||||
.then(() => self.clients.claim())
|
.then(() => self.clients.claim())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Network-first, fall back to cache for external Bible API requests.
|
self.addEventListener('fetch', (e) => {
|
||||||
self.addEventListener('fetch', (event) => {
|
const { request } = e;
|
||||||
if (!CACHEABLE.some(origin => event.request.url.startsWith(origin))) return;
|
const url = request.url;
|
||||||
|
|
||||||
event.respondWith(
|
// Never intercept API routes — let Express handle them
|
||||||
fetch(event.request)
|
if (new URL(url).pathname.startsWith('/api/')) return;
|
||||||
.then(response => {
|
|
||||||
if (response.ok) {
|
// External Bible API: network-first, fall back to cache
|
||||||
const clone = response.clone();
|
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
|
||||||
caches.open(CACHE).then(cache => cache.put(event.request, clone));
|
e.respondWith(
|
||||||
}
|
fetch(request)
|
||||||
return response;
|
.then(res => {
|
||||||
|
if (res.ok) caches.open(API).then(c => c.put(request, res.clone()));
|
||||||
|
return res;
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
caches.open(CACHE)
|
caches.open(API).then(c => c.match(request)).then(
|
||||||
.then(cache => cache.match(event.request))
|
hit => hit ?? new Response('{"error":"offline"}', {
|
||||||
.then(cached => cached ?? new Response('{"error":"offline"}', {
|
status: 503, headers: { 'Content-Type': 'application/json' },
|
||||||
status: 503,
|
})
|
||||||
headers: { 'Content-Type': 'application/json' },
|
)
|
||||||
}))
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// App-shell navigation: network-first, fall back to cached index.html
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
e.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then(res => {
|
||||||
|
if (res.ok) caches.open(SHELL).then(c => c.put(request, res.clone()));
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
caches.open(SHELL).then(c =>
|
||||||
|
c.match('/index.html').then(hit => hit ?? c.match('/'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-origin static assets (hashed JS/CSS/images): cache-first
|
||||||
|
if (url.startsWith(self.location.origin)) {
|
||||||
|
e.respondWith(
|
||||||
|
caches.open(SHELL).then(c =>
|
||||||
|
c.match(request).then(hit => {
|
||||||
|
if (hit) return hit;
|
||||||
|
return fetch(request).then(res => {
|
||||||
|
if (res.ok) c.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Generates PWA icon PNGs using only Node.js built-ins (no dependencies).
|
||||||
|
import { deflateSync } from 'zlib';
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
const table = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i;
|
||||||
|
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
table[i] = c;
|
||||||
|
}
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
|
||||||
|
return ((crc ^ 0xffffffff) >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function u32(n) {
|
||||||
|
const b = Buffer.alloc(4);
|
||||||
|
b.writeUInt32BE(n, 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
const t = Buffer.from(type, 'ascii');
|
||||||
|
return Buffer.concat([u32(data.length), t, data, u32(crc32(Buffer.concat([t, data])))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePNG(size, draw) {
|
||||||
|
const px = new Uint8ClampedArray(size * size * 4); // RGBA
|
||||||
|
draw(px, size);
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
rows.push(0); // PNG filter byte: None
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
rows.push(px[i], px[i + 1], px[i + 2], px[i + 3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = Buffer.from(rows);
|
||||||
|
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||||
|
const ihdr = chunk('IHDR', Buffer.concat([u32(size), u32(size), Buffer.from([8, 6, 0, 0, 0])]));
|
||||||
|
const idat = chunk('IDAT', deflateSync(raw, { level: 9 }));
|
||||||
|
const iend = chunk('IEND', Buffer.alloc(0));
|
||||||
|
return Buffer.concat([sig, ihdr, idat, iend]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPixel(px, size, x, y, r, g, b, a = 255) {
|
||||||
|
if (x < 0 || x >= size || y < 0 || y >= size) return;
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRect(px, size, x1, y1, x2, y2, r, g, b, a = 255) {
|
||||||
|
for (let y = Math.max(0, y1); y < Math.min(size, y2); y++)
|
||||||
|
for (let x = Math.max(0, x1); x < Math.min(size, x2); x++)
|
||||||
|
setPixel(px, size, x, y, r, g, b, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawIcon(px, S) {
|
||||||
|
const rad = Math.round(S * 0.22); // corner radius
|
||||||
|
|
||||||
|
// Rounded background: #0f172a (15, 23, 42)
|
||||||
|
for (let y = 0; y < S; y++) {
|
||||||
|
for (let x = 0; x < S; x++) {
|
||||||
|
const cx = Math.min(x, S - 1 - x);
|
||||||
|
const cy = Math.min(y, S - 1 - y);
|
||||||
|
if (cx < rad && cy < rad) {
|
||||||
|
const dx = rad - cx - 1;
|
||||||
|
const dy = rad - cy - 1;
|
||||||
|
if (dx * dx + dy * dy > rad * rad) { setPixel(px, S, x, y, 0, 0, 0, 0); continue; }
|
||||||
|
}
|
||||||
|
setPixel(px, S, x, y, 15, 23, 42);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross: vertical bar (center-ish, top-of-cross higher than center)
|
||||||
|
const cw = Math.round(S * 0.12); // cross bar thickness
|
||||||
|
const cx = Math.round(S / 2 - cw / 2);
|
||||||
|
const vTop = Math.round(S * 0.18);
|
||||||
|
const vBot = Math.round(S * 0.82);
|
||||||
|
fillRect(px, S, cx, vTop, cx + cw, vBot, 255, 255, 255);
|
||||||
|
|
||||||
|
// Horizontal bar (slightly above center)
|
||||||
|
const hh = Math.round(S * 0.12);
|
||||||
|
const hy = Math.round(S * 0.36 - hh / 2);
|
||||||
|
const hLeft = Math.round(S * 0.22);
|
||||||
|
const hRight = Math.round(S * 0.78);
|
||||||
|
fillRect(px, S, hLeft, hy, hRight, hy + hh, 255, 255, 255);
|
||||||
|
|
||||||
|
// Subtle glow/shine at cross intersection (slightly lighter center)
|
||||||
|
const glowR = Math.round(S * 0.07);
|
||||||
|
const gcx = Math.round(S / 2);
|
||||||
|
const gcy = Math.round(S * 0.36);
|
||||||
|
for (let y = gcy - glowR; y <= gcy + glowR; y++) {
|
||||||
|
for (let x = gcx - glowR; x <= gcx + glowR; x++) {
|
||||||
|
const d2 = (x - gcx) ** 2 + (y - gcy) ** 2;
|
||||||
|
if (d2 <= glowR * glowR) {
|
||||||
|
const i = (Math.max(0, Math.min(S - 1, y)) * S + Math.max(0, Math.min(S - 1, x))) * 4;
|
||||||
|
if (px[i + 3] === 255) {
|
||||||
|
px[i] = Math.min(255, px[i] + 20);
|
||||||
|
px[i + 1] = Math.min(255, px[i + 1] + 20);
|
||||||
|
px[i + 2] = Math.min(255, px[i + 2] + 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync('public', { recursive: true });
|
||||||
|
writeFileSync('public/icon-192.png', makePNG(192, drawIcon));
|
||||||
|
writeFileSync('public/icon-512.png', makePNG(512, drawIcon));
|
||||||
|
writeFileSync('public/apple-touch-icon.png', makePNG(180, drawIcon));
|
||||||
|
console.log('Icons written to public/');
|
||||||
+64
-15
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { renderInkToCanvas } from '../utils/inkRender.js';
|
import { renderInkToCanvas } from '../utils/inkRender.js';
|
||||||
|
|
||||||
@@ -8,9 +8,22 @@ const INK_SIZES = [
|
|||||||
{ label: 'M', value: 0.012 },
|
{ label: 'M', value: 0.012 },
|
||||||
{ label: 'L', value: 0.025 },
|
{ label: 'L', value: 0.025 },
|
||||||
];
|
];
|
||||||
|
const PAGE_HEIGHT = 640; // px per notebook page
|
||||||
|
|
||||||
|
// Page count is persisted as a hidden metadata stroke at strokes[0]
|
||||||
|
// so the canvas size survives navigation without a separate state key.
|
||||||
|
function extractMeta(strokes) {
|
||||||
|
if (strokes.length > 0 && strokes[0]?._meta) {
|
||||||
|
return { pageCount: strokes[0].pageCount ?? 1, realStrokes: strokes.slice(1) };
|
||||||
|
}
|
||||||
|
return { pageCount: 1, realStrokes: strokes };
|
||||||
|
}
|
||||||
|
function packStrokes(realStrokes, pageCount) {
|
||||||
|
return [{ _meta: true, pageCount }, ...realStrokes];
|
||||||
|
}
|
||||||
|
|
||||||
// Standalone notebook-style draw canvas.
|
// Standalone notebook-style draw canvas.
|
||||||
// strokes: array of saved stroke objects
|
// strokes: array of saved stroke objects (may include a leading metadata object)
|
||||||
// onStrokesChange: (newStrokes) => void
|
// onStrokesChange: (newStrokes) => void
|
||||||
// onDone: optional () => void — shows "Done" button when provided
|
// onDone: optional () => void — shows "Done" button when provided
|
||||||
// headerContent: optional JSX rendered above the notebook area.
|
// headerContent: optional JSX rendered above the notebook area.
|
||||||
@@ -18,18 +31,26 @@ const INK_SIZES = [
|
|||||||
export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerContent }) {
|
export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerContent }) {
|
||||||
const { drawTool, setDrawTool, drawColor, setDrawColor, drawSize, setDrawSize } = useApp();
|
const { drawTool, setDrawTool, drawColor, setDrawColor, drawSize, setDrawSize } = useApp();
|
||||||
|
|
||||||
|
const { pageCount: initPages } = extractMeta(strokes);
|
||||||
|
const [pageCount, setPageCount] = useState(initPages);
|
||||||
|
|
||||||
|
// realStrokes = strokes without the metadata header
|
||||||
|
const { realStrokes } = extractMeta(strokes);
|
||||||
|
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const activeStrokeRef = useRef([]);
|
const activeStrokeRef = useRef([]);
|
||||||
const isDrawingRef = useRef(false);
|
const isDrawingRef = useRef(false);
|
||||||
|
|
||||||
// Refs prevent stale closures in stable callbacks
|
// Refs prevent stale closures in stable callbacks
|
||||||
const strokesRef = useRef(strokes);
|
const strokesRef = useRef(realStrokes);
|
||||||
const onStrokesChangeRef = useRef(onStrokesChange);
|
const onStrokesChangeRef = useRef(onStrokesChange);
|
||||||
const drawToolRef = useRef(drawTool);
|
const drawToolRef = useRef(drawTool);
|
||||||
const drawColorRef = useRef(drawColor);
|
const drawColorRef = useRef(drawColor);
|
||||||
const drawSizeRef = useRef(drawSize);
|
const drawSizeRef = useRef(drawSize);
|
||||||
strokesRef.current = strokes;
|
strokesRef.current = realStrokes;
|
||||||
onStrokesChangeRef.current = onStrokesChange;
|
onStrokesChangeRef.current = onStrokesChange;
|
||||||
|
const pageCountRef = useRef(pageCount);
|
||||||
|
pageCountRef.current = pageCount;
|
||||||
drawToolRef.current = drawTool;
|
drawToolRef.current = drawTool;
|
||||||
drawColorRef.current = drawColor;
|
drawColorRef.current = drawColor;
|
||||||
drawSizeRef.current = drawSize;
|
drawSizeRef.current = drawSize;
|
||||||
@@ -47,7 +68,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { render(); }, [strokes, drawTool, drawColor, drawSize, render]);
|
useEffect(() => { render(); }, [realStrokes, drawTool, drawColor, drawSize, render]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -75,7 +96,8 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
function commitStroke() {
|
function commitStroke() {
|
||||||
const pts = activeStrokeRef.current;
|
const pts = activeStrokeRef.current;
|
||||||
if (drawToolRef.current !== 'eraser' && pts.length > 1) {
|
if (drawToolRef.current !== 'eraser' && pts.length > 1) {
|
||||||
onStrokesChangeRef.current([
|
onStrokesChangeRef.current(packStrokes(
|
||||||
|
[
|
||||||
...strokesRef.current,
|
...strokesRef.current,
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`,
|
id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`,
|
||||||
@@ -84,7 +106,9 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
size: drawSizeRef.current,
|
size: drawSizeRef.current,
|
||||||
points: pts,
|
points: pts,
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
pageCountRef.current,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
activeStrokeRef.current = [];
|
activeStrokeRef.current = [];
|
||||||
isDrawingRef.current = false;
|
isDrawingRef.current = false;
|
||||||
@@ -98,7 +122,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
(s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r),
|
(s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r),
|
||||||
);
|
);
|
||||||
if (remaining.length !== strokesRef.current.length)
|
if (remaining.length !== strokesRef.current.length)
|
||||||
onStrokesChangeRef.current(remaining);
|
onStrokesChangeRef.current(packStrokes(remaining, pageCountRef.current));
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS Safari: Apple Pencil fires Touch Events with touchType === 'stylus'.
|
// iOS Safari: Apple Pencil fires Touch Events with touchType === 'stylus'.
|
||||||
@@ -197,11 +221,27 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
};
|
};
|
||||||
}, [getPoint, render]);
|
}, [getPoint, render]);
|
||||||
|
|
||||||
const undo = () => strokes.length > 0 && onStrokesChange(strokes.slice(0, -1));
|
const undo = () =>
|
||||||
|
realStrokes.length > 0 &&
|
||||||
|
onStrokesChange(packStrokes(realStrokes.slice(0, -1), pageCount));
|
||||||
|
|
||||||
const clear = () =>
|
const clear = () =>
|
||||||
strokes.length > 0 &&
|
realStrokes.length > 0 &&
|
||||||
window.confirm('Clear all ink notes?') &&
|
window.confirm('Clear all ink notes?') &&
|
||||||
onStrokesChange([]);
|
onStrokesChange(packStrokes([], pageCount));
|
||||||
|
|
||||||
|
// Add another notebook page below existing content.
|
||||||
|
// Rescales all stroke y-coords so existing ink stays at the same pixel position.
|
||||||
|
const addSpace = () => {
|
||||||
|
const newCount = pageCount + 1;
|
||||||
|
const ratio = pageCount / newCount;
|
||||||
|
const rescaled = realStrokes.map((s) => ({
|
||||||
|
...s,
|
||||||
|
points: s.points.map(([x, y, p]) => [x, y * ratio, p]),
|
||||||
|
}));
|
||||||
|
onStrokesChange(packStrokes(rescaled, newCount));
|
||||||
|
setPageCount(newCount);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-3xl border border-slate-200 shadow-sm select-none" style={{ WebkitUserSelect: 'none' }}>
|
<div className="overflow-hidden rounded-3xl border border-slate-200 shadow-sm select-none" style={{ WebkitUserSelect: 'none' }}>
|
||||||
@@ -259,7 +299,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={undo}
|
onClick={undo}
|
||||||
disabled={strokes.length === 0}
|
disabled={realStrokes.length === 0}
|
||||||
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40"
|
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Undo
|
Undo
|
||||||
@@ -267,7 +307,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clear}
|
onClick={clear}
|
||||||
disabled={strokes.length === 0}
|
disabled={realStrokes.length === 0}
|
||||||
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@@ -298,10 +338,10 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
{headerContent}
|
{headerContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Ruled notebook area */}
|
{/* Ruled notebook area — height grows with pageCount */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minHeight: 640,
|
minHeight: PAGE_HEIGHT * pageCount,
|
||||||
background: 'white',
|
background: 'white',
|
||||||
backgroundImage: 'repeating-linear-gradient(transparent 0px, transparent 31px, #dde3ec 31px, #dde3ec 32px)',
|
backgroundImage: 'repeating-linear-gradient(transparent 0px, transparent 31px, #dde3ec 31px, #dde3ec 32px)',
|
||||||
}}
|
}}
|
||||||
@@ -321,6 +361,15 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add more notebook space */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addSpace}
|
||||||
|
className="w-full border-t border-slate-200 bg-slate-50 py-3 text-xs font-semibold text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
+ Add More Space
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user