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
+544 -144

No files matched your search

+373 -57
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";
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"&&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>);
}
/* ═══════ 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&&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)
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&&currentNoteMeta)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>
);
}
}