Improved drawing sync: MIME detection, WebP/JPEG support, sync log + frontend updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-21 13:15:25 +02:00
1 parent a8779d7be1
commit d97591b242
7 files changed
+540 -140

No files matched your search

+46 -4
View File
@@ -136,6 +136,18 @@ function decrypt(data) {
return decrypted; return decrypted;
} }
function detectImageMime(buffer, filePath = "") {
if (buffer && buffer.length >= 12) {
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return "image/png";
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
if (buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") return "image/webp";
}
const ext = path.extname(filePath).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
return "image/png";
}
/* ─────────── Vector clock conflict resolution ─────────── */ /* ─────────── Vector clock conflict resolution ─────────── */
function mergeVectorClocks(clockA, clockB) { function mergeVectorClocks(clockA, clockB) {
const merged = { ...clockA }; const merged = { ...clockA };
@@ -313,6 +325,20 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => {
} }
} }
delete note.content_encrypted; delete note.content_encrypted;
// Read drawing data from file if exists
if (note.drawing_data_path && fs.existsSync(note.drawing_data_path)) {
try {
const raw = fs.readFileSync(note.drawing_data_path);
const mime = detectImageMime(raw, note.drawing_data_path);
note.drawingData = `data:${mime};base64,` + raw.toString("base64");
} catch {
note.drawingData = null;
}
} else {
note.drawingData = null;
}
res.json(note); res.json(note);
}); });
@@ -404,12 +430,28 @@ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!note) return res.status(404).json({ error: "Not found" }); if (!note) return res.status(404).json({ error: "Not found" });
const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.png`); if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingData required" });
const base64Data = drawingData.replace(/^data:image\/\w+;base64,/, ""); const m = drawingData.match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/);
if (!m) return res.status(400).json({ error: "Invalid drawing format" });
const mime = m[1];
const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg";
const base64Data = m[2];
const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${ext}`);
// Remove older file variants from previous format writes.
for (const oldExt of ["png", "jpg", "jpeg", "webp"]) {
const oldPath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${oldExt}`);
if (oldPath !== filePath && fs.existsSync(oldPath)) {
try { fs.unlinkSync(oldPath); } catch {}
}
}
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1 WHERE id = ?") db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?")
.run(filePath, req.params.id); .run(filePath, req.deviceId, req.params.id);
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)")
.run(req.userId, req.params.id, JSON.stringify({ drawingUpdated: true }), req.deviceId);
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, drawingUpdated: true } }, req.deviceId);
res.json({ success: true }); res.json({ success: true });
}); });
+8 -7
View File
@@ -4,26 +4,26 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Allow large drawing data + body size
client_max_body_size 50m; client_max_body_size 50m;
# PWA service worker — MUST not be cached by browser/proxy # PWA service worker — never cache
location = /sw.js { location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; add_header Pragma "no-cache";
add_header Expires "0";
add_header Service-Worker-Allowed "/"; add_header Service-Worker-Allowed "/";
add_header Content-Type "application/javascript"; types { application/javascript js; }
try_files $uri =404; try_files $uri =404;
} }
# PWA manifest # PWA manifest
location = /manifest.json { location = /manifest.json {
add_header Cache-Control "public, max-age=3600"; add_header Cache-Control "no-cache";
add_header Content-Type "application/manifest+json"; types { application/manifest+json json; }
try_files $uri =404; try_files $uri =404;
} }
# Static assets — long cache with content hash from Vite # Vite hashed assets — immutable cache
location /assets/ { location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404; try_files $uri =404;
@@ -35,8 +35,9 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# SPA fallback — all other routes serve index.html # SPA fallback
location / { location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+27 -4
View File
@@ -2,16 +2,39 @@
"name": "InkNote", "name": "InkNote",
"short_name": "InkNote", "short_name": "InkNote",
"description": "Write & draw — notes that sync everywhere", "description": "Write & draw — notes that sync everywhere",
"start_url": ".", "start_url": "./",
"scope": ".", "scope": "./",
"id": "/",
"display": "standalone", "display": "standalone",
"background_color": "#0d1117", "background_color": "#0d1117",
"theme_color": "#0d1117", "theme_color": "#0d1117",
"orientation": "any", "orientation": "any",
"categories": ["productivity", "utilities"], "categories": ["productivity", "utilities"],
"icons": [ "icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, {
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } "src": "./icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "./icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
], ],
"prefer_related_applications": false "prefer_related_applications": false
} }
+32 -36
View File
@@ -1,66 +1,62 @@
const CACHE_NAME = 'inknote-v2'; const CACHE_NAME = 'inknote-v6';
// Install — cache app shell on first load // Install — don't precache, just activate immediately
self.addEventListener('install', (e) => { self.addEventListener('install', () => {
e.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => {
// Cache the main page and manifest
const urls = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png'];
for (const url of urls) {
try { await cache.add(url); } catch (err) { console.warn('[SW] Failed to cache:', url); }
}
})
);
self.skipWaiting(); self.skipWaiting();
}); });
// Activate — clean old caches // Activate — clean old caches, take control
self.addEventListener('activate', (e) => { self.addEventListener('activate', (e) => {
e.waitUntil( e.waitUntil(
caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))) caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
).then(() => self.clients.claim())
); );
self.clients.claim();
}); });
// Fetch handler // Fetch — runtime caching strategy
self.addEventListener('fetch', (e) => { self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url); const url = new URL(e.request.url);
// Skip API/WebSocket requests entirely — handled by app + IndexedDB // Never intercept API or WebSocket
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return; if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
// Skip non-GET requests // Only handle GET requests
if (e.request.method !== 'GET') return; if (e.request.method !== 'GET') return;
// Navigation (HTML pages) — network-first, fallback to cache // Navigation (HTML) — network-first with cache fallback
if (e.request.mode === 'navigate') { if (e.request.mode === 'navigate') {
e.respondWith( e.respondWith(
fetch(e.request) fetch(e.request).then(r => {
.then(r => { const c = r.clone(); caches.open(CACHE_NAME).then(cache => cache.put('/', c)); return r; }) const clone = r.clone();
.catch(() => caches.match('/')) caches.open(CACHE_NAME).then(c => c.put(e.request, clone));
return r;
}).catch(() => caches.match(e.request).then(r => r || caches.match('/')))
); );
return; return;
} }
// JS/CSS/images — stale-while-revalidate // Static assets — stale-while-revalidate
e.respondWith( e.respondWith(
caches.match(e.request).then(cached => { caches.open(CACHE_NAME).then(cache =>
const fetchP = fetch(e.request).then(r => { cache.match(e.request).then(cached => {
if (r.ok && r.type === 'basic') { const fetchP = fetch(e.request).then(r => {
caches.open(CACHE_NAME).then(cache => cache.put(e.request, r.clone())); if (r.ok) cache.put(e.request, r.clone());
} return r;
return r; }).catch(() => cached);
}).catch(() => cached); return cached || fetchP;
return cached || fetchP; })
}) )
); );
}); });
// Background sync // Background sync trigger
self.addEventListener('sync', (e) => { self.addEventListener('sync', (e) => {
if (e.tag === 'sync-changes') { if (e.tag === 'sync-changes') {
e.waitUntil(self.clients.matchAll().then(clients => { e.waitUntil(
clients.forEach(c => c.postMessage({ type: 'FLUSH_QUEUE' })); self.clients.matchAll().then(clients =>
})); clients.forEach(c => c.postMessage({ type: 'FLUSH_QUEUE' }))
)
);
} }
}); });
+372 -56
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import * as sync from "./syncEngine.js"; import * as sync from "./syncEngine.js";
const uid = () => Math.random().toString(36).slice(2, 10); const uid = () => Math.random().toString(36).slice(2, 10);
@@ -286,84 +286,339 @@ function NoteEditor({note,onUpdate,dark}){
const editorRef=useRef(null);const canvasRef=useRef(null);const ctxRef=useRef(null); const editorRef=useRef(null);const canvasRef=useRef(null);const ctxRef=useRef(null);
const [isDrawing,setIsDrawing]=useState(false); const [isDrawing,setIsDrawing]=useState(false);
const [tool,setTool]=useState("pen");const [penColor,setPenColor]=useState(dark?"#e6edf3":"#1a1a2e");const [penSize,setPenSize]=useState(3); const [tool,setTool]=useState("pen");const [penColor,setPenColor]=useState(dark?"#e6edf3":"#1a1a2e");const [penSize,setPenSize]=useState(3);
const [history,setHistory]=useState([]);const [hIdx,setHIdx]=useState(-1); const [undoStack,setUndoStack]=useState([]);const [redoStack,setRedoStack]=useState([]);
const [showColors,setShowColors]=useState(false);const [showSizes,setShowSizes]=useState(false); const [showColors,setShowColors]=useState(false);const [showSizes,setShowSizes]=useState(false);
const [selectedImg,setSelectedImg]=useState(null);
const [hideDrawing,setHideDrawing]=useState(false);
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 CW=1200,CH=1600; // Canonical drawing resolution shared by all devices.
const SAVE_DEBOUNCE_MS=600;
const [stageSize,setStageSize]=useState({w:CW,h:CH});
const parsed=useMemo(()=>{try{const p=JSON.parse(note.content);return{html:p.html||"",tasks:p.tasks||[]};}catch{return{html:note.content||"",tasks:[]};}},[note.content,note.id]); const parsed=useMemo(()=>{try{const p=JSON.parse(note.content);return{html:p.html||"",tasks:p.tasks||[]};}catch{return{html:note.content||"",tasks:[]};}},[note.content,note.id]);
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]); 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]);
useEffect(()=>{if(editorRef.current)editorRef.current.innerHTML=parsed.html;},[note.id]);
useEffect(()=>{ useEffect(()=>{
if(mode!=="draw")return;const c=canvasRef.current;if(!c)return; const el=editorRef.current;
const p=c.parentElement;c.width=p.clientWidth;c.height=p.clientHeight; if(!el)return;
const prevNoteId=el.dataset.noteId;
const isNewNote=prevNoteId!==note.id;
if(isNewNote){
el.innerHTML=parsed.html;
el.dataset.noteId=note.id;
return;
}
// Late content arrival for the same note (mobile/network): fill only if editor is still empty.
const hasLocalText=(el.textContent||"").trim().length>0;
if(!hasLocalText && parsed.html){
el.innerHTML=parsed.html;
}
},[note.id,parsed.html]);
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.
useEffect(()=>{
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*CH)/CW);
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(!canvasRef.current||!dirtyRef.current)return;
onUpdate({drawingData:canvasRef.current.toDataURL("image/png")});
dirtyRef.current=false;
};
const scheduleDrawingSave=()=>{
if(saveTimerRef.current)clearTimeout(saveTimerRef.current);
saveTimerRef.current=setTimeout(()=>{saveTimerRef.current=null;saveDrawingNow();},SAVE_DEBOUNCE_MS);
};
/* ── DRAWING: init canvas once on mode switch ── */
useEffect(()=>{
if(mode!=="draw")return;
const c=canvasRef.current;if(!c)return;
c.width=CW;c.height=CH;
const ctx=c.getContext("2d");ctx.lineCap="round";ctx.lineJoin="round";ctxRef.current=ctx; const ctx=c.getContext("2d");ctx.lineCap="round";ctx.lineJoin="round";ctxRef.current=ctx;
if(note.drawingData){const img=new Image();img.onload=()=>{ctx.drawImage(img,0,0);snap();};img.src=note.drawingData;}else snap(); ctx.clearRect(0,0,CW,CH);
dirtyRef.current=false;
if(note.drawingData){
const img=new Image();img.onload=()=>{
const t=document.createElement("canvas");t.width=CW;t.height=CH;
const tctx=t.getContext("2d",{willReadFrequently:true});if(!tctx){ctx.drawImage(img,0,0,CW,CH);return;}
tctx.drawImage(img,0,0,CW,CH);
try{
const sc=document.createElement("canvas");sc.width=40;sc.height=40;
const sctx=sc.getContext("2d",{willReadFrequently:true});if(!sctx)throw new Error("ctx");
sctx.drawImage(t,0,0,40,40);
const sample=sctx.getImageData(0,0,40,40).data;
let solid=0,black=0;
for(let i=0;i<sample.length;i+=4){
const a=sample[i+3];if(a<245)continue;solid++;
if(sample[i]<16&&sample[i+1]<16&&sample[i+2]<16)black++;
}
const blackRatio=solid?black/solid:0;
if(blackRatio>0.9){
const im=tctx.getImageData(0,0,CW,CH);const d=im.data;
for(let i=0;i<d.length;i+=4){
if(d[i]<18&&d[i+1]<18&&d[i+2]<18&&d[i+3]>245)d[i+3]=0;
}
tctx.putImageData(im,0,0);
}
}catch{}
ctx.drawImage(t,0,0,CW,CH);
};img.src=note.drawingData;
}
},[mode]); },[mode]);
/* Save drawing on mode switch and debounced stroke-end updates. */
const switchMode=(m)=>{
if(mode==="draw"&&m==="text"&&dirtyRef.current&&canvasRef.current){
if(saveTimerRef.current){clearTimeout(saveTimerRef.current);saveTimerRef.current=null;}
saveDrawingNow();
}
setMode(m);setShowColors(false);setShowSizes(false);setSelectedImg(null);
};
const pushUndo=()=>{if(!canvasRef.current)return;const d=canvasRef.current.toDataURL("image/png");setUndoStack(p=>[...p.slice(-15),d]);setRedoStack([]);};
const undo=()=>{if(!undoStack.length)return;const s=undoStack[undoStack.length-1];setUndoStack(p=>p.slice(0,-1));
setRedoStack(p=>[...p,canvasRef.current.toDataURL("image/png")]);
const i=new Image();i.onload=()=>{ctxRef.current.clearRect(0,0,CW,CH);ctxRef.current.drawImage(i,0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();};i.src=s;};
const redo=()=>{if(!redoStack.length)return;const s=redoStack[redoStack.length-1];setRedoStack(p=>p.slice(0,-1));
setUndoStack(p=>[...p,canvasRef.current.toDataURL("image/png")]);
const i=new Image();i.onload=()=>{ctxRef.current.clearRect(0,0,CW,CH);ctxRef.current.drawImage(i,0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();};i.src=s;};
const clearCanvas=()=>{pushUndo();ctxRef.current?.clearRect(0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();};
const getPos=(e)=>{const c=canvasRef.current,r=c.getBoundingClientRect();const t=e.touches?e.touches[0]:e;return{x:(t.clientX-r.left)*(CW/r.width),y:(t.clientY-r.top)*(CH/r.height)};};
const startDraw=(e)=>{e.preventDefault();pushUndo();const p=getPos(e);setIsDrawing(true);
const ctx=ctxRef.current;ctx.beginPath();ctx.moveTo(p.x,p.y);
if(tool==="eraser"){ctx.globalCompositeOperation="destination-out";ctx.lineWidth=penSize*5;}
else if(tool==="highlighter"){ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor+"44";ctx.lineWidth=penSize*6;}
else{ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor;ctx.lineWidth=penSize;}};
const doDraw=(e)=>{if(!isDrawing)return;e.preventDefault();const p=getPos(e);ctxRef.current.lineTo(p.x,p.y);ctxRef.current.stroke();};
const stopDraw=(e)=>{if(!isDrawing)return;e?.preventDefault();setIsDrawing(false);ctxRef.current.closePath();ctxRef.current.globalCompositeOperation="source-over";dirtyRef.current=true;scheduleDrawingSave();};
useEffect(()=>()=>{if(saveTimerRef.current)clearTimeout(saveTimerRef.current);saveDrawingNow();},[]);
/* ── TEXT ── */
const saveContent=(html,tasks)=>{onUpdate({content:JSON.stringify({html,tasks})});}; const saveContent=(html,tasks)=>{onUpdate({content:JSON.stringify({html,tasks})});};
const onHtmlChange=()=>{if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; const onHtmlChange=()=>{if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);};
const onTasksChange=(tasks)=>{saveContent(parsed.html,tasks);}; const onTasksChange=(tasks)=>{saveContent(parsed.html,tasks);};
const addTaskList=()=>{if(parsed.tasks.length===0)onTasksChange([{id:uid(),text:"",done:false}]);}; const addTaskList=()=>{if(parsed.tasks.length===0)onTasksChange([{id:uid(),text:"",done:false}]);};
const snap=()=>{if(!canvasRef.current)return;const d=canvasRef.current.toDataURL();setHistory(p=>[...p.slice(0,(hIdx>=0?hIdx+1:p.length)),d]);setHIdx(p=>p+1);};
const restoreIdx=(idx)=>{setHIdx(idx);const img=new Image();img.onload=()=>{ctxRef.current.clearRect(0,0,canvasRef.current.width,canvasRef.current.height);ctxRef.current.drawImage(img,0,0);};img.src=history[idx];};
const undo=()=>{if(hIdx>0)restoreIdx(hIdx-1);};const redo=()=>{if(hIdx<history.length-1)restoreIdx(hIdx+1);};
const clearCanvas=()=>{ctxRef.current?.clearRect(0,0,canvasRef.current.width,canvasRef.current.height);snap();onUpdate({drawingData:canvasRef.current.toDataURL()});};
const getPos=(e)=>{const c=canvasRef.current,r=c.getBoundingClientRect();const t=e.touches?e.touches[0]:e;return{x:(t.clientX-r.left)*(c.width/r.width),y:(t.clientY-r.top)*(c.height/r.height)};};
const startDraw=(e)=>{e.preventDefault();const p=getPos(e);setIsDrawing(true);const ctx=ctxRef.current;ctx.beginPath();ctx.moveTo(p.x,p.y);if(tool==="eraser"){ctx.globalCompositeOperation="destination-out";ctx.lineWidth=penSize*5;}else if(tool==="highlighter"){ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor+"44";ctx.lineWidth=penSize*6;}else{ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor;ctx.lineWidth=penSize;}};
const doDraw=(e)=>{if(!isDrawing)return;e.preventDefault();const p=getPos(e);ctxRef.current.lineTo(p.x,p.y);ctxRef.current.stroke();};
const stopDraw=(e)=>{if(!isDrawing)return;e?.preventDefault();setIsDrawing(false);ctxRef.current.closePath();ctxRef.current.globalCompositeOperation="source-over";snap();onUpdate({drawingData:canvasRef.current.toDataURL()});};
const exec=(cmd,val=null)=>{document.execCommand(cmd,false,val);editorRef.current?.focus();onHtmlChange();}; const exec=(cmd,val=null)=>{document.execCommand(cmd,false,val);editorRef.current?.focus();onHtmlChange();};
const tbtn=(active)=>({display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:6,border:"none",cursor:"pointer",background:active?T.active+"22":"transparent",color:active?T.active:T.text,transition:"all .12s",flexShrink:0});
const modeBtn=(m,icon,label)=><button onClick={()=>{setMode(m);setShowColors(false);setShowSizes(false);}} 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}}>{icon}<span>{label}</span></button>;
return(<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}> /* ── IMAGE PASTE ── */
useEffect(()=>{
const el=editorRef.current;if(!el||mode!=="text")return;
const h=(e)=>{
const items=e.clipboardData?.items;if(!items)return;
for(const item of items){
if(item.type.startsWith("image/")){
e.preventDefault();
const blob=item.getAsFile();if(!blob)return;
const reader=new FileReader();
reader.onload=(ev)=>{
document.execCommand("insertHTML",false,
`<img src="${ev.target.result}" data-rid="1" style="width:50%;max-width:50%;height:auto;border-radius:8px;margin:8px 0;display:block;cursor:pointer" />`);
if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);
};
reader.readAsDataURL(blob);return;
}
}
};
el.addEventListener("paste",h);return()=>el.removeEventListener("paste",h);
},[mode,parsed.tasks]);
/* ── IMAGE SELECT + DRAG RESIZE ── */
useEffect(()=>{
const el=editorRef.current;if(!el||mode!=="text")return;
const onClick=(e)=>{
const img=e.target.closest("img[data-rid]");
setSelectedImg(img||null);
};
const onPointerDown=(e)=>{
const img=e.target.closest("img[data-rid]");if(!img)return;
const rect=img.getBoundingClientRect();
const px=e.clientX||(e.touches?.[0]?.clientX);
// Only start resize if clicking within 20px of right edge
if(px<rect.right-20)return;
e.preventDefault();e.stopPropagation();
setSelectedImg(img);
const startX=px,startW=img.offsetWidth,cW=el.offsetWidth-48;
const move=(ev)=>{
ev.preventDefault();
const mx=ev.clientX||(ev.touches?.[0]?.clientX);
const nw=Math.max(40,Math.min(cW,startW+(mx-startX)));
const pct=Math.round(nw/cW*100);
img.style.width=pct+"%";img.style.maxWidth=pct+"%";
};
const up=()=>{
document.removeEventListener("mousemove",move);document.removeEventListener("mouseup",up);
document.removeEventListener("touchmove",move);document.removeEventListener("touchend",up);
if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);
};
document.addEventListener("mousemove",move);document.addEventListener("mouseup",up);
document.addEventListener("touchmove",move,{passive:false});document.addEventListener("touchend",up);
};
// Cursor hint near right edge
const onMove=(e)=>{
const img=e.target.closest("img[data-rid]");if(!img){return;}
const r=img.getBoundingClientRect();
img.style.cursor=e.clientX>r.right-20?"ew-resize":"pointer";
};
el.addEventListener("click",onClick);
el.addEventListener("mousedown",onPointerDown);
el.addEventListener("touchstart",onPointerDown,{passive:false});
el.addEventListener("mousemove",onMove);
return()=>{el.removeEventListener("click",onClick);el.removeEventListener("mousedown",onPointerDown);
el.removeEventListener("touchstart",onPointerDown);el.removeEventListener("mousemove",onMove);};
},[mode,parsed.tasks]);
// Blue outline on selected image
useEffect(()=>{
const el=editorRef.current;if(!el)return;
el.querySelectorAll("img[data-rid]").forEach(img=>{
if(img===selectedImg){img.style.outline=`3px solid ${T.active}`;img.style.outlineOffset="2px";}
else{img.style.outline="none";img.style.outlineOffset="0";}
});
},[selectedImg,T.active]);
// Deselect on outside click
useEffect(()=>{if(!selectedImg)return;
const h=(e)=>{if(!editorRef.current?.contains(e.target)&&!e.target.closest("[data-imgtb]"))setSelectedImg(null);};
document.addEventListener("pointerdown",h);return()=>document.removeEventListener("pointerdown",h);
},[selectedImg]);
const setImgSize=(p)=>{if(!selectedImg)return;selectedImg.style.width=p+"%";selectedImg.style.maxWidth=p+"%";if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);};
const deleteImg=()=>{if(!selectedImg)return;selectedImg.remove();setSelectedImg(null);if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);};
/* ── 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>;
return(<div style={{display:"flex",flexDirection:"column",height:"100%",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}}> <div style={{display:"flex",alignItems:"center",gap:6,padding:"7px 14px",borderBottom:`1px solid ${T.border}`,background:T.toolbar,flexWrap:"wrap",minHeight:46}}>
{modeBtn("text",I.text,"Write")}{modeBtn("draw",I.draw,"Draw")} {mb("text",I.text,"Write")}{mb("draw",I.draw,"Draw")}
<div style={{width:1,height:22,background:T.border,margin:"0 2px",flexShrink:0}}/> <div style={{width:1,height:22,background:T.border,margin:"0 2px",flexShrink:0}}/>
{mode==="text"?(<> {mode==="text"?(<>
<button style={tbtn(false)} onClick={()=>exec("bold")}>{I.bold}</button> <button style={tb()} onClick={()=>exec("bold")}>{I.bold}</button>
<button style={tbtn(false)} onClick={()=>exec("italic")}>{I.italic}</button> <button style={tb()} onClick={()=>exec("italic")}>{I.italic}</button>
<button style={tbtn(false)} onClick={()=>exec("underline")}>{I.underline}</button> <button style={tb()} onClick={()=>exec("underline")}>{I.underline}</button>
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/> <div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
<button style={tbtn(false)} onClick={()=>exec("insertUnorderedList")}>{I.list}</button> <button style={tb()} onClick={()=>exec("insertUnorderedList")}>{I.list}</button>
<button style={tbtn(false)} onClick={()=>exec("insertOrderedList")}>{I.listOrd}</button> <button style={tb()} onClick={()=>exec("insertOrderedList")}>{I.listOrd}</button>
<button style={tbtn(parsed.tasks.length>0)} onClick={addTaskList} title="Add task list">{I.check}</button> <button style={tb(parsed.tasks.length>0)} onClick={addTaskList} title="Add task list">{I.check}</button>
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/> <div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
<select onChange={e=>exec("formatBlock",e.target.value)} defaultValue="" style={{border:`1px solid ${T.border}`,borderRadius:6,padding:"3px 6px",background:T.toolbar,color:T.text,fontSize:11}}> <select onChange={e=>exec("formatBlock",e.target.value)} defaultValue="" style={{border:`1px solid ${T.border}`,borderRadius:6,padding:"3px 6px",background:T.toolbar,color:T.text,fontSize:11}}>
<option value="" disabled>Heading</option><option value="h1">H1</option><option value="h2">H2</option><option value="h3">H3</option><option value="p">Body</option> <option value="" disabled>Heading</option><option value="h1">H1</option><option value="h2">H2</option><option value="h3">H3</option><option value="p">Body</option>
</select> </select>
</>):(<> </>):(<>
<button style={tbtn(tool==="pen")} onClick={()=>setTool("pen")}>{I.pen}</button> <button style={tb(tool==="pen")} onClick={()=>setTool("pen")}>{I.pen}</button>
<button style={tbtn(tool==="highlighter")} onClick={()=>setTool("highlighter")}>{I.highlight}</button> <button style={tb(tool==="highlighter")} onClick={()=>setTool("highlighter")}>{I.highlight}</button>
<button style={tbtn(tool==="eraser")} onClick={()=>setTool("eraser")}>{I.eraser}</button> <button style={tb(tool==="eraser")} onClick={()=>setTool("eraser")}>{I.eraser}</button>
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/> <div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
<button style={tbtn(false)} onClick={undo}>{I.undo}</button><button style={tbtn(false)} onClick={redo}>{I.redo}</button> <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={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
<div style={{position:"relative"}}> <div style={{position:"relative"}}>
<button style={tbtn(showColors)} onClick={()=>{setShowColors(!showColors);setShowSizes(false);}}><div style={{width:18,height:18,borderRadius:"50%",background:penColor,border:`2px solid ${T.border}`}}/></button> <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)"}}> {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"}}/>)} {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> </div>
<div style={{position:"relative"}}> <div style={{position:"relative"}}>
<button style={tbtn(showSizes)} onClick={()=>{setShowSizes(!showSizes);setShowColors(false);}}><div style={{width:Math.min(penSize*2,16),height:Math.min(penSize*2,16),borderRadius:"50%",background:T.text}}/></button> <button style={tb(showSizes)} onClick={()=>{setShowSizes(!showSizes);setShowColors(false);}}><div style={{width:Math.min(penSize*2,16),height:Math.min(penSize*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)"}}> {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=><button key={s} onClick={()=>{setPenSize(s);setShowSizes(false);}} style={{display:"flex",alignItems:"center",gap:8,padding:"3px 8px",borderRadius:6,border:"none",cursor:"pointer",background:penSize===s?T.active+"22":"transparent"}}> {PEN_SIZES.map(s=><button key={s} onClick={()=>{setPenSize(s);setShowSizes(false);}} style={{display:"flex",alignItems:"center",gap:8,padding:"3px 8px",borderRadius:6,border:"none",cursor:"pointer",background:penSize===s?T.active+"22":"transparent"}}>
<div style={{width:Math.min(s*2.5,20),height:Math.min(s*2.5,20),borderRadius:"50%",background:T.text}}/><span style={{fontSize:11,color:T.muted}}>{s}px</span></button>)} <div style={{width:Math.min(s*2.5,20),height:Math.min(s*2.5,20),borderRadius:"50%",background:T.text}}/><span style={{fontSize:11,color:T.muted}}>{s}px</span></button>)}
</div>} </div>}
</div> </div>
<div style={{flex:1}}/><button style={{...tbtn(false),color:"#e5534b"}} onClick={clearCanvas}>{I.trash}</button> <div style={{flex:1}}/><button style={{...tb(),color:"#e5534b"}} onClick={clearCanvas}>{I.trash}</button>
</>)} </>)}
</div> </div>
<div style={{flex:1,position:"relative",overflow:"hidden"}}>
<div style={{position:"absolute",inset:0,overflowY:"auto",zIndex:1,pointerEvents:mode==="text"?"auto":"none"}}> {/* Image toolbar — appears when an image is selected */}
<div ref={editorRef} contentEditable={mode==="text"} suppressContentEditableWarning onInput={onHtmlChange} {selectedImg&&mode==="text"&&<div data-imgtb="1" style={{display:"flex",alignItems:"center",justifyContent:"center",gap:6,padding:"6px 10px",background:T.toolbar,borderBottom:`1px solid ${T.border}`}}>
style={{padding:"20px 24px",outline:"none",fontSize:15,lineHeight:1.7,color:T.text,minHeight:"50%",fontFamily:"'Newsreader','Georgia',serif"}}/> <span style={{fontSize:11,color:T.muted}}>Size:</span>
{mode==="text"&&<TaskList tasks={parsed.tasks} onChange={onTasksChange} dark={dark}/>} {[25,33,50,75,100].map(p=><button key={p} onClick={()=>setImgSize(p)} style={{padding:"3px 8px",borderRadius:6,border:`1px solid ${T.border}`,background:T.bg,color:T.text,fontSize:11,cursor:"pointer",fontWeight:500}}>{p}%</button>)}
<div style={{width:1,height:16,background:T.border}}/>
<button onClick={deleteImg} style={{padding:"3px 8px",borderRadius:6,border:"none",background:"#e5534b22",color:"#e5534b",fontSize:11,cursor:"pointer",fontWeight:600}}>Delete</button>
<span style={{fontSize:10,color:T.faint,marginLeft:2}}>· drag right edge to resize</span>
</div>}
{/* Content area */}
<div ref={stageRef} style={{flex:1,position:"relative",overflow:"hidden"}}>
<div 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,minHeight:stageSize.h,margin:isMobileViewport?"0 auto":"0 0 0 28px",background:T.bg,border:isMobileViewport?"none":`1px solid ${T.border}`,borderRadius:isMobileViewport?0:14,boxShadow:isMobileViewport?"none":"0 16px 36px rgba(0,0,0,0.14)"}}>
<div ref={editorRef} contentEditable={mode==="text"} suppressContentEditableWarning onInput={onHtmlChange}
style={{padding:"20px 24px",outline:"none",fontSize:15,lineHeight:1.7,color:T.text,minHeight:stageSize.h,width:"100%",fontFamily:"'Newsreader','Georgia',serif",pointerEvents:mode==="text"?"auto":"none"}}/>
{note.drawingData&&!hideDrawing&&<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}}/>}
{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}}/>}
{mode==="text"&&<TaskList tasks={parsed.tasks} onChange={onTasksChange} dark={dark}/>}
</div>
</div> </div>
{mode==="text"&&note.drawingData&&<img src={note.drawingData} alt="" style={{position:"absolute",inset:0,width:"100%",height:"100%",pointerEvents:"none",zIndex:2,objectFit:"fill"}}/>}
{mode==="draw"&&<canvas ref={canvasRef} onMouseDown={startDraw} onMouseMove={doDraw} onMouseUp={stopDraw} onMouseLeave={stopDraw} onTouchStart={startDraw} onTouchMove={doDraw} onTouchEnd={stopDraw} style={{position:"absolute",inset:0,width:"100%",height:"100%",touchAction:"none",cursor:"crosshair",zIndex:3,background:"transparent"}}/>}
</div> </div>
</div>); </div>);
} }
/* ═══════ NOTEBOOK COVER ═══════ */ /* ═══════ NOTEBOOK COVER ═══════ */
function NotebookCover({notebook,noteCount,onClick,onContextMenu,isActive,isDragOver,draggable,onDragStart,onDragEnd,onDragOver,onDrop}){ function NotebookCover({notebook,noteCount,onClick,onContextMenu,isActive,isDragOver,draggable,onDragStart,onDragEnd,onDragOver,onDrop}){
const cover=COVERS[notebook.cover%COVERS.length];const [hovered,setHovered]=useState(false); const cover=COVERS[notebook.cover%COVERS.length];const [hovered,setHovered]=useState(false);
@@ -416,6 +671,7 @@ export default function NoteApp(){
const [noteContents,setNoteContents]=useState({}); const [noteContents,setNoteContents]=useState({});
const [isMobile,setIsMobile]=useState(window.innerWidth<768); const [isMobile,setIsMobile]=useState(window.innerWidth<768);
const [mobileView,setMobileView]=useState("list"); // list | editor const [mobileView,setMobileView]=useState("list"); // list | editor
const [installPrompt,setInstallPrompt]=useState(null);
const T=useMemo(()=>dark?{ const T=useMemo(()=>dark?{
bg:"#0d1117",sidebar:"#010409",sidebarActive:"#161b22",card:"#161b22",toolbar:"#161b22",border:"#30363d",borderLight:"#21262d",text:"#e6edf3",textSec:"#c9d1d9",muted:"#8b949e",faint:"#484f58",accent:"#388bfd",accentSoft:"#388bfd22",accentText:"#58a6ff",danger:"#f85149",success:"#3fb950",warning:"#d29922",inputBg:"#0d1117",dashBg:"#0d1117", bg:"#0d1117",sidebar:"#010409",sidebarActive:"#161b22",card:"#161b22",toolbar:"#161b22",border:"#30363d",borderLight:"#21262d",text:"#e6edf3",textSec:"#c9d1d9",muted:"#8b949e",faint:"#484f58",accent:"#388bfd",accentSoft:"#388bfd22",accentText:"#58a6ff",danger:"#f85149",success:"#3fb950",warning:"#d29922",inputBg:"#0d1117",dashBg:"#0d1117",
@@ -426,6 +682,14 @@ export default function NoteApp(){
// ── Responsive ── // ── Responsive ──
useEffect(()=>{const h=()=>{const m=window.innerWidth<768;setIsMobile(m);if(!m)setSidebarOpen(true);};window.addEventListener("resize",h);return()=>window.removeEventListener("resize",h);},[]); useEffect(()=>{const h=()=>{const m=window.innerWidth<768;setIsMobile(m);if(!m)setSidebarOpen(true);};window.addEventListener("resize",h);return()=>window.removeEventListener("resize",h);},[]);
// ── PWA install prompt ──
useEffect(()=>{
const h=(e)=>{e.preventDefault();setInstallPrompt(e);};
window.addEventListener("beforeinstallprompt",h);
return()=>window.removeEventListener("beforeinstallprompt",h);
},[]);
const handleInstall=async()=>{if(!installPrompt)return;installPrompt.prompt();const r=await installPrompt.userChoice;if(r.outcome==="accepted")setInstallPrompt(null);};
// ── Sync status listener ── // ── Sync status listener ──
useEffect(()=>sync.onSyncStatus(setSyncStatus),[]); useEffect(()=>sync.onSyncStatus(setSyncStatus),[]);
@@ -433,6 +697,7 @@ export default function NoteApp(){
useEffect(()=>sync.onDataChange(async()=>{ useEffect(()=>sync.onDataChange(async()=>{
setNotebooks(await sync.getNotebooks()); setNotebooks(await sync.getNotebooks());
setNotes(await sync.getNotes()); setNotes(await sync.getNotes());
// Keep loaded note content cached to avoid editor flicker/stalls on periodic sync
}),[]); }),[]);
// ── Startup: restore session → load local data → sync ── // ── Startup: restore session → load local data → sync ──
@@ -474,13 +739,48 @@ export default function NoteApp(){
const currentNoteMeta=activeNote?notes.find(n=>n.id===activeNote):null; const currentNoteMeta=activeNote?notes.find(n=>n.id===activeNote):null;
const currentNoteContent=activeNote?noteContents[activeNote]:null; const currentNoteContent=activeNote?noteContents[activeNote]:null;
const contentLoaded=!!currentNoteContent; const activeNoteHasContent=!!currentNoteContent;
const currentNote=currentNoteMeta&&currentNoteContent?{...currentNoteMeta,...currentNoteContent}:null; const currentNote=currentNoteMeta&&currentNoteContent?{...currentNoteMeta,...currentNoteContent}:null;
const currentNoteSafe=currentNote||(
currentNoteMeta
? {...currentNoteMeta,content:currentNoteContent?.content||"",drawingData:currentNoteContent?.drawingData||null}
: null
);
// Load note content on demand (from IndexedDB → instant, or server if cache miss) // Load note content on demand — fetches server-first on cache miss (page load = all cache empty)
useEffect(()=>{if(activeNote&&!noteContents[activeNote]){ useEffect(()=>{
sync.loadNoteContent(activeNote).then(c=>setNoteContents(p=>({...p,[activeNote]:c}))); if(!activeNote || activeNoteHasContent) return;
}},[activeNote]); let cancelled=false;
sync.loadNoteContent(activeNote)
.then(c=>{ if(!cancelled) setNoteContents(p=>({...p,[activeNote]:c})); })
.catch(()=>{ if(!cancelled) setNoteContents(p=>({...p,[activeNote]:{content:"",drawingData:null}})); });
return ()=>{cancelled=true;};
},[activeNote,activeNoteHasContent]);
// Hard fallback: never stay in loading state forever.
useEffect(()=>{
if(!activeNote || activeNoteHasContent) return;
const t=setTimeout(()=>{
setNoteContents(p=>p[activeNote]?p:{...p,[activeNote]:{content:"",drawingData:null}});
},5000);
return ()=>clearTimeout(t);
},[activeNote,activeNoteHasContent]);
// Keep currently open note content fresh across devices.
useEffect(()=>{
if(!user||!activeNote)return;
const i=setInterval(()=>{
if(!sync.isOnline())return;
sync.loadNoteContent(activeNote,{forceRemote:true}).then(c=>{
setNoteContents(p=>{
const prev=p[activeNote];
if(prev&&prev.content===c.content&&prev.drawingData===c.drawingData)return p;
return {...p,[activeNote]:c};
});
}).catch(()=>{});
},6000);
return()=>clearInterval(i);
},[user,activeNote]);
// ── CRUD — all go through sync engine ── // ── CRUD — all go through sync engine ──
const createNote=async()=>{ const createNote=async()=>{
@@ -550,7 +850,27 @@ export default function NoteApp(){
useEffect(()=>{const l=document.createElement("link");l.href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap";l.rel="stylesheet";document.head.appendChild(l);},[]); useEffect(()=>{const l=document.createElement("link");l.href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap";l.rel="stylesheet";document.head.appendChild(l);},[]);
// ── Periodic sync (every 30s if online) ── // ── Periodic sync (every 30s if online) ──
useEffect(()=>{const i=setInterval(()=>{if(sync.isOnline())sync.flushQueue().catch(()=>{});},30000);return()=>clearInterval(i);},[]); useEffect(()=>{const i=setInterval(()=>{if(sync.isOnline())sync.fullSync().then(()=>reloadLocal()).catch(()=>{});},30000);return()=>clearInterval(i);},[]);
// ── History API — browser/phone back button ──
const navStateRef=useRef({folder:"dashboard",note:null,view:"list"});
useEffect(()=>{
const cur=navStateRef.current;
if(cur.folder!==activeFolder||cur.note!==activeNote||cur.view!==mobileView){
const state={folder:activeFolder,note:activeNote,view:mobileView};
try{window.history.pushState(state,"");}catch{}
navStateRef.current=state;
}
},[activeFolder,activeNote,mobileView]);
useEffect(()=>{
const handler=(e)=>{
const s=e.state;
if(s){setActiveFolder(s.folder||"dashboard");setActiveNote(s.note||null);if(isMobile)setMobileView(s.view||"list");}
else{if(activeNote){setActiveNote(null);if(isMobile)setMobileView("list");}else if(activeFolder!=="dashboard"){setActiveFolder("dashboard");setActiveNote(null);}}
};
window.addEventListener("popstate",handler);return()=>window.removeEventListener("popstate",handler);
},[activeNote,activeFolder,isMobile]);
if(loading)return<div style={{display:"flex",alignItems:"center",justifyContent:"center",height:"100vh",background:dark?"#0d1117":"#f6f8fa",color:dark?"#e6edf3":"#1f2328",fontFamily:"'DM Sans',sans-serif",fontSize:16}}>Loading...</div>; if(loading)return<div style={{display:"flex",alignItems:"center",justifyContent:"center",height:"100vh",background:dark?"#0d1117":"#f6f8fa",color:dark?"#e6edf3":"#1f2328",fontFamily:"'DM Sans',sans-serif",fontSize:16}}>Loading...</div>;
if(!user)return<AuthScreen onLogin={onLogin} dark={dark}/>; if(!user)return<AuthScreen onLogin={onLogin} dark={dark}/>;
@@ -612,6 +932,8 @@ export default function NoteApp(){
))} ))}
</div> </div>
<div style={{marginTop:"auto",padding:"12px 14px",borderTop:`1px solid ${T.borderLight}`}}> <div style={{marginTop:"auto",padding:"12px 14px",borderTop:`1px solid ${T.borderLight}`}}>
{installPrompt&&<button onClick={handleInstall} style={{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"8px 12px",borderRadius:10,border:`1px solid ${T.accent}44`,background:T.accentSoft,cursor:"pointer",color:T.accent,fontSize:12,fontWeight:600,marginBottom:8}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Install App</button>}
<div style={{display:"flex",alignItems:"center",gap:6,color:T.muted,fontSize:12}}> <div style={{display:"flex",alignItems:"center",gap:6,color:T.muted,fontSize:12}}>
<span style={{color:syncStatus==="synced"?T.success:syncStatus==="syncing"?T.warning:syncStatus==="offline"?"#8b949e":syncStatus==="pending"?T.warning:T.danger}}>●</span> <span style={{color:syncStatus==="synced"?T.success:syncStatus==="syncing"?T.warning:syncStatus==="offline"?"#8b949e":syncStatus==="pending"?T.warning:T.danger}}>●</span>
<span>{syncStatus==="synced"?"All synced":syncStatus==="syncing"?"Syncing...":syncStatus==="offline"?"Offline mode":syncStatus==="pending"?"Changes pending":"Sync error"}</span> <span>{syncStatus==="synced"?"All synced":syncStatus==="syncing"?"Syncing...":syncStatus==="offline"?"Offline mode":syncStatus==="pending"?"Changes pending":"Sync error"}</span>
@@ -710,13 +1032,7 @@ export default function NoteApp(){
const renderEditor=()=>{ const renderEditor=()=>{
// On mobile, hide editor when list is shown // On mobile, hide editor when list is shown
if(isMobile&&mobileView!=="editor")return null; if(isMobile&&mobileView!=="editor")return null;
if(!currentNote&&currentNoteMeta)return( if(!currentNoteSafe)return isMobile?null:(
<div style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",background:T.bg,color:T.faint,fontFamily:"'DM Sans',sans-serif",gap:10}}>
{isMobile&&<div style={{alignSelf:"flex-start",padding:"8px 12px"}}>{backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})}</div>}
<div style={{fontSize:14,color:T.muted}}>Loading note...</div>
</div>
);
if(!currentNote)return isMobile?null:(
<div style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",background:T.bg,color:T.faint,fontFamily:"'DM Sans',sans-serif",gap:14}}> <div style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",background:T.bg,color:T.faint,fontFamily:"'DM Sans',sans-serif",gap:14}}>
<div style={{width:72,height:72,borderRadius:20,background:T.accentSoft,display:"flex",alignItems:"center",justifyContent:"center",fontSize:32}}>✏️</div> <div style={{width:72,height:72,borderRadius:20,background:T.accentSoft,display:"flex",alignItems:"center",justifyContent:"center",fontSize:32}}>✏️</div>
<div style={{fontSize:15,fontWeight:500,color:T.muted}}>Select a note to edit</div> <div style={{fontSize:15,fontWeight:500,color:T.muted}}>Select a note to edit</div>
@@ -738,13 +1054,13 @@ export default function NoteApp(){
<div style={{flex:1,display:"flex",flexDirection:"column",background:T.bg,fontFamily:"'DM Sans',sans-serif"}}> <div style={{flex:1,display:"flex",flexDirection:"column",background:T.bg,fontFamily:"'DM Sans',sans-serif"}}>
<div style={{display:"flex",alignItems:"center",gap:8,padding:"8px 16px",borderBottom:`1px solid ${T.borderLight}`}}> <div style={{display:"flex",alignItems:"center",gap:8,padding:"8px 16px",borderBottom:`1px solid ${T.borderLight}`}}>
{isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})} {isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})}
<input type="text" value={currentNote.title} onChange={e=>updateNote(currentNote.id,{title:e.target.value})} <input type="text" value={currentNoteSafe.title} onChange={e=>updateNote(currentNoteSafe.id,{title:e.target.value})}
style={{flex:1,border:"none",background:"transparent",outline:"none",fontSize:isMobile?15:17,fontWeight:600,color:T.text,fontFamily:"'Newsreader',Georgia,serif",minWidth:0}}/> style={{flex:1,border:"none",background:"transparent",outline:"none",fontSize:isMobile?15:17,fontWeight:600,color:T.text,fontFamily:"'Newsreader',Georgia,serif",minWidth:0}}/>
<button onClick={()=>toggleStar(currentNote.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:currentNote.starred?"#f0b429":T.faint}}>{currentNote.starred?I.starFill:I.star}</button> <button onClick={()=>toggleStar(currentNoteSafe.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:currentNoteSafe.starred?"#f0b429":T.faint}}>{currentNoteSafe.starred?I.starFill:I.star}</button>
<button onClick={()=>trashNoteAction(currentNote.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.danger}}>{I.trash}</button> <button onClick={()=>trashNoteAction(currentNoteSafe.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.danger}}>{I.trash}</button>
</div> </div>
<div style={{flex:1,overflow:"hidden"}}> <div style={{flex:1,overflow:"hidden"}}>
<NoteEditor key={currentNote.id} note={currentNote} onUpdate={u=>updateNote(currentNote.id,u)} dark={dark}/> <NoteEditor key={currentNoteSafe.id} note={currentNoteSafe} onUpdate={u=>updateNote(currentNoteSafe.id,u)} dark={dark}/>
</div> </div>
</div> </div>
); );
+9 -23
View File
@@ -2,13 +2,10 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import NoteApp from './NoteApp.jsx' import NoteApp from './NoteApp.jsx'
// Hide splash screen after app renders // Hide splash screen
const hideSplash = () => { const hideSplash = () => {
const splash = document.getElementById('splash'); const splash = document.getElementById('splash');
if (splash) { if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 300); }
splash.classList.add('hide');
setTimeout(() => splash.remove(), 300);
}
}; };
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
@@ -17,29 +14,18 @@ ReactDOM.createRoot(document.getElementById('root')).render(
</React.StrictMode> </React.StrictMode>
); );
// Hide splash after a short delay (let React mount)
setTimeout(hideSplash, 200); setTimeout(hideSplash, 200);
// ── Register Service Worker ── // Register service worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => { window.addEventListener('load', async () => {
try { try {
const reg = await navigator.serviceWorker.register('./sw.js', { scope: '/' }); // Use the page's base URL as scope so it works behind any reverse proxy path
console.log('[SW] Registered:', reg.scope); const swUrl = new URL('./sw.js', window.location.href).href;
const scope = new URL('./', window.location.href).href;
// Check for updates periodically const reg = await navigator.serviceWorker.register(swUrl, { scope });
setInterval(() => reg.update(), 60 * 60 * 1000); // hourly console.log('[SW] Registered, scope:', reg.scope);
setInterval(() => reg.update(), 60 * 60 * 1000);
// Prompt refresh on new version
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'activated' && navigator.serviceWorker.controller) {
// New version available — could show a toast here
console.log('[SW] New version available');
}
});
});
} catch (err) { } catch (err) {
console.warn('[SW] Registration failed:', err); console.warn('[SW] Registration failed:', err);
} }
+46 -10
View File
@@ -6,6 +6,7 @@
*/ */
import * as db from './offlineStore.js'; import * as db from './offlineStore.js';
const REQUEST_TIMEOUT_MS = 12000;
const DEVICE_ID = (() => { const DEVICE_ID = (() => {
try { try {
@@ -20,14 +21,27 @@ const api = {
base: '/api', base: '/api',
token: null, token: null,
async req(method, path, body) { async req(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Device-Id': DEVICE_ID } }; const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Device-Id': DEVICE_ID }, signal: controller.signal };
if (this.token) opts.headers['Authorization'] = 'Bearer ' + this.token; if (this.token) opts.headers['Authorization'] = 'Bearer ' + this.token;
if (body) opts.body = JSON.stringify(body); if (body) opts.body = JSON.stringify(body);
const r = await fetch(this.base + path, opts); try {
if (r.status === 401) { this.token = null; try { localStorage.removeItem('inknote_token'); } catch {} throw new Error('AUTH'); } let r;
const data = await r.json(); try {
if (!r.ok) throw new Error(data.error || 'Request failed'); r = await fetch(this.base + path, opts);
return data; } catch (err) {
if (err?.name === 'AbortError') throw new Error('TIMEOUT');
throw new Error('NETWORK');
}
if (r.status === 401) { this.token = null; try { localStorage.removeItem('inknote_token'); } catch {} throw new Error('AUTH'); }
let data = {};
try { data = await r.json(); } catch {}
if (!r.ok) throw new Error(data.error || 'Request failed');
return data;
} finally {
clearTimeout(timeoutId);
}
}, },
get(p) { return this.req('GET', p); }, get(p) { return this.req('GET', p); },
post(p, b) { return this.req('POST', p, b); }, post(p, b) { return this.req('POST', p, b); },
@@ -234,12 +248,34 @@ export async function reorderNotebooks(orderedIds) {
export async function getNotes() { return db.getAllNotes(); } export async function getNotes() { return db.getAllNotes(); }
export async function loadNoteContent(id) { export async function loadNoteContent(id, opts = {}) {
const forceRemote = !!opts.forceRemote;
// Local-first for instant editor open and resilience against slow/hung requests.
if (!forceRemote) {
const local = await db.getNoteContent(id);
if (local) {
if (_online && api.token) {
api.get(`/notes/${id}`).then(async (d) => {
const c = { content: d.content || '', drawingData: d.drawingData || null };
await db.putNoteContent(id, c.content, c.drawingData);
}).catch(() => {});
}
return { content: local.content || '', drawingData: local.drawingData || null };
}
}
// No local cache (or force remote): fetch server, then fallback to cache.
if (_online && api.token) {
try {
const d = await api.get(`/notes/${id}`);
const c = { content: d.content || '', drawingData: d.drawingData || null };
await db.putNoteContent(id, c.content, c.drawingData);
return c;
} catch {}
}
const local = await db.getNoteContent(id); const local = await db.getNoteContent(id);
if (local) return { content: local.content || '', drawingData: local.drawingData || null }; if (local) return { content: local.content || '', drawingData: local.drawingData || null };
if (_online && api.token) {
try { const d = await api.get(`/notes/${id}`); const c = { content: d.content || '', drawingData: null }; await db.putNoteContent(id, c.content, c.drawingData); return c; } catch {}
}
return { content: '', drawingData: null }; return { content: '', drawingData: null };
} }