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:
1 parent
a8779d7be1
commit
d97591b242
7 files changed
+544
-144
No files matched your search
+47
-5
@@ -136,6 +136,18 @@ function decrypt(data) {
|
||||
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 ─────────── */
|
||||
function mergeVectorClocks(clockA, clockB) {
|
||||
const merged = { ...clockA };
|
||||
@@ -313,6 +325,20 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
if (!note) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.png`);
|
||||
const base64Data = drawingData.replace(/^data:image\/\w+;base64,/, "");
|
||||
if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingData required" });
|
||||
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"));
|
||||
|
||||
db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1 WHERE id = ?")
|
||||
.run(filePath, req.params.id);
|
||||
db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE 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 });
|
||||
});
|
||||
@@ -537,4 +579,4 @@ server.listen(PORT, () => {
|
||||
console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`);
|
||||
console.log(` 🔒 JWT Secret: ${JWT_SECRET.slice(0, 12)}...`);
|
||||
console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}\n`);
|
||||
});
|
||||
});
|
||||
+8
-7
@@ -4,26 +4,26 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Allow large drawing data + body size
|
||||
client_max_body_size 50m;
|
||||
|
||||
# PWA service worker — MUST not be cached by browser/proxy
|
||||
# PWA service worker — never cache
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
add_header Service-Worker-Allowed "/";
|
||||
add_header Content-Type "application/javascript";
|
||||
types { application/javascript js; }
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# PWA manifest
|
||||
location = /manifest.json {
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header Content-Type "application/manifest+json";
|
||||
add_header Cache-Control "no-cache";
|
||||
types { application/manifest+json json; }
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Static assets — long cache with content hash from Vite
|
||||
# Vite hashed assets — immutable cache
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
@@ -35,8 +35,9 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback — all other routes serve index.html
|
||||
# SPA fallback
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,39 @@
|
||||
"name": "InkNote",
|
||||
"short_name": "InkNote",
|
||||
"description": "Write & draw — notes that sync everywhere",
|
||||
"start_url": ".",
|
||||
"scope": ".",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"id": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1117",
|
||||
"theme_color": "#0d1117",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"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
|
||||
}
|
||||
+33
-37
@@ -1,66 +1,62 @@
|
||||
const CACHE_NAME = 'inknote-v2';
|
||||
const CACHE_NAME = 'inknote-v6';
|
||||
|
||||
// Install — cache app shell on first load
|
||||
self.addEventListener('install', (e) => {
|
||||
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); }
|
||||
}
|
||||
})
|
||||
);
|
||||
// Install — don't precache, just activate immediately
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate — clean old caches
|
||||
// Activate — clean old caches, take control
|
||||
self.addEventListener('activate', (e) => {
|
||||
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) => {
|
||||
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;
|
||||
|
||||
// Skip non-GET requests
|
||||
// Only handle GET requests
|
||||
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') {
|
||||
e.respondWith(
|
||||
fetch(e.request)
|
||||
.then(r => { const c = r.clone(); caches.open(CACHE_NAME).then(cache => cache.put('/', c)); return r; })
|
||||
.catch(() => caches.match('/'))
|
||||
fetch(e.request).then(r => {
|
||||
const clone = r.clone();
|
||||
caches.open(CACHE_NAME).then(c => c.put(e.request, clone));
|
||||
return r;
|
||||
}).catch(() => caches.match(e.request).then(r => r || caches.match('/')))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// JS/CSS/images — stale-while-revalidate
|
||||
// Static assets — stale-while-revalidate
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(cached => {
|
||||
const fetchP = fetch(e.request).then(r => {
|
||||
if (r.ok && r.type === 'basic') {
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(e.request, r.clone()));
|
||||
}
|
||||
return r;
|
||||
}).catch(() => cached);
|
||||
return cached || fetchP;
|
||||
})
|
||||
caches.open(CACHE_NAME).then(cache =>
|
||||
cache.match(e.request).then(cached => {
|
||||
const fetchP = fetch(e.request).then(r => {
|
||||
if (r.ok) cache.put(e.request, r.clone());
|
||||
return r;
|
||||
}).catch(() => cached);
|
||||
return cached || fetchP;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync
|
||||
// Background sync trigger
|
||||
self.addEventListener('sync', (e) => {
|
||||
if (e.tag === 'sync-changes') {
|
||||
e.waitUntil(self.clients.matchAll().then(clients => {
|
||||
clients.forEach(c => c.postMessage({ type: 'FLUSH_QUEUE' }));
|
||||
}));
|
||||
e.waitUntil(
|
||||
self.clients.matchAll().then(clients =>
|
||||
clients.forEach(c => c.postMessage({ type: 'FLUSH_QUEUE' }))
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+373
-57
@@ -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";
|
||||
|
||||
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 [isDrawing,setIsDrawing]=useState(false);
|
||||
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 [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 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(()=>{
|
||||
if(mode!=="draw")return;const c=canvasRef.current;if(!c)return;
|
||||
const p=c.parentElement;c.width=p.clientWidth;c.height=p.clientHeight;
|
||||
const el=editorRef.current;
|
||||
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;
|
||||
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]);
|
||||
|
||||
/* 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 onHtmlChange=()=>{if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);};
|
||||
const onTasksChange=(tasks)=>{saveContent(parsed.html,tasks);};
|
||||
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 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}}>
|
||||
{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}}/>
|
||||
{mode==="text"?(<>
|
||||
<button style={tbtn(false)} onClick={()=>exec("bold")}>{I.bold}</button>
|
||||
<button style={tbtn(false)} onClick={()=>exec("italic")}>{I.italic}</button>
|
||||
<button style={tbtn(false)} onClick={()=>exec("underline")}>{I.underline}</button>
|
||||
<button style={tb()} onClick={()=>exec("bold")}>{I.bold}</button>
|
||||
<button style={tb()} onClick={()=>exec("italic")}>{I.italic}</button>
|
||||
<button style={tb()} onClick={()=>exec("underline")}>{I.underline}</button>
|
||||
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
|
||||
<button style={tbtn(false)} onClick={()=>exec("insertUnorderedList")}>{I.list}</button>
|
||||
<button style={tbtn(false)} 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()} onClick={()=>exec("insertUnorderedList")}>{I.list}</button>
|
||||
<button style={tb()} onClick={()=>exec("insertOrderedList")}>{I.listOrd}</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"}}/>
|
||||
<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>
|
||||
</select>
|
||||
</>):(<>
|
||||
<button style={tbtn(tool==="pen")} onClick={()=>setTool("pen")}>{I.pen}</button>
|
||||
<button style={tbtn(tool==="highlighter")} onClick={()=>setTool("highlighter")}>{I.highlight}</button>
|
||||
<button style={tbtn(tool==="eraser")} onClick={()=>setTool("eraser")}>{I.eraser}</button>
|
||||
<button style={tb(tool==="pen")} onClick={()=>setTool("pen")}>{I.pen}</button>
|
||||
<button style={tb(tool==="highlighter")} onClick={()=>setTool("highlighter")}>{I.highlight}</button>
|
||||
<button style={tb(tool==="eraser")} onClick={()=>setTool("eraser")}>{I.eraser}</button>
|
||||
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
|
||||
<button style={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={{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)"}}>
|
||||
{COLORS.map(c=><button key={c} onClick={()=>{setPenColor(c);setShowColors(false);}} style={{width:26,height:26,borderRadius:"50%",background:c,border:penColor===c?`3px solid ${T.active}`:`1px solid ${T.border}`,cursor:"pointer"}}/>)}
|
||||
</div>}
|
||||
</div>
|
||||
<div style={{position:"relative"}}>
|
||||
<button style={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)"}}>
|
||||
{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>}
|
||||
</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 style={{flex:1,position:"relative",overflow:"hidden"}}>
|
||||
<div style={{position:"absolute",inset:0,overflowY:"auto",zIndex:1,pointerEvents:mode==="text"?"auto":"none"}}>
|
||||
<div ref={editorRef} contentEditable={mode==="text"} suppressContentEditableWarning onInput={onHtmlChange}
|
||||
style={{padding:"20px 24px",outline:"none",fontSize:15,lineHeight:1.7,color:T.text,minHeight:"50%",fontFamily:"'Newsreader','Georgia',serif"}}/>
|
||||
{mode==="text"&&<TaskList tasks={parsed.tasks} onChange={onTasksChange} dark={dark}/>}
|
||||
|
||||
{/* Image toolbar — appears when an image is selected */}
|
||||
{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}`}}>
|
||||
<span style={{fontSize:11,color:T.muted}}>Size:</span>
|
||||
{[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>
|
||||
{mode==="text"&¬e.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>);
|
||||
}
|
||||
|
||||
/* ═══════ NOTEBOOK COVER ═══════ */
|
||||
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);
|
||||
@@ -416,6 +671,7 @@ export default function NoteApp(){
|
||||
const [noteContents,setNoteContents]=useState({});
|
||||
const [isMobile,setIsMobile]=useState(window.innerWidth<768);
|
||||
const [mobileView,setMobileView]=useState("list"); // list | editor
|
||||
const [installPrompt,setInstallPrompt]=useState(null);
|
||||
|
||||
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",
|
||||
@@ -426,6 +682,14 @@ export default function NoteApp(){
|
||||
// ── Responsive ──
|
||||
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 ──
|
||||
useEffect(()=>sync.onSyncStatus(setSyncStatus),[]);
|
||||
|
||||
@@ -433,6 +697,7 @@ export default function NoteApp(){
|
||||
useEffect(()=>sync.onDataChange(async()=>{
|
||||
setNotebooks(await sync.getNotebooks());
|
||||
setNotes(await sync.getNotes());
|
||||
// Keep loaded note content cached to avoid editor flicker/stalls on periodic 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 currentNoteContent=activeNote?noteContents[activeNote]:null;
|
||||
const contentLoaded=!!currentNoteContent;
|
||||
const activeNoteHasContent=!!currentNoteContent;
|
||||
const currentNote=currentNoteMeta&¤tNoteContent?{...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)
|
||||
useEffect(()=>{if(activeNote&&!noteContents[activeNote]){
|
||||
sync.loadNoteContent(activeNote).then(c=>setNoteContents(p=>({...p,[activeNote]:c})));
|
||||
}},[activeNote]);
|
||||
// Load note content on demand — fetches server-first on cache miss (page load = all cache empty)
|
||||
useEffect(()=>{
|
||||
if(!activeNote || activeNoteHasContent) return;
|
||||
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 ──
|
||||
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);},[]);
|
||||
|
||||
// ── 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(!user)return<AuthScreen onLogin={onLogin} dark={dark}/>;
|
||||
@@ -612,6 +932,8 @@ export default function NoteApp(){
|
||||
))}
|
||||
</div>
|
||||
<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}}>
|
||||
<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>
|
||||
@@ -710,13 +1032,7 @@ export default function NoteApp(){
|
||||
const renderEditor=()=>{
|
||||
// On mobile, hide editor when list is shown
|
||||
if(isMobile&&mobileView!=="editor")return null;
|
||||
if(!currentNote&¤tNoteMeta)return(
|
||||
<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:(
|
||||
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:14}}>
|
||||
<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>
|
||||
@@ -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={{display:"flex",alignItems:"center",gap:8,padding:"8px 16px",borderBottom:`1px solid ${T.borderLight}`}}>
|
||||
{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}}/>
|
||||
<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={()=>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={()=>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(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 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>
|
||||
);
|
||||
@@ -775,4 +1091,4 @@ export default function NoteApp(){
|
||||
{ctxMenu&&<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={()=>setCtxMenu(null)} dark={dark}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
-23
@@ -2,13 +2,10 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import NoteApp from './NoteApp.jsx'
|
||||
|
||||
// Hide splash screen after app renders
|
||||
// Hide splash screen
|
||||
const hideSplash = () => {
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
splash.classList.add('hide');
|
||||
setTimeout(() => splash.remove(), 300);
|
||||
}
|
||||
if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 300); }
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
@@ -17,29 +14,18 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// Hide splash after a short delay (let React mount)
|
||||
setTimeout(hideSplash, 200);
|
||||
|
||||
// ── Register Service Worker ──
|
||||
// Register service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', async () => {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('./sw.js', { scope: '/' });
|
||||
console.log('[SW] Registered:', reg.scope);
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => reg.update(), 60 * 60 * 1000); // hourly
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
// Use the page's base URL as scope so it works behind any reverse proxy path
|
||||
const swUrl = new URL('./sw.js', window.location.href).href;
|
||||
const scope = new URL('./', window.location.href).href;
|
||||
const reg = await navigator.serviceWorker.register(swUrl, { scope });
|
||||
console.log('[SW] Registered, scope:', reg.scope);
|
||||
setInterval(() => reg.update(), 60 * 60 * 1000);
|
||||
} catch (err) {
|
||||
console.warn('[SW] Registration failed:', err);
|
||||
}
|
||||
|
||||
+47
-11
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import * as db from './offlineStore.js';
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
|
||||
const DEVICE_ID = (() => {
|
||||
try {
|
||||
@@ -20,14 +21,27 @@ const api = {
|
||||
base: '/api',
|
||||
token: null,
|
||||
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 (body) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(this.base + path, opts);
|
||||
if (r.status === 401) { this.token = null; try { localStorage.removeItem('inknote_token'); } catch {} throw new Error('AUTH'); }
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
try {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(this.base + path, opts);
|
||||
} 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); },
|
||||
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 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);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -330,4 +366,4 @@ export async function emptyTrashLocal() {
|
||||
else { await db.enqueue({ action: 'emptyTrash', data: {} }); notifyStatus('pending'); }
|
||||
}
|
||||
|
||||
export { DEVICE_ID };
|
||||
export { DEVICE_ID };
|
||||
Reference in new issue
Block a user