const { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback, Fragment } = React; /* ---------- hover helpers (issue #49) ---------- * Nearly every control in this file is inline-styled, and an inline * background beats any stylesheet :hover rule — so hover has to be applied * from JS. These two builders keep every surface on the same theme-derived * highlight (see hoverBg in utils.jsx) instead of the hand-rolled rgba each * call site used to invent, which is how the terminal theme ended up with a * hover nobody could see. * hoverProps(T, idleBg) — controls on a themed panel (menus, drawer, chrome) * inkHoverProps(ink, o) — controls sitting on a note's own paper colour, * where the theme tokens don't apply: notes are * light in every theme, so their own ink is the * right thing to darken with. * Spread them AFTER any other mouse handlers on the element, or they clobber * each other — none of the current call sites have their own. */ function hoverProps(T, idleBg = 'transparent', alpha) { return { onMouseEnter: (e) => { e.currentTarget.style.background = hoverBg(T, alpha); }, onMouseLeave: (e) => { e.currentTarget.style.background = idleBg; }, }; } function inkHoverProps(ink, idleOpacity = 0.65) { return { onMouseEnter: (e) => { e.currentTarget.style.background = withA(ink, .14); e.currentTarget.style.opacity = 1; }, onMouseLeave: (e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.opacity = idleOpacity; }, }; } function Loading() { return (
Loading…
); } function UpdateBanner({ info, onDismiss }) { const open = () => { if (window.stickyAPI && window.stickyAPI.openExternal) { window.stickyAPI.openExternal(info.url); } else { window.open(info.url, '_blank', 'noopener'); } }; return (
New version v{info.version} available
); } /* ==================================================================== */ /* PASTE ERROR TOAST */ /* ==================================================================== */ // Transient banner shown at top of the canvas when Ctrl+V is pressed but // the clipboard text doesn't contain a sticky-notes payload (or contains // one with malformed JSON). Auto-dismisses after 5s; can be closed early // with the × button. Without this, paste failures were completely silent // — the user pressed Ctrl+V and nothing happened, with no clue why. function PasteErrorToast({ message, onClose }) { if (!message) return null; return (
{message}
); } /* ==================================================================== */ /* INFO DIALOG */ /* ==================================================================== */ // In-app modal for short informational popups (Help → About, Help → Check // for Updates result). Replaces the previous native dialog.showMessageBox // path because native dialogs render as garbled glyphs inside snap // confinement (font/sandbox issue). HTML modal works the same in every // build channel and matches the app's aesthetic. function InfoDialog({ info, onClose }) { useEffect(() => { if (!info) return; const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [info, onClose]); if (!info) return null; return (
e.stopPropagation()} style={{ background:'#fbf7ef', color:'#2a241a', border:'1px solid #d8cfbc', borderRadius:8, boxShadow:'0 10px 40px rgba(0,0,0,.25)', padding:'20px 24px', minWidth:320, maxWidth:480, fontFamily:'Inter, system-ui, sans-serif', }}> {info.title && (
{info.title}
)}
{info.message}
{info.detail && (
{info.detail}
)}
); } /* ==================================================================== */ /* IMPORT-FROM-IMAGE HELP DIALOG */ /* ==================================================================== */ // Shown by File → "Import notes from image using your AI…". Surfaces a prompt the // user copies into ChatGPT/Claude/Gemini along with an image of sticky // notes. The LLM returns text in the app's existing clipboard format and // the user pastes here (Ctrl+V), hitting the standard paste handler. This // feature is intentionally "bring your own LLM" — no network calls from // the app itself. const IMPORT_FROM_IMAGE_PROMPT = `You are given an image of sticky notes (either a photo of physical notes or a screenshot from another app). Your task: extract every visible note, then output ONE block of text matching the format below — nothing else. The output has three sections, in order: 1. Human-readable preview. Each note rendered as: <body> Notes separated by a line containing only: --- 2. A blank line, then this literal marker on its own line: <!-- sticky-notes/v1 --> 3. One line of minified JSON with shape: {"notes":[ ... ],"links":[ ... ]} Each note in the JSON has these fields: - "id": short string unique within the payload (e.g. "n1","n2"); used only to wire links and is remapped on paste. - "title": string. Short heading. If the note has no obvious title, infer one from its first line. - "body": string. Remaining content. Use "\\n" between lines. Markdown supported: # headings (all levels), - or * bullet lists, numbered lists, **bold**, *italic* or _italic_, \`inline code\`, fenced code blocks, blockquotes, tables, [links](url) and images (http/https only), \`\`\`mermaid diagrams. - "color": one of "red","pink","blue","green","yellow","peach","lilac","white". Match the note's real colour in the image; omit the field if you can't tell and one is picked for you. - "w": integer pixel width. Use 260 by default; use ~300 for notes with wide/long lines. - "h": integer pixel height. Use 180 by default; use 220–280 for notes with lots of text. - "pinned": false "links" is an empty array unless the image clearly shows arrows/lines connecting notes; if it does, add entries like {"from":"n1","to":"n2"} using the note ids you assigned above. </format> <example label="two plain notes"> Groceries - eggs - milk - bread --- Call mom <!-- sticky-notes/v1 --> {"notes":[{"id":"n1","title":"Groceries","body":"- eggs\\n- milk\\n- bread","color":"yellow","w":260,"h":200,"pinned":false},{"id":"n2","title":"Call mom","body":"","color":"pink","w":260,"h":180,"pinned":false}],"links":[]} </example> <example label="single note with markdown body"> Project ideas # Q1 priorities - ship the **flatpak** release - write \`import-from-image\` doc - bump to v1.4.0 tag <!-- sticky-notes/v1 --> {"notes":[{"id":"n1","title":"Project ideas","body":"# Q1 priorities\\n\\n- ship the **flatpak** release\\n- write \`import-from-image\` doc\\n- bump to v1.4.0 tag","w":300,"h":260,"pinned":false}],"links":[]} </example> <rules> The importer is a strict JSON parser. Any of these and the import fails — the app will either show an error or paste your reply as one plain note instead of the notes you extracted: - Single quotes anywhere in the JSON (use double quotes only). - Trailing commas in the JSON (e.g., \`[{...},]\` or \`{...,}\`). - Real newline characters inside a JSON string value (escape as \\n). - Wrapping the output in code fences (\`\`\`), or prefixing it with "json", or adding any language tag. - Preamble, explanation, or commentary before, between, or after the block. The \`<format>\`, \`<example>\`, and \`<rules>\` tags above are for your understanding only — do NOT include them in your output. Output ONLY the three-section block. </rules>`; function ImportFromImageDialog({ open, onClose }) { const [copied, setCopied] = useState(false); const textareaRef = useRef(null); const isMac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || ''); const pasteShortcut = isMac ? '⌘V' : 'Ctrl+V'; useEffect(() => { if (!open) return; setCopied(false); const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]); if (!open) return null; const doCopy = async () => { try { await navigator.clipboard.writeText(IMPORT_FROM_IMAGE_PROMPT); setCopied(true); setTimeout(() => setCopied(false), 1600); } catch { // Clipboard API can reject (e.g. permissions); fall back to // selecting the textarea so the user can Ctrl+C manually. const ta = textareaRef.current; if (ta) { ta.focus(); ta.select(); } } }; return ( <div onClick={onClose} style={{ position:'fixed', inset:0, background:'rgba(20,20,18,.5)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:30000, }}> <div onClick={(e)=>e.stopPropagation()} style={{ background:'#fbf7ef', color:'#2a241a', border:'1px solid #d8cfbc', borderRadius:8, boxShadow:'0 10px 40px rgba(0,0,0,.25)', padding:'20px 24px', width:'min(640px, 92vw)', maxHeight:'86vh', display:'flex', flexDirection:'column', fontFamily:'Inter, system-ui, sans-serif', }}> <div style={{ fontSize:11, fontWeight:600, color:'#7a6f5b', marginBottom:8, textTransform:'uppercase', letterSpacing:.5, }}>Import from image</div> <div style={{fontSize:15, fontWeight:600, marginBottom:10}}> Import notes from a photo or screenshot using your AI </div> <div style={{ fontSize:12, color:'#7a6f5b', lineHeight:1.45, marginBottom:12, }}> Paste this prompt into ChatGPT, Claude, or Gemini with your image, then copy the response and {pasteShortcut} here. </div> <textarea ref={textareaRef} readOnly value={IMPORT_FROM_IMAGE_PROMPT} onFocus={(e) => e.target.select()} style={{ flex:'1 1 auto', minHeight:180, maxHeight:'42vh', width:'100%', resize:'vertical', fontFamily:'"JetBrains Mono", ui-monospace, monospace', fontSize:12, lineHeight:1.45, background:'#fffdf7', color:'#2a241a', border:'1px solid #d8cfbc', borderRadius:6, padding:'10px 12px', marginBottom:14, boxSizing:'border-box', }} /> <div style={{ fontSize:13, color:'#6b4a1f', lineHeight:1.5, marginBottom:14, background:'#fdf3d8', border:'1px solid #ecd9a6', borderRadius:6, padding:'9px 12px', }}> <strong>Tip:</strong> if the output looks wrong or invented, the model probably isn't strong enough to read your image. Try a more capable model (Claude Opus/Sonnet, GPT-4o, Gemini 2.5 Pro). </div> <div style={{display:'flex', justifyContent:'flex-end', gap:8}}> <button onClick={onClose} onMouseEnter={e=>{ e.currentTarget.style.background='rgba(90,74,58,.12)'; }} onMouseLeave={e=>{ e.currentTarget.style.background='transparent'; }} style={{ background:'transparent', color:'#5a4a3a', border:'1px solid #d8cfbc', borderRadius:6, padding:'8px 14px', fontSize:13, fontWeight:600, cursor:'pointer', transition:'background .1s', }}>Close</button> <button onClick={doCopy} autoFocus onMouseEnter={e=>{ e.currentTarget.style.background = copied ? '#3d8657' : '#c2603f'; }} onMouseLeave={e=>{ e.currentTarget.style.background = copied ? '#4c9e6b' : '#d97757'; }} style={{ background: copied ? '#4c9e6b' : '#d97757', color:'#fff', border:'none', borderRadius:6, padding:'8px 18px', fontSize:13, fontWeight:600, cursor:'pointer', minWidth:130, transition:'background .1s', }}>{copied ? 'Copied!' : 'Copy prompt'}</button> </div> </div> </div> ); } /* ==================================================================== */ /* MOBILE DEMO BANNER */ /* ==================================================================== */ // A thin "web demo — download the native app" strip that only shows on // narrow viewports (phones). Hidden entirely in the Electron desktop build // (stickyAPI is the bridge exposed by preload.js), and dismissible per // session with the close state persisted to localStorage so it stays // dismissed across reloads. const MOBILE_BANNER_DISMISSED_KEY = 'stickies.mobileBannerDismissed'; const MOBILE_BANNER_MAX_WIDTH = 640; function MobileDemoBanner() { // Electron build: never show. The preload script exposes window.stickyAPI, // which is the same signal the rest of the app uses to gate desktop-only // behavior (see the browser/Electron branching in useStickyStore). if (typeof window !== 'undefined' && window.stickyAPI) return null; const [narrow, setNarrow] = useState( () => typeof window !== 'undefined' && window.innerWidth <= MOBILE_BANNER_MAX_WIDTH ); const [dismissed, setDismissed] = useState(() => { try { return localStorage.getItem(MOBILE_BANNER_DISMISSED_KEY) === '1'; } catch { return false; } }); useEffect(() => { const onResize = () => setNarrow(window.innerWidth <= MOBILE_BANNER_MAX_WIDTH); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); if (!narrow || dismissed) return null; const onDismiss = () => { try { localStorage.setItem(MOBILE_BANNER_DISMISSED_KEY, '1'); } catch {} setDismissed(true); }; return ( <div style={{ flex:'0 0 auto', height:38, width:'100%', display:'flex', alignItems:'center', gap:10, padding:'0 12px', // Warm, slightly darker than the paper wallpaper so it reads as a // system notice without fighting the app's aesthetic. background:'#ede4d1', color:'#3a2f1a', borderBottom:'1px solid #d8cfbc', fontFamily:'Inter, system-ui, sans-serif', fontSize:12, zIndex:20001, }}> <span style={{flex:1, minWidth:0, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}}> Web demo — full app runs natively on Linux & Mac </span> <a href="https://github.com/faridjaff/StickyNotesCanvas/releases" target="_blank" rel="noopener noreferrer" style={{ color:'#d97757', fontWeight:600, textDecoration:'none', whiteSpace:'nowrap', }} > Download → </a> <button onClick={onDismiss} aria-label="Dismiss" style={{ background:'transparent', border:'none', color:'#7a6f5b', cursor:'pointer', fontSize:18, lineHeight:1, padding:'0 4px', }} >×</button> </div> ); } /* ==================================================================== */ /* TOP CHROME */ /* ==================================================================== */ function TopChrome({T, tweaks, currentFolderName, query, setQuery, onNewNote, onNewFolder, onExport, onImport}) { const isTerm = tweaks.theme==='terminal'; const [backupOpen, setBackupOpen] = useState(false); // Narrow-viewport detection, used to hide the "Sticky Notes" wordmark on // phones where vertical room is scarce. Tracks resizes so rotating the // device (or opening devtools on desktop) toggles the wordmark back. // Follows the same pattern and threshold as MobileDemoBanner. const [narrow, setNarrow] = useState( () => typeof window !== 'undefined' && !window.stickyAPI && window.innerWidth <= MOBILE_BANNER_MAX_WIDTH ); useEffect(() => { if (typeof window === 'undefined' || window.stickyAPI) return; const onResize = () => setNarrow(window.innerWidth <= MOBILE_BANNER_MAX_WIDTH); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); useEffect(() => { if (!backupOpen) return; const close = (e) => { if (e.target.closest('[data-backup-menu]')) return; setBackupOpen(false); }; const id = setTimeout(() => window.addEventListener('mousedown', close), 0); return () => { clearTimeout(id); window.removeEventListener('mousedown', close); }; }, [backupOpen]); return ( <div style={{ height:54, background:T.panelBg, borderBottom:`1px solid ${T.panelBorder}`, display:'flex', alignItems:'center', gap:12, padding:'0 14px', position:'relative', zIndex:20000, color:T.panelText, }}> <AppGlyph T={T} isTerm={isTerm}/> <div style={{fontWeight:600, fontSize:14, letterSpacing:isTerm?0.5:0, display: narrow?'none':undefined}}> {isTerm ? 'stickies' : 'Sticky Notes'} </div> <div style={{width:1, height:22, background:T.panelBorder, margin:'0 8px', display: narrow?'none':undefined}}/> <div dir="auto" style={{fontSize:13, color:T.panelText, opacity:.85, fontWeight:500}}> {currentFolderName} </div> <div style={{flex:1}}/> <div dir={firstStrongDir(query)} style={{position:'relative'}}> <input id="qs" dir="auto" value={query} onChange={e=>setQuery(e.target.value)} placeholder={isTerm?'grep…':'Search notes'} style={{ width:220, height:30, borderRadius: isTerm?2:8, border:`1px solid ${T.panelBorder}`, background: T.inputBg, color:T.panelText, paddingBlock:0, paddingInlineStart:30, paddingInlineEnd:12, fontSize:13, outline:'none', fontFamily: 'inherit', }}/> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{position:'absolute', insetInlineStart:10, top:8, opacity:.5}}> <circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2"/> <path d="M20 20l-3.5-3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/> </svg> </div> <div data-backup-menu style={{position:'relative', display: narrow?'none':undefined}}> <button onClick={()=>setBackupOpen(o=>!o)} title="Save or restore a backup" style={{ height:30, padding:'0 12px', borderRadius: isTerm?2:8, background:'#000', color:'#fff', border:`1px solid ${T.panelBorder}`, fontWeight:500, fontSize:13, cursor:'pointer', display:'flex', alignItems:'center', gap:6, }}> {isTerm?'backup':'Backup'} <span style={{fontSize:9, opacity:.7, marginTop:1}}>▾</span> </button> {backupOpen && ( <div data-backup-menu style={{ position:'absolute', top:36, right:0, minWidth:160, zIndex:30000, background:T.panelBg, border:`1px solid ${T.panelBorder}`, borderRadius: isTerm?2:8, boxShadow:'0 8px 22px rgba(0,0,0,.15)', padding:4, fontFamily:'inherit', }}> <button onClick={()=>{setBackupOpen(false); onExport && onExport();}} style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px', background:'transparent', border:'none', color:T.panelText, fontSize:13, cursor:'pointer', borderRadius: isTerm?2:6, }} {...hoverProps(T)}> Save backup… </button> <button onClick={()=>{setBackupOpen(false); onImport && onImport();}} style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px', background:'transparent', border:'none', color:T.panelText, fontSize:13, cursor:'pointer', borderRadius: isTerm?2:6, }} {...hoverProps(T)}> Restore backup… </button> </div> )} </div> </div> ); } function AppGlyph({T, isTerm}) { if (isTerm) return <div style={{width:22,height:22, background:T.inputBg, color:T.accent, border:`1px solid ${T.panelBorder}`, display:'grid', placeItems:'center', fontFamily:T.bodyFont, fontSize:12, fontWeight:700, marginLeft:4}}>_</div>; return <div style={{position:'relative', width:22, height:22, marginLeft:4}}> <div style={{position:'absolute', inset:0, background:'#fde8a1', borderRadius:4, transform:'rotate(-6deg)', boxShadow:'0 2px 4px rgba(0,0,0,.1)'}}/> <div style={{position:'absolute', inset:0, background:'#b6dbf5', borderRadius:4, transform:'rotate(5deg) translate(4px,1px)', boxShadow:'0 2px 4px rgba(0,0,0,.1)'}}/> </div>; } function FolderIcon({size=14, color="#000", open=false, fill=null}) { if (open) return ( <svg width={size*1.2} height={size} viewBox="0 0 24 20" fill="none"> <path d="M2 5a2 2 0 0 1 2-2h5l2 2h9a2 2 0 0 1 2 2v2H2V5z" fill={fill||color} opacity={fill?1:.2} stroke={color} strokeWidth="1.5"/> <path d="M2 9h20l-2 9a2 2 0 0 1-2 1.5H4a2 2 0 0 1-2-1.5L2 9z" fill={fill||color} opacity={fill?.85:.35} stroke={color} strokeWidth="1.5"/> </svg> ); return ( <svg width={size*1.2} height={size} viewBox="0 0 24 20" fill="none"> <path d="M2 5a2 2 0 0 1 2-2h5l2 2h9a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5z" fill={fill||color} opacity={fill?1:.2} stroke={color} strokeWidth="1.5"/> </svg> ); } function HomeIcon({size=14, color="#000"}) { return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2"> <path d="M3 11l9-7 9 7v10a1 1 0 0 1-1 1h-5v-7h-6v7H4a1 1 0 0 1-1-1V11z"/> </svg>; } /* ==================================================================== */ /* FOLDER TREE (sidebar) */ /* ==================================================================== */ function FolderTree({T, folders, notes, currentFolder, setCurrentFolder, onCreateFolder, onRename, onDelete, renamingFolder, setRenamingFolder, onDropNoteOnFolder}) { // Flat list: root first (as "All notes"), then all real folders alpha const flatList = useMemo(() => { const real = Object.values(folders).filter(f => f.id !== 'root').sort((a,b)=>a.name.localeCompare(b.name)); return real; }, [folders]); const Row = ({f, isAll}) => { const isActive = currentFolder===f.id; const [over, setOver] = useState(false); const count = isAll ? notes.length : notes.filter(n=>n.folder===f.id).length; return ( <div onDragOver={e=>{e.preventDefault(); setOver(true);}} onDragLeave={()=>setOver(false)} onDrop={(e)=>{ setOver(false); const nid = e.dataTransfer.getData('note-id'); if (nid && !isAll) onDropNoteOnFolder(nid, f.id); }} onClick={()=>setCurrentFolder(f.id)} onDoubleClick={()=>!isAll && setRenamingFolder(f.id)} style={{ display:'flex', alignItems:'center', gap:8, padding:'7px 10px', borderRadius:6, background: isActive ? withA(isAll?T.accent:f.hue, .18) : over ? withA(T.accent, .18) : 'transparent', color: T.panelText, fontSize:13, cursor:'pointer', marginBottom:2, outline: over ? `1px dashed ${T.accent}` : 'none', }}> {isAll ? <HomeIcon size={14} color={T.panelText}/> : <FolderIcon size={14} color={f.hue} fill={f.hue} open={isActive}/>} {(!isAll && renamingFolder===f.id) ? ( <input autoFocus defaultValue={f.name} dir="auto" onClick={e=>e.stopPropagation()} onBlur={e=>{ onRename(f.id, e.target.value||f.name); setRenamingFolder(null); }} onKeyDown={e=>{ if(e.key==='Enter'){onRename(f.id, e.target.value||f.name); setRenamingFolder(null);} if(e.key==='Escape'){setRenamingFolder(null);}}} style={{flex:1, background:'transparent', border:'none', outline:'none', color:T.panelText, fontSize:13, font:'inherit', fontWeight: isActive?600:500}} /> ) : ( <span dir="auto" style={{flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontWeight: isActive?600:500}}> {isAll ? 'All notes' : f.name} </span> )} <span style={{fontSize:11, color:T.muted, fontVariantNumeric:'tabular-nums'}}> {count} </span> </div> ); }; return ( <div style={{ position:'absolute', left:0, top:54, bottom:28, width:220, background:T.panelBg, borderRight:`1px solid ${T.panelBorder}`, padding:'12px 10px', zIndex:15000, overflow:'auto', color:T.panelText, }}> <Row f={{id:'root', name:'All notes'}} isAll/> <div style={{fontSize:11, textTransform:'uppercase', letterSpacing:1, opacity:.5, padding:'16px 10px 8px', display:'flex', alignItems:'center'}}> Folders <div style={{flex:1}}/> <button onClick={()=>onCreateFolder()} title="New folder" style={{ background:'transparent', border:'none', cursor:'pointer', color:T.panelText, opacity:.6, fontSize:16, padding:0, lineHeight:1, }}>+</button> </div> {flatList.map(f => <Row key={f.id} f={f}/>)} <div style={{fontSize:11, textTransform:'uppercase', letterSpacing:1, opacity:.5, padding:'18px 10px 8px'}}>Shortcuts</div> <KeyHint T={T} keys={['N']} label="New note"/> <KeyHint T={T} keys={['⌘','F']} label="Search"/> <KeyHint T={T} keys={['Esc']} label="Deselect"/> <KeyHint T={T} keys={['Drag']} label="Move note to folder"/> <div style={{fontSize:11, textTransform:'uppercase', letterSpacing:1, opacity:.5, padding:'18px 10px 8px'}}>Stats</div> <div style={{padding:'0 10px', fontSize:12, color:T.muted, lineHeight:1.7}}> <div>{notes.length} notes · {flatList.length} folders</div> <div>{notes.filter(n=>n.pinned).length} pinned</div> </div> </div> ); } function KeyHint({T, keys, label}) { return <div style={{display:'flex', alignItems:'center', gap:8, padding:'5px 10px', fontSize:12, color:T.muted}}> <div style={{display:'flex', gap:3}}> {keys.map(k => <kbd key={k} style={{ fontFamily:'ui-monospace, monospace', fontSize:10, padding:'2px 5px', background:'rgba(0,0,0,.05)', border:`1px solid ${T.panelBorder}`, borderRadius:3, color:T.panelText, }}>{k}</kbd>)} </div> <span>{label}</span> </div>; } /* ==================================================================== */ /* DESKTOP (canvas with folder tiles + sticky notes) */ /* ==================================================================== */ function Desktop({T, tweaks, currentFolder, folders, folderOrder, notes, allNotes, noteRefs, linkLines, links, addLink, removeLink, linksFor, updateNote, bringToFront, bringGroupToFront, focusNote, onDeleteNote, selectedIds, setSelectedIds, setNotes, jumpToNote, moveNoteToFolder, moveNotesToFolder, onCreateNote, onImportMarkdown, onCopyNotes, onPasteNotes, view, setView, drawerOpen, takeSnapshot}) { // Tree-ordered folder list (with depth) for the per-note "Move to folder" // submenu, so nested folders appear indented under their parents there. const folderTreeRows = useMemo(() => flattenFolderTree(folders, folderOrder), [folders, folderOrder]); const [deskMenu, setDeskMenu] = useState(null); const [linkMenu, setLinkMenu] = useState(null); const [linkingFrom, setLinkingFrom] = useState(null); // note id when drawing a new link const [spaceHeld, setSpaceHeld] = useState(false); const [panning, setPanning] = useState(false); const [pinching, setPinching] = useState(false); const [marquee, setMarquee] = useState(null); // {startX, startY, curX, curY, shift} in world coords const panRef = useRef(null); const pinchRef = useRef(null); const deskRef = useRef(null); // Narrow-viewport detection for the desk's touch-action (see the desk's // style below). Matches the threshold used by MobileDemoBanner and the // other mobile gates so that Electron and desktop browsers are never // affected. const [narrow, setNarrow] = useState( () => typeof window !== 'undefined' && !window.stickyAPI && window.innerWidth <= MOBILE_BANNER_MAX_WIDTH ); useEffect(() => { if (typeof window === 'undefined' || window.stickyAPI) return; const onResize = () => setNarrow(window.innerWidth <= MOBILE_BANNER_MAX_WIDTH); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); // space bar toggles pan mode useEffect(() => { const down = (e) => { if (e.code==='Space' && !e.target.matches('input, textarea, [contenteditable]')) { // Claim every keydown of a held Space, not just the first. The OS // auto-repeat keeps sending more (e.repeat) until the key comes up, // and each one left to the browser does Space's default: it arms the // focused button, so the keyup clicks it (releasing Space after a pan // re-fired the last clicked toolbar button, #54), or it scrolls the // note body last clicked by a page. e.preventDefault(); if (!e.repeat) setSpaceHeld(true); } }; const up = (e) => { if (e.code==='Space') setSpaceHeld(false); }; window.addEventListener('keydown', down); window.addEventListener('keyup', up); return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); }; }, []); // convert screen coords (relative to desk) → world coords const toWorld = (sx, sy) => ({ x: (sx - view.x) / view.z, y: (sy - view.y) / view.z, }); const onWheel = (e) => { // Zoom is gated on Ctrl/Cmd+wheel only. Plain wheel passes through so // long note bodies (with overflow scroll) and the folders drawer can // scroll naturally instead of unexpectedly zooming the canvas. The // global wheel guard at the top of AppInner already preventDefaults // Ctrl/Cmd+wheel so the host browser's page-zoom doesn't fire. if (!(e.ctrlKey || e.metaKey)) return; if (e.target.matches('textarea, input, [contenteditable="true"]')) return; e.preventDefault(); const rect = deskRef.current.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; // Halved rate compared to the previous default (was 0.01) — single // mouse-wheel notches felt too aggressive, jumping multiple zoom levels. // Trackpad pinch (Ctrl+wheel synthesised) still feels responsive; mouse // wheels now step by a more controllable amount per notch. const factor = Math.exp(-e.deltaY * 0.005); setView(v => zoomViewAt(v, factor, mx, my)); }; const onMouseDown = (e) => { // Space+drag OR middle mouse = pan if (spaceHeld || e.button===1) { e.preventDefault(); setPanning(true); panRef.current = { sx: e.clientX, sy: e.clientY, vx: view.x, vy: view.y }; return; } // Plain left-drag on empty canvas = marquee selection if (e.button === 0 && (e.target.id==='desk' || e.target.id==='desk-inner' || e.target.id==='desk-grid')) { e.preventDefault(); // preventDefault keeps the browser from natively collapsing any text // highlight in a note body on this mousedown; collapse it explicitly // so a stale selection doesn't keep hijacking Ctrl+C after the user // has moved on to selecting notes (#30). const ws = typeof window.getSelection === 'function' ? window.getSelection() : null; if (ws && !ws.isCollapsed) ws.removeAllRanges(); const rect = deskRef.current.getBoundingClientRect(); const wx = (e.clientX - rect.left - view.x) / view.z; const wy = (e.clientY - rect.top - view.y) / view.z; setMarquee({ startX: wx, startY: wy, curX: wx, curY: wy, additive: e.ctrlKey || e.metaKey }); } }; // Touch gestures on the canvas. Two branches with different gates: // // 1-finger pan — works everywhere (#41; it used to be gated to the narrow // web demo, which left a one-finger drag in the desktop app to the // browser's own scroll of a desk that can't scroll, so nothing happened), // but only on the canvas background. Mirrors the "empty-canvas" target // filter used by the mouse marquee branch so a touch that lands on a // sticky note is passed through untouched (the note's own drag logic owns // that gesture). A drag never produced a marquee on touch — Chromium // synthesises mouse events for taps only — and a tap still does exactly // what it did: its synthesised click clears the selection. // // 2-finger pinch — works everywhere (Electron touchscreens, wide desktop // browsers, and the narrow mobile demo alike; issue #17) but ONLY when // BOTH fingers started on the desk background. Touches that begin on a // sticky note belong to the note's pointer-driven drag machinery (#18): // a finger per header on two different notes drags both notes at once, // and a second finger landing on empty canvas mid-drag ends that drag in // place (startPointerDrag's pointerdown listener) — in neither case may // the pinch hijack the gesture, so mere touches.length === 2 is not // enough to engage it. Touch.target is the element each touch STARTED // on (it never retargets mid-gesture), which is exactly the gate we // need. Editable text fields also opt out, matching onWheel, so a pinch // landing in an open editor doesn't fight text selection/scroll. // // Pan and pinch are mutually exclusive: pan only starts on exactly 1 // finger, pinch only starts on exactly 2. When pinch is active, the // pan-touchmove effect short-circuits (panning is false), and vice versa. const onTouchStart = (e) => { if (e.touches.length === 1) { if (!(e.target.id==='desk' || e.target.id==='desk-inner' || e.target.id==='desk-grid')) return; const t = e.touches[0]; setPanning(true); panRef.current = { sx: t.clientX, sy: t.clientY, vx: view.x, vy: view.y }; return; } if (e.touches.length === 2) { if (activePointerDrags > 0) return; // a note is being dragged — never zoom under it // Desk background = not inside any sticky note (headers, footers and // resize corners are drag handles; the body is a text-selection // surface) and not an editable field. Anything else under the desk — // the grid, the link layer — is fair game for a pinch. const onDeskBackground = (t) => { const el = t.target; if (!el || typeof el.closest !== 'function') return false; if (el.closest('[data-note]')) return false; if (el.matches?.('textarea, input, [contenteditable="true"]')) return false; return true; }; if (!onDeskBackground(e.touches[0]) || !onDeskBackground(e.touches[1])) return; const t0 = e.touches[0], t1 = e.touches[1]; const dx = t1.clientX - t0.clientX; const dy = t1.clientY - t0.clientY; const d0 = Math.hypot(dx, dy); if (d0 === 0) return; const rect = deskRef.current.getBoundingClientRect(); // midpoint in screen (desk-relative) coords at pinch start const mx0 = ((t0.clientX + t1.clientX) / 2) - rect.left; const my0 = ((t0.clientY + t1.clientY) / 2) - rect.top; pinchRef.current = { d0, z0: view.z, vx0: view.x, vy0: view.y, mx0, my0, left: rect.left, top: rect.top }; setPinching(true); // If a 1-finger pan was in progress (user dropped a second finger // mid-drag), cancel it so the pan touchmove handler doesn't fight // the pinch handler. The user can start a fresh pan after lifting // both fingers. if (panning) { setPanning(false); panRef.current = null; } } }; useEffect(() => { if (!panning) return; const move = (e) => { const p = panRef.current; if (!p) return; setView(v => ({ ...v, x: p.vx + (e.clientX - p.sx), y: p.vy + (e.clientY - p.sy) })); }; const up = () => { setPanning(false); panRef.current = null; }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; }, [panning]); // Touch equivalent of the mouse pan effect above. Registered with // {passive: false} so preventDefault in touchmove reliably suppresses // the browser's default scroll/zoom gesture while the user is panning. // Both this and the mouse effect attach while `panning` is true; they // listen for disjoint event types (touchmove/end vs mousemove/up) so // they don't fight each other regardless of which input started the pan. useEffect(() => { if (!panning) return; const move = (e) => { const p = panRef.current; if (!p) return; if (!e.touches || e.touches.length === 0) return; e.preventDefault(); const t = e.touches[0]; setView(v => ({ ...v, x: p.vx + (t.clientX - p.sx), y: p.vy + (t.clientY - p.sy) })); }; const end = () => { setPanning(false); panRef.current = null; }; window.addEventListener('touchmove', move, { passive: false }); window.addEventListener('touchend', end); window.addEventListener('touchcancel', end); return () => { window.removeEventListener('touchmove', move, { passive: false }); window.removeEventListener('touchend', end); window.removeEventListener('touchcancel', end); }; }, [panning]); // Claim canvas touch gestures from their very first touchmove (#41). The // pan and pinch effects attach their {passive:false} listeners one React // commit after touchstart, but a touchscreen's first moves reach the // browser a millisecond or two after the finger lands, before any such // listener exists. Uncancelable, they let the browser take the drag as a // native scroll under the desk's pan-x pan-y touch-action (pointercancel, // and every later touchmove of the gesture uncancelable too). Registered // at mount, this listener makes the desk a blocking touch target before // any finger lands; it cancels only while onTouchStart has armed a pan or // pinch (synchronously, through the refs), so touch-scrolling a note body // is left alone. useEffect(() => { const desk = deskRef.current; if (!desk) return; const claim = (e) => { if (panRef.current || pinchRef.current) e.preventDefault(); }; desk.addEventListener('touchmove', claim, { passive: false }); return () => desk.removeEventListener('touchmove', claim, { passive: false }); }, []); // Two-finger pinch-to-zoom on the canvas (all platforms). Mirrors the pan // effect's structure (window-scoped {passive:false} listeners for the // duration of the gesture) but operates on pinchRef instead of panRef. // The world point beneath the fingers' midpoint follows the midpoint, so // one gesture pans and zooms at once, like a map (#41). When the finger // count drops below 2 the gesture ends; we do not transition into a pan — // a fresh touchstart is required for that. useEffect(() => { if (!pinching) return; const move = (e) => { const p = pinchRef.current; if (!p) return; if (!e.touches || e.touches.length < 2) return; e.preventDefault(); const t0 = e.touches[0], t1 = e.touches[1]; const dx = t1.clientX - t0.clientX; const dy = t1.clientY - t0.clientY; const d = Math.hypot(dx, dy); if (d === 0) return; // Pan and zoom at once (#41): the pinch-START view (z0, vx0, vy0) is // scaled about the start midpoint (mx0, my0) and that point is carried // to the CURRENT midpoint, so the world point that began between the // fingers stays between them wherever they travel (the whole gesture // is one absolute transform, not increments). const mx = (t0.clientX + t1.clientX) / 2 - p.left; const my = (t0.clientY + t1.clientY) / 2 - p.top; setView(() => pinchView({ x: p.vx0, y: p.vy0, z: p.z0 }, d / p.d0, { x: p.mx0, y: p.my0 }, { x: mx, y: my })); }; const end = (e) => { // End as soon as fewer than 2 fingers remain. Do NOT promote the // remaining finger into a pan — a new touchstart is required. if (e.touches && e.touches.length >= 2) return; setPinching(false); pinchRef.current = null; }; window.addEventListener('touchmove', move, { passive: false }); window.addEventListener('touchend', end); window.addEventListener('touchcancel', end); return () => { window.removeEventListener('touchmove', move, { passive: false }); window.removeEventListener('touchend', end); window.removeEventListener('touchcancel', end); }; }, [pinching]); // Safari-only trackpad pinch (web demo). Chromium and Firefox synthesise // Ctrl+wheel events for trackpad pinch, which onWheel already handles — // but Safari fires its non-standard gesturestart/gesturechange/gestureend // events instead, so without this the demo can't pinch-zoom in Safari at // all. Gated on GestureEvent existing (WebKit-only) so other engines never // attach the listeners and can't double-zoom. Applied incrementally // (e.scale ratio between successive events, via the setView functional // form) so the effect needs no view deps and can attach once. On iPad, // Safari fires BOTH touch events and gesture events for the same pinch; // the touch-pinch handler above wins there (it sets pinchRef on // touchstart, before gesturestart), and this handler stands down while // pinchRef is set so the gesture isn't applied twice. useEffect(() => { if (typeof window.GestureEvent === 'undefined') return; const desk = deskRef.current; if (!desk) return; let lastScale = 1; const start = (e) => { e.preventDefault(); // suppress Safari's own page zoom over the canvas lastScale = e.scale; }; const change = (e) => { e.preventDefault(); if (pinchRef.current) return; if (activePointerDrags > 0) return; // a note is being dragged — never zoom under it if (e.target.closest?.('[data-note]')) return; if (e.target.matches?.('textarea, input, [contenteditable="true"]')) return; const factor = e.scale / lastScale; lastScale = e.scale; const rect = desk.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; // Same clamp range and cursor-anchored formula as onWheel. setView(v => zoomViewAt(v, factor, mx, my)); }; const end = (e) => e.preventDefault(); desk.addEventListener('gesturestart', start); desk.addEventListener('gesturechange', change); desk.addEventListener('gestureend', end); return () => { desk.removeEventListener('gesturestart', start); desk.removeEventListener('gesturechange', change); desk.removeEventListener('gestureend', end); }; }, []); // Marquee drag: while active, track pointer in world coords; on release, resolve selection. useEffect(() => { if (!marquee) return; const rect = deskRef.current.getBoundingClientRect(); const move = (e) => { const wx = (e.clientX - rect.left - view.x) / view.z; const wy = (e.clientY - rect.top - view.y) / view.z; setMarquee(m => m ? { ...m, curX: wx, curY: wy } : m); }; const up = () => { setMarquee(m => { if (!m) return null; const dragged = Math.hypot(m.curX - m.startX, m.curY - m.startY) > 3; if (!dragged) { // Treat as plain click on empty canvas: clear selection (unless Ctrl/Cmd). if (!m.additive) setSelectedIds(new Set()); return null; } const x1 = Math.min(m.startX, m.curX); const y1 = Math.min(m.startY, m.curY); const x2 = Math.max(m.startX, m.curX); const y2 = Math.max(m.startY, m.curY); const base = m.additive ? new Set(selectedIds) : new Set(); notes.forEach(n => { if (n.x < x2 && n.x + n.w > x1 && n.y < y2 && n.y + n.h > y1) { if (m.additive && base.has(n.id)) base.delete(n.id); else base.add(n.id); } }); setSelectedIds(base); return null; }); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; }, [marquee, view.x, view.y, view.z, notes, selectedIds, setSelectedIds]); const resetView = () => setView({x:0, y:0, z:1}); // Zoom by a fixed step about the centre of the desk viewport — the anchor // for every zoom that has no pointer of its own (the +/− buttons and the // keyboard shortcuts), so the view scales around whatever the user is // already looking at instead of jumping. const zoomTo = (factor) => { const rect = deskRef.current.getBoundingClientRect(); setView(v => zoomViewAt(v, factor, rect.width/2, rect.height/2)); }; // Ctrl/Cmd+0: back to 100%, centre-anchored. Only the SCALE is reset — // the pan is deliberately left where it is (beyond the centre-preserving // offset the anchor implies), because the shortcut everywhere else in // desktop software means "actual size", not "go home". Teleporting the // canvas to the origin would lose the user's place; the desk's "Reset // view" button and its context-menu entry still do the full x/y/z reset. const zoomReset = () => { const rect = deskRef.current.getBoundingClientRect(); setView(v => zoomViewAt(v, 1 / v.z, rect.width/2, rect.height/2)); }; const fitToNotes = () => { if (!notes.length) { resetView(); return; } let minX=Infinity, minY=Infinity, maxX=-Infinity, maxY=-Infinity; notes.forEach(n => { minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); maxX = Math.max(maxX, n.x + n.w); maxY = Math.max(maxY, n.y + n.h); }); const rect = deskRef.current.getBoundingClientRect(); // Reserve space for the folders drawer (if open) so notes don't end up under it // Reserve space for the folders drawer (if open) so notes don't end up // under it. drawerOpen comes from the hoisted store state in the parent. const rightReserve = drawerOpen ? 320 : 0; // 300 width + 10 margin + gap const pad = 80; const availW = rect.width - rightReserve - pad*2; const availH = rect.height - pad*2; const bw = maxX - minX, bh = maxY - minY; const sx = availW / bw; const sy = availH / bh; const nz = Math.max(0.25, Math.min(1.5, Math.min(sx, sy))); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; // Center within the available (non-drawer) area const availCenterX = (rect.width - rightReserve) / 2; const availCenterY = rect.height / 2; setView({ x: availCenterX - cx*nz, y: availCenterY - cy*nz, z: nz, }); }; // Keyboard zoom (issue #45): Ctrl/Cmd with +/= zooms in, with -/_ zooms // out, with 0 returns to 100%. Same step as the on-screen +/− buttons, // same clamp as every other zoom path, anchored at the viewport centre // since a keystroke carries no cursor position. zoomActionForKey owns the // layout/numpad matching and the "user is typing" opt-out. // // These chords zoom the CANVAS only. Electron applies no page zoom of its // own here: the app menu (main.js buildMenu) deliberately defines no // zoomIn/zoomOut/resetZoom roles, and unlike Ctrl+wheel — which Chromium // handles internally, hence the wheel guard in AppInner — keyboard zoom is // a browser-UI shortcut that doesn't exist in Electron. Verified over CDP: // devicePixelRatio and innerWidth don't budge for any of these chords. The // preventDefault below is still worth having: it keeps the browser demo // (where Ctrl+- DOES zoom the page) from doubling up. useEffect(() => { const onKey = (e) => { const action = zoomActionForKey(e, document.activeElement); if (!action) return; e.preventDefault(); if (action === 'reset') zoomReset(); else zoomTo(action === 'in' ? 1.2 : 1/1.2); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); // Escape cancels link-drawing. Kept in its own effect so the listener // isn't torn down on every mousemove (which replaces linkingFrom). useEffect(() => { if (!linkingFrom) return; const onKey = (e) => { if (e.key === 'Escape') setLinkingFrom(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [!!linkingFrom]); // While in "linking" mode, track cursor and click-to-connect. useEffect(() => { if (!linkingFrom) return; // Ignore clicks that happen within the same tick as starting the mode // (so the button-click that initiated linking doesn't immediately cancel it) let armed = false; const armTimer = setTimeout(() => { armed = true; }, 50); const onMove = (e) => { const rect = deskRef.current.getBoundingClientRect(); const sx = e.clientX - rect.left, sy = e.clientY - rect.top; const world = { x:(sx-view.x)/view.z, y:(sy-view.y)/view.z }; setLinkingFrom(lf => lf ? { ...lf, x:world.x, y:world.y } : lf); }; const onClick = (e) => { if (!armed) return; const noteEl = e.target.closest('[data-note-id]'); if (noteEl) { const toId = noteEl.getAttribute('data-note-id'); if (toId && toId !== linkingFrom.id) { addLink(linkingFrom.id, toId); } } setLinkingFrom(null); }; window.addEventListener('mousemove', onMove); window.addEventListener('click', onClick, true); return () => { clearTimeout(armTimer); window.removeEventListener('mousemove', onMove); window.removeEventListener('click', onClick, true); }; }, [linkingFrom, view.x, view.y, view.z, addLink]); const cursor = panning ? 'grabbing' : (spaceHeld ? 'grab' : (linkingFrom ? 'crosshair' : 'default')); return ( <> {linkingFrom && ( <div style={{ position:'absolute', top:64, left:'50%', transform:'translateX(-50%)', background:T.panelBg, color:T.panelText, padding:'8px 16px', borderRadius:8, border:`1px solid ${T.panelBorder}`, fontSize:12, fontWeight:500, zIndex:25000, boxShadow:'0 4px 16px rgba(0,0,0,.18)', userSelect:'none', pointerEvents:'none', display:'flex', alignItems:'center', gap:10, }}> <span>Click another note to link</span> <span style={{opacity:.5}}>·</span> <kbd style={{ fontFamily:'ui-monospace, monospace', fontSize:11, padding:'2px 6px', background:'rgba(0,0,0,.06)', border:`1px solid ${T.panelBorder}`, borderRadius:4, }}>Esc</kbd> <span>to cancel</span> </div> )} <div id="desk" ref={deskRef} onContextMenu={(e)=>{ if (e.target.id==='desk' || e.target.id==='desk-inner' || e.target.id==='desk-grid') { e.preventDefault(); setDeskMenu({x:e.clientX, y:e.clientY}); }}} onClick={(e)=>{ if (e.target.id==='desk' || e.target.id==='desk-inner' || e.target.id==='desk-grid') setDeskMenu(null); }} onWheel={onWheel} onMouseDown={onMouseDown} onTouchStart={onTouchStart} style={{position:'absolute', left:0, right:0, top:54, bottom:28, // `clip`, not `hidden`: an overflow:hidden box is still a scroll // container (its descendants — the 4000×4000 link layer and any // far-flung notes — overflow it), so the browser will silently // scroll it to reveal a focused-but-offscreen element (e.g. a note's // autoFocus textarea on entering edit mode). That stray scrollTop is // invisible (no scrollbar) yet shifts the whole canvas out of sync // with the `view` transform, which breaks fit / pan / PageDown. // `overflow:clip` is not a scroll container, so scrollTop is pinned // to 0 and no focus/keyboard gesture can ever desync the canvas. // touch-action: mobile demo disables all browser touch gestures on // the desk ('none' — the app owns pan and pinch there). Wide // viewports keep pan-x/pan-y so touch-scrolling a long note body // still works, but drop the browser's own pinch-zoom: our two-finger // pinch handler attaches its {passive:false} touchmove listener one // React commit AFTER touchstart, and without this declaration the // browser could commit to its native pinch in that gap, making the // gesture's touchmoves non-cancelable and the canvas zoom janky. overflow:'clip', cursor, userSelect: panning?'none':'auto', touchAction: narrow?'none':'pan-x pan-y'}}> {/* faint grid — lives in screen space, scales with zoom */} <div id="desk-grid" style={{ position:'absolute', inset:0, pointerEvents:'none', backgroundImage:`radial-gradient(${withA(T.panelText,.07)} 1px, transparent 1px)`, backgroundSize:`${24*view.z}px ${24*view.z}px`, backgroundPosition:`${view.x}px ${view.y}px`, opacity: isDarkSurface(T.panelBg)?.3:.5, }}/> <div id="desk-inner" style={{ position:'absolute', inset:0, transform:`translate(${view.x}px, ${view.y}px) scale(${view.z})`, transformOrigin:'0 0', pointerEvents: panning ? 'none' : 'auto', }}> {/* Link layer */} {tweaks.showLinks && ( <svg style={{position:'absolute', left:0, top:0, pointerEvents:'none', width:4000, height:4000, overflow:'visible', zIndex:1}}> <defs> <marker id="arr" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto"> <path d="M0,0 L10,5 L0,10 z" fill={T.accent}/> </marker> </defs> {linkLines.map(l => { const x1 = l.x1, y1 = l.y1, x2 = l.x2, y2 = l.y2; const mx = (x1+x2)/2, my = (y1+y2)/2; return ( <g key={l.id} style={{pointerEvents:'auto', cursor:'pointer'}} onClick={(e)=>{ e.stopPropagation(); setLinkMenu({id:l.id, fromId:l.fromId, toId:l.toId, sx:e.clientX, sy:e.clientY}); }}> <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="transparent" strokeWidth="14"/> <line x1={x1} y1={y1} x2={x2} y2={y2} stroke={T.accent} strokeOpacity=".65" strokeWidth="1.75" strokeDasharray="5 4" markerEnd="url(#arr)"/> <circle cx={mx} cy={my} r="5" fill={T.panelBg} stroke={T.accent} strokeWidth="1.5"/> </g> ); })} {linkingFrom && (() => { const src = allNotes.find(n => n.id===linkingFrom.id); if (!src) return null; return ( <line x1={src.x+src.w/2} y1={src.y+src.h/2} x2={linkingFrom.x} y2={linkingFrom.y} stroke={T.accent} strokeOpacity=".8" strokeWidth="2" strokeDasharray="6 4" markerEnd="url(#arr)"/> ); })()} </svg> )} {/* Marquee selection rectangle (world coords) */} {marquee && Math.hypot(marquee.curX - marquee.startX, marquee.curY - marquee.startY) > 3 && ( <div style={{ position:'absolute', pointerEvents:'none', zIndex:5000, left: Math.min(marquee.startX, marquee.curX), top: Math.min(marquee.startY, marquee.curY), width: Math.abs(marquee.curX - marquee.startX), height: Math.abs(marquee.curY - marquee.startY), background: withA(T.accent, 0.10), border: `1px solid ${T.accent}`, borderRadius: 2, }}/> )} {/* Sticky notes */} {notes.map(n => ( <StickyNote key={n.id} note={n} T={T} tweaks={tweaks} folder={folders[n.folder]} refCb={(el)=>{ noteRefs.current[n.id] = el; }} selected={selectedIds.has(n.id)} selectedIds={selectedIds} setSelectedIds={setSelectedIds} setNotes={setNotes} bringGroupToFront={bringGroupToFront} onFocus={(e)=>{ if (e && (e.ctrlKey || e.metaKey)) { setSelectedIds(prev => { const next = new Set(prev); if (next.has(n.id)) next.delete(n.id); else next.add(n.id); return next; }); bringToFront(n.id); } else if (!selectedIds.has(n.id)) { focusNote(n.id); } else { bringToFront(n.id); // already part of selection — don't collapse it } }} onChange={(patch)=>updateNote(n.id, patch)} onSnapshot={takeSnapshot} onTogglePin={()=>{ takeSnapshot && takeSnapshot(); updateNote(n.id, {pinned: !n.pinned}); }} onDelete={()=>onDeleteNote(n.id)} onLinkClick={jumpToNote} childFolders={folderTreeRows.filter(r=>r.id!==n.folder).map(r=>({...folders[r.id], depth:r.depth}))} onMoveToFolder={(fid)=>moveNoteToFolder(n.id, fid)} zoom={view.z} allNotes={allNotes} linksFor={linksFor} onMoveNotesToFolder={moveNotesToFolder} onCopy={()=>onCopyNotes && onCopyNotes(n.id)} onPasteNotes={onPasteNotes} onAddLink={(toId)=>addLink(n.id, toId)} onStartLink={()=>setLinkingFrom({id:n.id, x:n.x+n.w/2, y:n.y+n.h/2})} onJumpToNote={jumpToNote} /> ))} </div> {/* Empty state — in screen space, not transformed */} {notes.length===0 && ( <EmptyState T={T} folderName={folders[currentFolder]?.name || 'All notes'} isRoot={currentFolder==='root'}/> )} {/* zoom controls */} <div style={{ position:'absolute', left:16, bottom:16, display:'flex', alignItems:'center', gap:2, background:T.panelBg, border:`1px solid ${T.panelBorder}`, borderRadius: tweaks.theme==='terminal'?2:8, padding:3, boxShadow:'0 2px 8px rgba(0,0,0,.08)', zIndex:500, fontFamily: '"'+tweaks.font+'", system-ui, sans-serif', }}> {/* These four had no hover feedback at all before issue #49. */} <button onClick={()=>zoomTo(1/1.2)} title="Zoom out (Ctrl −)" {...hoverProps(T)} style={zBtn(T)}>−</button> <button onClick={resetView} title="Reset view (Ctrl 0 resets the zoom)" {...hoverProps(T)} style={{ ...zBtn(T), width:'auto', padding:'0 10px', fontSize:11, fontVariantNumeric:'tabular-nums', fontWeight:600, }}>{Math.round(view.z*100)}%</button> <button onClick={()=>zoomTo(1.2)} title="Zoom in (Ctrl +)" {...hoverProps(T)} style={zBtn(T)}>+</button> <div style={{width:1, height:20, background:T.hairline, margin:'0 3px'}}/> <button onClick={fitToNotes} title="Fit all notes to view" {...hoverProps(T)} style={{...zBtn(T), width:'auto', padding:'0 8px', fontSize:11}}>fit</button> </div> {/* space-held indicator */} {spaceHeld && !panning && ( <div style={{ position:'absolute', left:'50%', bottom:16, transform:'translateX(-50%)', background:T.panelText, color:T.panelBg, padding:'6px 14px', borderRadius: tweaks.theme==='terminal'?2:999, fontSize:12, fontWeight:600, letterSpacing:.3, boxShadow:'0 4px 12px rgba(0,0,0,.2)', pointerEvents:'none', zIndex:500, fontFamily: '"'+tweaks.font+'", system-ui, sans-serif', }}>✋ drag to pan</div> )} {deskMenu && (() => { const rect = deskRef.current.getBoundingClientRect(); const sx = deskMenu.x - rect.left; const sy = deskMenu.y - rect.top; const world = toWorld(sx, sy); return ( <ContextMenu T={T} x={sx} y={sy} onClose={()=>setDeskMenu(null)} items={[ {label:'New note here', onClick:()=>{ onCreateNote(world.x, world.y); setDeskMenu(null); }}, // Importing a .md file makes a NOTE, so it belongs next to // "New note here" rather than in one note's own menu. The // picked files land like a paste does (near the viewport, // cascaded), not at this click — the dialog takes as long as // the user takes, and a multi-file pick needs the cascade. {label:'Import markdown file…', onClick:()=>{ onImportMarkdown && onImportMarkdown(); setDeskMenu(null); }}, {label:'Reset view', onClick:()=>{ resetView(); setDeskMenu(null); }}, ]}/> ); })()} {linkMenu && (() => { const rect = deskRef.current.getBoundingClientRect(); const from = allNotes.find(n=>n.id===linkMenu.fromId); const to = allNotes.find(n=>n.id===linkMenu.toId); return ( <ContextMenu T={T} x={linkMenu.sx-rect.left} y={linkMenu.sy-rect.top} onClose={()=>setLinkMenu(null)} items={[ {label: `→ Jump to "${to?.title || 'target'}"`, onClick:()=>{ jumpToNote(linkMenu.toId); setLinkMenu(null); }}, {label: `← Jump to "${from?.title || 'source'}"`, onClick:()=>{ jumpToNote(linkMenu.fromId); setLinkMenu(null); }}, {separator:true, divider:true}, {label: 'Delete link', destructive:true, onClick:()=>{ removeLink(linkMenu.id); setLinkMenu(null); }}, ]}/> ); })()} {/* linking banner */} {linkingFrom && ( <div style={{ position:'absolute', left:'50%', top:16, transform:'translateX(-50%)', background:T.accent, color:T.accentInk, padding:'7px 14px', borderRadius: tweaks.theme==='terminal'?2:999, fontSize:12, fontWeight:700, letterSpacing:.3, boxShadow:'0 4px 12px rgba(0,0,0,.2)', pointerEvents:'none', zIndex:500, fontFamily: '"'+tweaks.font+'", system-ui, sans-serif', }}>🔗 click a note to link · esc to cancel</div> )} </div> </> ); } const zBtn = (T) => ({ width:28, height:28, display:'grid', placeItems:'center', background:'transparent', color:T.panelText, border:'none', cursor:'pointer', fontSize:16, lineHeight:1, padding:0, borderRadius:4, }); function EmptyState({T, folderName, isRoot}) { return ( <div style={{position:'absolute', inset:0, display:'grid', placeItems:'center', pointerEvents:'none'}}> <div style={{textAlign:'center', color:T.muted, maxWidth:340}}> <div style={{fontSize:48, marginBottom:12, opacity:.6}}> {isRoot ? '🏠' : '📂'} </div> <div style={{fontSize:15, fontWeight:600, color:T.panelText, marginBottom:6}}> {isRoot ? 'Your desktop is empty' : `"${folderName}" is empty`} </div> <div style={{fontSize:13, lineHeight:1.55}}> Press <kbd style={kbdS(T)}>N</kbd> to add a sticky note, or use <b>New folder</b> to organize by topic. </div> </div> </div> ); } function kbdS(T) { return {fontFamily:'ui-monospace, monospace', fontSize:11, padding:'2px 6px', background:'rgba(0,0,0,.06)', border:`1px solid ${T.panelBorder}`, borderRadius:3}; } /* ==================================================================== */ /* FOLDER TILE (draggable on desktop) */ /* ==================================================================== */ /* STICKY NOTE */ /* ==================================================================== */ // Runs a window-scoped pointer-drag session for note move/resize. Compared // to raw pointermove listeners this (issue #18, touch dragging): // - filters events to the pointer that started the gesture — window-level // pointermove receives EVERY active pointer, so a stray second finger // (or palm) would otherwise teleport the note between two anchors; // - coalesces moves to one per animation frame — touch digitizers report // faster than the display refreshes, and re-rendering the canvas per // input sample (instead of per frame) is what reads as stutter; // - flushes the last pending move before ending so the release position // is never a frame behind the finger. // pointercancel is treated like pointerup: with touch-action:none on the // drag handles it should no longer fire mid-drag, but if the OS does claim // the gesture we end cleanly instead of leaving a stuck drag. // A second touch joining mid-drag ends the drag at its current position: // two fingers mean canvas pinch-zoom, not note dragging, and without this // the note would keep chasing finger 1 while the canvas zooms. // Live count of note drag/resize sessions. The zoom paths consult it: // while ANY note is being dragged the canvas must never zoom — WebKit fires // its gesture events on the fingers' common ANCESTOR (the desk itself when // one finger rides each of two notes), so target-based gating cannot see // multi-note drags. This invariant can. let activePointerDrags = 0; function startPointerDrag(e, onMove, onEnd) { const pid = e.pointerId, isTouch = e.pointerType === 'touch'; let raf = 0, last = e; activePointerDrags += 1; const finish = (ev) => { activePointerDrags = Math.max(0, activePointerDrags - 1); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end); window.removeEventListener('pointerdown', down); if (raf) { cancelAnimationFrame(raf); raf = 0; onMove(last); } if (onEnd) onEnd(ev); }; const move = (ev) => { if (ev.pointerId !== pid) return; last = ev; if (!raf) raf = requestAnimationFrame(() => { raf = 0; onMove(last); }); }; const end = (ev) => { if (ev.pointerId !== pid) return; finish(ev); }; const down = (ev) => { // finish with the drag pointer's last event, not the new finger's — // onEnd handlers read coordinates off it (drag-to-folder hit test). if (isTouch && ev.pointerType === 'touch' && ev.pointerId !== pid) finish(last); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end); window.addEventListener('pointerdown', down); } function StickyNote({note, T, tweaks, folder, refCb, selected, selectedIds, setSelectedIds, setNotes, bringGroupToFront, onFocus, onChange, onTogglePin, onDelete, onLinkClick, childFolders, onMoveToFolder, onMoveNotesToFolder, zoom=1, allNotes=[], linksFor, onAddLink, onStartLink, onJumpToNote, onCopy, onPasteNotes, onSnapshot}) { const zRef = useRef(zoom); zRef.current = zoom; const [editing, setEditing] = useState(false); const [editingTitle, setEditingTitle] = useState(false); const [menu, setMenu] = useState(null); const el = useRef(null); // Snapshot the title/body at the moment the user enters edit mode so that // pressing Escape reverts to what it was. The input stays controlled (live // onChange) — Escape just calls onChange with the snapshot and exits. const origTitleRef = useRef(''); const origBodyRef = useRef(''); const bodyBoxRef = useRef(null); useEffect(() => { if (editingTitle) origTitleRef.current = note.title; }, [editingTitle]); useEffect(() => { if (editing) origBodyRef.current = note.body; }, [editing]); // One undo step per editing session. Typing used to record nothing at // all, so Ctrl+Z reached back to whatever was snapshotted before the // session — creating a note, say — and swallowed that note along with // everything typed since. The snapshot is taken on the first keystroke, // not when the editor opens, so merely looking at a note costs nothing. const editDirtyRef = useRef(false); useEffect(() => { editDirtyRef.current = false; }, [editing, editingTitle]); const snapshotOnce = () => { if (editDirtyRef.current) return; editDirtyRef.current = true; onSnapshot && onSnapshot(); }; /* ---------- Pictures from a FILE: drag-and-drop and "Insert image…" ---------- * Pasting a picture already works (the textarea's onPaste, which hands the * clipboard to the main process). These two routes cover the other way * people have a picture — as a file. Both end at the same insert, and both * are written to fail soft: any failure warns to the console and leaves * the note byte-for-byte as it was. Nothing here may throw into React. */ // The body as of the latest render. The inserts below run after an await // (store the file, then insert), by which time this component's `note` // prop can already be stale. const bodyRef = useRef(note.body); bodyRef.current = note.body; // Where the caret was the last time this note was edited, remembered with // the body text it belonged to. Both gestures that insert a picture take // focus away first — dragging a file in comes from another window, and // opening the context menu blurs the textarea, which ends edit mode — so // by the time a reference is ready there is no live editor to ask. The // remembered position is only trusted while the body still matches, so an // edit elsewhere can never send a picture to a stale offset. const lastCaretRef = useRef(null); const rememberCaret = (e) => { const ta = e.target; lastCaretRef.current = { start: ta.selectionStart, end: ta.selectionEnd, body: ta.value }; }; // Caret to insert at: the live editor if one is open, otherwise the // remembered one when it still fits the current body. const editorCaret = () => { const ta = el.current && el.current.querySelector('textarea'); if (ta) return { ta, start: ta.selectionStart, end: ta.selectionEnd }; const last = lastCaretRef.current; if (last && last.body === (bodyRef.current || '')) return { start: last.start, end: last.end }; return null; }; // Put stored references into the body: at the caret when the editor is // open, otherwise appended to the end on a line of their own. Several // pictures (a multi-file drop) go in together, one per line, in drop order. const insertImageRefs = (refs, caret) => { if (!refs || !refs.length) return; const md = refs.map(r => `![](${r})`).join('\n'); // execCommand keeps the textarea's native undo — and React's onChange — // working, the same contract as the paste and list-editing edits. if (caret && caret.ta && caret.ta.isConnected) { caret.ta.focus(); caret.ta.setSelectionRange(caret.start, caret.end); document.execCommand('insertText', false, md); return; } // No live editor: this is an app-level edit, so it needs its own undo // step. Without one, Ctrl+Z reaches past the picture to whatever was // snapshotted before it — deleting unrelated work. onSnapshot && onSnapshot(); const body = bodyRef.current || ''; if (caret) { const at = Math.min(Math.max(caret.start, 0), body.length); const to = Math.min(Math.max(caret.end, at), body.length); const next = body.slice(0, at) + md + body.slice(to); lastCaretRef.current = { start: at + md.length, end: at + md.length, body: next }; onChange({ body: next }); return; } onChange({ body: body ? body + (body.endsWith('\n') ? '' : '\n') + md : md }); }; // One file in, one sticky-image:// reference out (null on failure). Two // routes, because which one works depends on the environment: // 1. by path — webUtils.getPathForFile through the preload, read in the // main process. Inside flatpak a dropped file's path has been // rewritten by the document portal to /run/user/<uid>/doc/…, which // main may read even though the app has no filesystem permission; // 2. by bytes — the File read in the renderer, like the paste fallback. // The only route when the drop carries no real path. const storeImageFile = async (file, mime) => { const api = window.stickyAPI; if (!api) return null; // web demo: no storage, nothing to insert let filePath = ''; try { filePath = api.pathForFile ? api.pathForFile(file) : ''; } catch { filePath = ''; } if (filePath && api.saveImageFile) { try { const res = await api.saveImageFile(filePath); if (res && res.ok) return res.ref; console.warn('[insert-image] path route failed:', res && res.error); } catch (err) { console.warn('[insert-image] path route failed:', err); } } else { console.warn('[insert-image] no usable file path, reading the bytes in the renderer'); } if (!api.saveImage) return null; try { const bytes = new Uint8Array(await file.arrayBuffer()); const res = await api.saveImage(bytes, mime); if (res && res.ok) return res.ref; console.warn('[insert-image] bytes route failed:', res && res.error); } catch (err) { console.warn('[insert-image] bytes route failed:', err); } return null; }; // Files dropped on this note. Only pictures are taken (each one inserted, // in drop order); folders, PDFs and friends are ignored without a sound. const onDropFiles = (e) => { const files = e.dataTransfer ? Array.from(e.dataTransfer.files || []) : []; if (!files.length) return; // not a file drop — leave it to the browser // Every file drop is ours now, even one we go on to ignore: the default // action is for the window to open the dropped file. e.preventDefault(); e.stopPropagation(); const images = files .map(f => ({ file: f, mime: imageMimeForFile(f.name, f.type) })) .filter(x => x.mime); if (!images.length) return; const caret = editorCaret(); (async () => { const refs = []; for (const { file, mime } of images) { const ref = await storeImageFile(file, mime); if (ref) refs.push(ref); } insertImageRefs(refs, caret); })().catch(err => console.warn('[insert-image]', err)); }; // Context-menu "Insert image…": main opens the picker, reads and stores // the chosen file, and returns the reference — one round trip. const insertImageFromPicker = () => { const api = window.stickyAPI; if (!api || !api.pickImage) return; const caret = editorCaret(); Promise.resolve() .then(() => api.pickImage()) .then(res => { if (res && res.ok) insertImageRefs([res.ref], caret); else if (res && !res.canceled) console.warn('[insert-image]', res.error); }) .catch(err => console.warn('[insert-image]', err)); }; /* ---------- Context-menu "Paste" (#59) ---------- * Takes whichever of the two Ctrl+V paths fits what the clipboard holds * (notePasteAction): the app's copied notes paste onto the canvas, as * Ctrl+V on the desk does; text and pictures go into this note, as Ctrl+V * in the editor does — same picture store, same smart text edits. The * clipboard is read in the main process, straight off the OS clipboard, * so nothing here leans on the renderer's clipboard access — which is what * broke image pasting inside the flatpak sandbox. The web demo has no main * process: it reads text through navigator.clipboard, on the click, and * has nowhere to store a picture — the same as its Ctrl+V. * * Where it lands: if the editor was open when the menu opened, at its * selection, replacing it. Clicking the menu has closed the editor by * then, so it is reopened there and the text goes in through execCommand * — a keyboard paste in every respect, native undo included. Otherwise * nobody can see a caret to aim at, so the end of the body, on a line of * its own, as one undo step: where "Insert image…" puts a picture then. */ const editorSelection = () => { const ta = taRef.current; return ta ? { start: ta.selectionStart, end: ta.selectionEnd, scrollTop: ta.scrollTop, body: ta.value } : null; }; // Taken on every press, before it can move focus: right-clicking the // header or footer blurs the textarea, which ends edit mode before the // contextmenu event arrives. const pressSelectionRef = useRef(null); const openMenu = (x, y) => { const selection = editorSelection() || pressSelectionRef.current; pressSelectionRef.current = null; setMenu({ x, y, selection, paste: null }); // Grey "Paste" out when there is nothing to paste. Electron only — the // web demo can't look without a permission prompt, so its item stays on. const api = window.stickyAPI; if (api && api.readClipboard) { api.readClipboard() .then(clip => { const paste = notePasteAction(clip); setMenu(m => m && { ...m, paste }); }) .catch(err => console.warn('[paste]', err)); } }; const pasteFromMenu = async (selection) => { const api = window.stickyAPI; let clip; try { clip = api && api.readClipboard ? await api.readClipboard() : { text: await navigator.clipboard.readText(), image: false }; } catch (err) { console.warn('[paste]', err); return; } const action = notePasteAction(clip); if (action === 'notes') { onPasteNotes && onPasteNotes(clip.text); return; } let text; if (action === 'image') { let res = null; try { res = await api.saveClipboardImage(); } catch (err) { res = { error: err }; } if (!res || !res.ok) { console.warn('[paste-image]', res && res.error); return; } text = `![](${res.ref})`; } else if (action === 'text') { text = clip.text.replace(/\r\n?/g, '\n'); } else { return; } if (selection && selection.body === (bodyRef.current || '')) { // Reopen it looking where the user left it — the caret and scroll // restore a double-click opens with — then paste. pendingCaretRef.current = selection.start; pendingScrollRef.current = selection.scrollTop; ReactDOM.flushSync(() => setEditing(true)); const ta = taRef.current; if (ta) { const edit = (action === 'text' && editOnPaste(ta.value, selection.start, selection.end, text)) || { start: selection.start, end: selection.end, text }; ta.focus(); ta.setSelectionRange(edit.start, edit.end); document.execCommand('insertText', false, edit.text); // A keyboard paste scrolls to the caret; execCommand doesn't, and // after a long paste it can sit out of view. Same check as opening // at a caret (the layout effect below). const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 18; const caretTop = ta.value.slice(0, ta.selectionEnd).split('\n').length * lineHeight; if (caretTop < ta.scrollTop || caretTop > ta.scrollTop + ta.clientHeight) { ta.scrollTop = Math.max(0, caretTop - ta.clientHeight / 2); } return; } } onSnapshot && onSnapshot(); onChange({ body: appendOnOwnLine(bodyRef.current || '', text) }); }; // Mermaid diagrams (issue #31): mdToHtml renders ```mermaid fences as // <pre class="mermaid-src">; once the markdown HTML is in the DOM, swap // each one for its SVG. Rendering is async and fails soft — on any error // the fence simply stays visible as a code block, the note never crashes. const mdBodyRef = useRef(null); useEffect(() => { if (editing) return; const root = mdBodyRef.current; if (!root || typeof mermaid === 'undefined') return; const fences = root.querySelectorAll('pre.mermaid-src'); if (!fences.length) return; let cancelled = false; fences.forEach((pre, i) => { const id = `mmd-${note.id}-${i}-${Date.now().toString(36)}`; mermaid.render(id, pre.textContent).then(({ svg }) => { if (cancelled || !pre.isConnected) return; const fig = document.createElement('div'); fig.className = 'mermaid-diagram'; fig.innerHTML = svg; pre.replaceWith(fig); }).catch(() => { // Parse/render failure: keep the code block. Mermaid can leave its // scratch element behind on errors — clean both possible ids up. for (const eid of [id, 'd' + id]) { const orphan = document.getElementById(eid); if (orphan && !root.contains(orphan)) orphan.remove(); } }); }); return () => { cancelled = true; }; }, [editing, note.body]); /* ---------- Double-click opens the editor AT the clicked word (#35) ---------- * By the time onDoubleClick runs the browser has already selected the word * under the pointer, and the selection's range start is an exact (text * node, offset) pair — the one position that survives the desk's zoom and * rotation transforms (caretRangeFromPoint does not: it collapses to the * start of the line). flattenPreviewText turns the rendered body into * plain text plus that offset, sourceCaretForPreviewClick maps it back * through the markdown to an offset in the raw body, and the layout effect * below puts the caret there once the textarea has mounted. Every step * fails soft: the worst case is the caret at 0, which is where it always * used to land. */ const pendingCaretRef = useRef(null); const pendingScrollRef = useRef(0); const taRef = useRef(null); const caretFromPreviewClick = () => { try { const root = mdBodyRef.current; const sel = typeof window.getSelection === 'function' ? window.getSelection() : null; if (!root || !sel || !sel.rangeCount) return null; const r = sel.getRangeAt(0); // always in document order if (!root.contains(r.startContainer)) return null; const flat = flattenPreviewText(root, r.startContainer, r.startOffset); if (flat.offset < 0) return null; return sourceCaretForPreviewClick(bodyRef.current || '', flat.text, flat.offset); } catch (err) { console.warn('[caret]', err); // never block entering edit mode return null; } }; useLayoutEffect(() => { const at = pendingCaretRef.current; const scroll = pendingScrollRef.current; pendingCaretRef.current = null; pendingScrollRef.current = 0; const ta = taRef.current; if (!editing || at == null || !ta) return; const pos = Math.max(0, Math.min(at, ta.value.length)); ta.setSelectionRange(pos, pos); // Open looking where the reader was looking: setSelectionRange moves the // caret but never scrolls, so a caret deep in a long note sat off-screen // until the first keystroke yanked the view down to it. if (scroll) ta.scrollTop = scroll; const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 18; const caretTop = ta.value.slice(0, pos).split('\n').length * lineHeight; if (caretTop < ta.scrollTop || caretTop > ta.scrollTop + ta.clientHeight) { ta.scrollTop = Math.max(0, caretTop - ta.clientHeight / 2); } // Keep the remembered caret (what image insertion aims at once the // editor has been blurred away) in step with what the user sees. lastCaretRef.current = { start: pos, end: pos, body: ta.value }; }, [editing]); // When the user clicks outside the note while editing, exit edit mode so // the cursor visibly goes away and further typing doesn't keep landing in // the note. The native blur event doesn't fire here because the desk's // own pointerdown handler calls preventDefault (to suppress text selection // during marquee/pan), which also suppresses the browser's default // "move focus away from the current input" behavior. This document-level // listener bypasses that by explicitly exiting edit mode when the click // lands outside the note's DOM. useEffect(() => { if (!editing && !editingTitle) return; const onOutsideDown = (e) => { if (el.current && !el.current.contains(e.target)) { if (editingTitle) setEditingTitle(false); if (editing) setEditing(false); } }; document.addEventListener('pointerdown', onOutsideDown); return () => document.removeEventListener('pointerdown', onOutsideDown); }, [editing, editingTitle]); useEffect(() => { refCb(el.current); return ()=>refCb(null); }, [refCb]); const col = NOTE_COLORS.find(c => c.id===note.color) || NOTE_COLORS[0]; const bg = tweaks.theme==='paper' ? col.paper : tweaks.theme==='flat' ? col.flat : col.term; const ink = col.ink; // Remembers pointer-down coords on any header button (pin, link, ×) so we // can suppress its click if the user actually dragged the note by it. The // whole header is a drag handle, so every button inside needs this guard. const btnDownRef = useRef(null); // Parsing markdown is the priciest part of a note's render, and dragging // any note re-renders every note on each frame — memoize per body so the // untouched notes don't re-parse while one is being dragged (issue #18). // The mermaid effect below is unaffected: it keys on note.body and swaps // DOM nodes after render, and React only resets innerHTML when the html // string actually changes. const bodyHtml = useMemo(() => mdToHtml(note.body), [note.body]); // Clicking a task checkbox (#65) edits the body in place, without opening // the editor. It is an app-level edit like a pin toggle, so it takes its // own undo step; bodyRef moves on at once so a second click landing before // the re-render toggles the new body rather than the stale one. const isTaskBox = (target) => !!(target && target.closest && target.closest('.task-list-item-checkbox')); const toggleTask = (line) => { const next = toggleTaskItem(bodyRef.current || '', line); if (next == null) return; onSnapshot && onSnapshot(); bodyRef.current = next; onChange({ body: next }); }; // Shared move-note drag. The header is the primary handle; the free // stretch of the footer bar (left of the color dots) reuses it so a note // whose header sits outside the viewport can still be moved (issue #16). const startDrag = (e) => { e.stopPropagation(); e.preventDefault(); // preventDefault above also suppresses the browser's native "collapse // the text selection on mousedown" behavior, so a highlight left in some // note body would silently survive this drag and keep diverting Ctrl+C // to stale text (#30). Grabbing a note signals note-level intent — // drop the highlight explicitly. const ws = typeof window.getSelection === 'function' ? window.getSelection() : null; if (ws && !ws.isCollapsed) ws.removeAllRanges(); onFocus(e); const sX = e.clientX, sY = e.clientY; const z = zRef.current; // Returns a folder id (≠ 'root') if the pointer is currently over a folder // row, else null. Lets header pointer-drag also act as drag-to-folder. const folderIdUnder = (ev) => { const el = document.elementFromPoint(ev.clientX, ev.clientY); const row = el && el.closest && el.closest('[data-folder-id]'); const fid = row && row.getAttribute('data-folder-id'); return (fid && fid !== 'root') ? fid : null; }; // Group drag: if this note was already part of a multi-selection, move all selected notes together. const isGroupDrag = !(e.ctrlKey || e.metaKey) && selected && selectedIds && selectedIds.size > 1 && typeof setNotes === 'function'; if (isGroupDrag) { // Promote the entire selection to top z so no group member slides // UNDER an unselected note during the drag. Centralized at App level // (bringGroupToFront) so the App's zRef counter stays in sync — if // we mutated z directly here, future single bringToFront calls would // assign colliding z values and notes would render in undefined order. bringGroupToFront && bringGroupToFront([...selectedIds]); const starts = new Map(); allNotes.forEach(n => { if (selectedIds.has(n.id)) starts.set(n.id, { x: n.x, y: n.y }); }); const move = (ev) => { const dx = (ev.clientX - sX) / z; const dy = (ev.clientY - sY) / z; setNotes(ns => ns.map(n => { const s = starts.get(n.id); return s ? { ...n, x: s.x + dx, y: s.y + dy } : n; })); }; const up = (ev) => { const targetFolder = folderIdUnder(ev); if (targetFolder && onMoveNotesToFolder) { onMoveNotesToFolder([...selectedIds], targetFolder); } }; startPointerDrag(e, move, up); return; } // Single drag. const { x:nx, y:ny } = note; const move = (ev) => onChange({ x: nx+(ev.clientX-sX)/z, y: ny+(ev.clientY-sY)/z }); const up = (ev) => { const targetFolder = folderIdUnder(ev); if (targetFolder && targetFolder !== note.folder && onMoveToFolder) { onMoveToFolder(targetFolder); } }; startPointerDrag(e, move, up); }; const onHeaderDown = (e) => { if (editingTitle || e.button!==0) return; startDrag(e); }; const onResize = (e) => { e.stopPropagation(); e.preventDefault(); const sX = e.clientX, sY = e.clientY; const { w, h } = note; const move = (ev) => onChange({ w: Math.max(180, w+(ev.clientX-sX)/zRef.current), h: Math.max(120, h+(ev.clientY-sY)/zRef.current) }); startPointerDrag(e, move); }; const rot = tweaks.theme==='paper' && tweaks.tilt !== false ? hashRot(note.id) : 0; // Caveat (the "Handwritten" font) has a much smaller x-height than the other // faces, so at a shared px size it reads noticeably smaller and harder to make // out. Scale note text up when it's active so it sits at a comparable visual // size to Inter/Serif/Mono. (issue #8) // Preferences → Note text size multiplies on top, for the title and the // body alike — it is "all of the text in my notes" that someone with poor // eyesight can't read, and a 12px title over 36px body would still be // unreadable. The note box keeps its size; the text wraps inside it. (#67) const textScale = noteTextScale(tweaks); const fontScale = (tweaks.font === 'Caveat' ? 1.35 : 1) * textScale; return ( // Deliberately NOT html5-draggable (issue #30): a `draggable` root made // every press on the body start a note drag, which is exactly the gesture // that selects text in a normal web app. Moving a note (including drop- // onto-a-folder-row, single or multi-select) is handled entirely by the // pointer-based startDrag on the header and footer handle, which resolves // the folder under the pointer at release via folderIdUnder. <div ref={el} data-note="1" data-note-id={note.id} onMouseDown={onFocus} onPointerDownCapture={()=>{ pressSelectionRef.current = editorSelection(); }} onContextMenu={e=>{e.preventDefault(); e.stopPropagation(); openMenu(e.clientX, e.clientY);}} // Dropping picture files anywhere on the note (header, body, footer) // inserts them — see onDropFiles. Claiming the dragover is what makes // the drop event fire at all, so only do it for drags that carry // files: a text drag keeps its normal drop-into-the-editor behavior. onDragOver={e=>{ const types = e.dataTransfer ? Array.from(e.dataTransfer.types || []) : []; if (!types.includes('Files')) return; e.preventDefault(); e.stopPropagation(); try { e.dataTransfer.dropEffect = 'copy'; } catch {} }} onDrop={onDropFiles} style={{ position:'absolute', left:note.x, top:note.y, width:note.w, height:note.h, background: bg, color: ink, zIndex: 10 + (note.z||0), borderRadius:T.noteRadius, boxShadow:T.noteShadow, transform:`rotate(${rot}deg)`, outline: selected ? `2px solid ${T.accent}` : 'none', outlineOffset:1, display:'flex', flexDirection:'column', overflow:'hidden', }}> <div onPointerDown={onHeaderDown} onDoubleClick={()=>setEditingTitle(true)} style={{ display:'flex', alignItems:'center', gap:8, padding:'6px 10px', background: tweaks.theme==='terminal' ? 'rgba(0,0,0,.2)' : 'rgba(0,0,0,.05)', borderBottom: tweaks.theme==='terminal' ? `1px solid ${T.panelBorder}` : '1px solid rgba(0,0,0,.04)', // touchAction none: with the body no longer a drag surface (#30), // the header/footer are the only ways to move a note by touch — // keep the browser from stealing their pointermoves for scrolling. cursor:'grab', userSelect:'none', flex:'none', touchAction:'none', fontFamily: '"'+tweaks.font+'", system-ui, sans-serif', }}> <button onPointerDown={e=>{ btnDownRef.current = {x:e.clientX, y:e.clientY}; }} onClick={e=>{ e.stopPropagation(); const d = btnDownRef.current; btnDownRef.current = null; if (d && Math.hypot(e.clientX - d.x, e.clientY - d.y) >= 6) { e.preventDefault(); return; } if (onTogglePin) onTogglePin(); else onChange({pinned:!note.pinned}); }} title={note.pinned ? 'Pinned (visible in every folder) · click to unpin' : 'Pin to keep visible in every folder'} {...inkHoverProps(ink)} style={{...btnS(ink), padding:2}}> {note.pinned ? ( <img src="./assets/pin-filled.png" width="16" height="16" alt="Pinned" style={{display:'block'}} draggable={false}/> ) : ( <svg width="16" height="16" viewBox="0 0 100 100" fill="none" stroke={ink} strokeWidth="4" strokeLinejoin="round"> <g transform="translate(50 50) rotate(-25)"> <polygon points="-5,0 5,0 1.4,42 -1.4,42"/> <polygon points="-10,-6 10,-6 7,2 -7,2"/> <circle cx="0" cy="-22" r="22"/> </g> </svg> )} </button> {folder && <span title={folder.name} style={{width:6, height:6, background:folder.hue, borderRadius:'50%', flex:'none'}}/>} {tweaks.hideNoteTitles ? ( <div style={{flex:1}}/> ) : editingTitle ? ( <input autoFocus value={note.title} dir="auto" onChange={e=>{ snapshotOnce(); onChange({title:e.target.value}); }} onBlur={()=>setEditingTitle(false)} onKeyDown={e=>{ if (e.key==='Enter' && (e.ctrlKey || e.metaKey)) { e.target.blur(); return; } if (e.key==='Enter') { setEditingTitle(false); } if (e.key==='Escape') { onChange({title:origTitleRef.current}); setEditingTitle(false); } }} style={{flex:1, background:'transparent', border:'none', outline:'none', font:'inherit', color:'inherit', fontWeight:600, fontSize:12*fontScale, // parity with the title display div (issue #26): no UA input padding padding:0, margin:0}} /> ) : ( /* whiteSpace 'pre' (not 'nowrap') so space runs in the title render exactly as typed in the title input (issue #26). */ <div dir="auto" style={{flex:1, fontWeight:600, fontSize:12*fontScale, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'pre'}}> {note.title || <span style={{opacity:.4}}>Untitled</span>} </div> )} {(() => { // Badge count reflects all links on this note, including ones whose // other endpoint lives in another folder (pinned notes follow the // user across folders, so cross-folder links are worth surfacing). const myLinks = linksFor ? linksFor(note.id) : []; return ( <button onPointerDown={e=>{ btnDownRef.current = {x:e.clientX, y:e.clientY}; }} onClick={e=>{ e.stopPropagation(); const d = btnDownRef.current; btnDownRef.current = null; if (d && Math.hypot(e.clientX - d.x, e.clientY - d.y) >= 6) { e.preventDefault(); return; } onStartLink && onStartLink(); }} title={myLinks.length ? `${myLinks.length} link${myLinks.length>1?'s':''} · click to add another` : 'Link to another note'} {...inkHoverProps(ink, myLinks.length ? 0.95 : 0.65)} style={{...btnS(ink), opacity: myLinks.length ? 0.95 : 0.65, position:'relative'}}> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={ink} strokeWidth="2" strokeLinecap="round"> <path d="M10 13a5 5 0 007 0l3-3a5 5 0 10-7-7l-1 1"/> <path d="M14 11a5 5 0 00-7 0l-3 3a5 5 0 107 7l1-1"/> </svg> {myLinks.length > 0 && ( <span style={{ position:'absolute', top:-2, right:-2, background:T.accent, color:T.accentInk, fontSize:8, minWidth:12, height:12, borderRadius:6, padding:'0 3px', display:'grid', placeItems:'center', fontWeight:700, lineHeight:1, }}>{myLinks.length}</span> )} </button> ); })()} <button onPointerDown={e=>{ btnDownRef.current = {x:e.clientX, y:e.clientY}; }} onClick={e=>{ e.stopPropagation(); const d = btnDownRef.current; btnDownRef.current = null; if (d && Math.hypot(e.clientX - d.x, e.clientY - d.y) >= 6) { e.preventDefault(); return; } onDelete(); }} title="Delete" {...inkHoverProps(ink)} style={btnS(ink)}> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={ink} strokeWidth="2"> <path d="M6 6l12 12M18 6L6 18"/> </svg> </button> </div> <div onDoubleClick={(e)=>{ // Two quick clicks on a task checkbox are two toggles, not a // request to edit (#65). if (isTaskBox(e.target)) return; // Read the word the browser just selected BEFORE the preview is // unmounted by the state change below (#35). if (!editing) { pendingCaretRef.current = caretFromPreviewClick(); // The preview and the editor lay text out on the same lines // (#26), so the preview's scroll position is the editor's too. pendingScrollRef.current = bodyBoxRef.current ? bodyBoxRef.current.scrollTop : 0; } setEditing(true); }} ref={bodyBoxRef} onPointerDown={e=>{ // Mouse/pen drag-selection in preview: while the drag lasts, the // sel-lock class makes everything outside this body unselectable, // so the browser natively clamps the selection to this note (see // the global stylesheet). Touch selection uses long-press handles // and needs no help. (#30) if (editing || e.button!==0 || e.pointerType==='touch' || isTaskBox(e.target)) return; const box = bodyBoxRef.current; if (!box) return; box.classList.add('sel-src'); document.body.classList.add('sel-lock'); // With the pointer outside the note, the browser hit-tests the // unselectable desk behind it, and its fallback can resolve to a // position BEFORE the note — flipping the selection backward when // dragging past the bottom edge. When the pointer is outside the // box, pick the end by geometry instead; inside, native wins. // The browser extends the selection as the mousemove DEFAULT // action, i.e. after listeners run — so the correction must wait // in a rAF, which fires after that but before the frame paints. const at = { x: e.clientX, y: e.clientY }; let raf = 0; const fix = ()=>{ raf = 0; const sel = window.getSelection(); if (!sel || !sel.rangeCount || !sel.anchorNode || !box.contains(sel.anchorNode)) return; const r = box.getBoundingClientRect(); let side = 0; if (at.y < r.top) side = -1; else if (at.y > r.bottom) side = 1; else if (at.x < r.left) side = -1; else if (at.x > r.right) side = 1; if (!side) return; const want = document.createRange(); want.selectNodeContents(box); want.collapse(side < 0); if (want.comparePoint(sel.focusNode, sel.focusOffset) === 0) return; sel.extend(box, side < 0 ? 0 : box.childNodes.length); }; const move = ev=>{ at.x = ev.clientX; at.y = ev.clientY; if (!raf) raf = requestAnimationFrame(fix); }; const done = ()=>{ if (raf) cancelAnimationFrame(raf); fix(); box.classList.remove('sel-src'); document.body.classList.remove('sel-lock'); document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', done); document.removeEventListener('pointercancel', done); window.removeEventListener('blur', done); }; document.addEventListener('pointermove', move); document.addEventListener('pointerup', done); document.addEventListener('pointercancel', done); window.addEventListener('blur', done); }} style={{ flex:1, padding:'10px 14px', overflow:'auto', fontFamily: '"'+tweaks.font+'", system-ui, sans-serif', fontSize: (tweaks.theme==='paper' ? 18 : 13.5) * fontScale, lineHeight: tweaks.theme==='paper' ? 1.35 : 1.5, // The preview and the editor both inherit the size above, so they // scale identically by construction (#26). This variable is only // for the few .md-body lengths that are px, not em (see app.jsx). '--note-text-scale': textScale, color:ink, // Body text is selectable like a normal web page (issue #30) so a // snippet can be copied without entering edit mode. Only the header // and the footer bar move the note. userSelect:'text', cursor:'text', }}> {editing ? ( <textarea autoFocus ref={taRef} value={note.body} dir="auto" onChange={e=>{ snapshotOnce(); rememberCaret(e); onChange({body:e.target.value}); }} onSelect={rememberCaret} onKeyUp={rememberCaret} onClick={rememberCaret} onBlur={e=>{ rememberCaret(e); setEditing(false); }} onPaste={e=>{ const ta = e.target; // Pasted picture (screenshot / copied image): hand the bytes // to the main process, which stores them content-hashed under // userData/images/ and returns a sticky-image:// reference // that mdToHtml renders as an <img>. Insertion goes through // execCommand so native undo keeps working. Electron-only: // the web demo has no stickyAPI, so image pastes fall through // to the default (inert for files) paste there. const items = e.clipboardData ? Array.from(e.clipboardData.items || []) : []; const imgItem = items.find(it => it.kind === 'file' && /^image\//.test(it.type)); if (imgItem && window.stickyAPI && window.stickyAPI.saveImage) { e.preventDefault(); const start = ta.selectionStart, end = ta.selectionEnd; const insert = (res) => { if (!res || !res.ok) return false; ta.focus(); ta.setSelectionRange(start, end); document.execCommand('insertText', false, `![](${res.ref})`); return true; }; // Preferred path: the main process reads the picture straight // off the OS clipboard. The File this paste event carries is // backed by a Chromium temp file that is unreadable inside // the flatpak sandbox (arrayBuffer rejects with NotFoundError, // and the paste silently did nothing), so it is only the // fallback — for the rare clipboard whose image the native // layer can't decode but the renderer can. const viaFile = () => { const file = imgItem.getAsFile(); if (!file) return; return file.arrayBuffer() .then(buf => window.stickyAPI.saveImage(new Uint8Array(buf), file.type)) .then(res => { if (!insert(res)) console.warn('[paste-image]', res && res.error); }); }; const viaClipboard = window.stickyAPI.saveClipboardImage ? window.stickyAPI.saveClipboardImage() : Promise.resolve(null); viaClipboard .then(res => (insert(res) ? null : viaFile())) .catch(err => { console.warn('[paste-image]', err); return viaFile(); }) .catch(err => console.warn('[paste-image]', err)); return; } // Slack-style: pasting a URL over selected text wraps the // selection as [selection](url); pasting multi-line text on a // blockquote line spreads the "> " prefix over every pasted // line. Same execCommand contract as the Enter/Tab list edits // below (native undo + React onChange keep working); null from // both means fall through to the default paste. const pasted = e.clipboardData ? e.clipboardData.getData('text/plain') : ''; const edit = editOnPaste(ta.value, ta.selectionStart, ta.selectionEnd, pasted); if (!edit) return; e.preventDefault(); ta.setSelectionRange(edit.start, edit.end); document.execCommand('insertText', false, edit.text); }} onKeyDown={e=>{ if (e.key==='Enter' && (e.ctrlKey || e.metaKey)) { e.target.blur(); return; } if (e.key==='Escape') { onChange({body:origBodyRef.current}); setEditing(false); return; } // Markdown list editing: Enter continues/ends a bullet, Tab / // Shift+Tab indents/outdents it. The helpers return a minimal // edit we apply via execCommand so the textarea's native // undo/redo (and React's onChange) keep working; null means // "not a list gesture" — fall through to the default behavior. if (e.key==='Enter' || e.key==='Tab') { const ta = e.target; const edit = e.key==='Enter' ? editListOnEnter(ta.value, ta.selectionStart, ta.selectionEnd, e.shiftKey) : editListOnTab(ta.value, ta.selectionStart, ta.selectionEnd, e.shiftKey); if (!edit) return; e.preventDefault(); ta.setSelectionRange(edit.start, edit.end); if (edit.text) document.execCommand('insertText', false, edit.text); else document.execCommand('delete'); ta.setSelectionRange(edit.caret, edit.caret); } }} style={{width:'100%', height:'100%', resize:'none', border:'none', outline:'none', // padding:0 kills the UA textarea padding (2px in Chromium) so // the raw text sits exactly where the rendered preview draws it // (issue #26 — preview identical to edit mode). padding:0, margin:0, // A textarea is inline-block: at height 100% the gap under its // baseline overflowed the body box, whose scrollbar then took // 8px off the editor's width — the text wrapped narrower than // in the preview. Block display leaves only the textarea's own // scrollbar, the one the preview's box shows when it scrolls. display:'block', background:'transparent', color:'inherit', font:'inherit', lineHeight:'inherit'}} /> ) : ( <div className="md-body" dir="auto" ref={mdBodyRef} dangerouslySetInnerHTML={{__html: bodyHtml}} // A press on a task checkbox must neither start a text selection // nor focus the checkbox: a focused <input> would swallow Ctrl+Z // (the global undo handler leaves inputs alone). onMouseDown={(e)=>{ if (isTaskBox(e.target)) e.preventDefault(); }} onClick={(e)=>{ // The checkbox's own state is never trusted: cancel the native // toggle and flip the body, which re-renders it (#65). if (isTaskBox(e.target)) { e.preventDefault(); toggleTask(Number(e.target.closest('.task-list-item-checkbox').dataset.taskLine)); return; } // The mouseup that ends a text-selection drag arrives as a // click on whatever element the pointer was released over. If // text is highlighted, the user was selecting — not asking to // open the link under the cursor. (#30) if (hasTextSelection(window.getSelection())) return; const w = e.target.closest('[data-weblink]'); if (w) { e.preventDefault(); openWebLink(w.dataset.weblink); return; } const a = e.target.closest('[data-link]'); if (a) { e.preventDefault(); onLinkClick(a.dataset.link); } }} /> )} </div> {/* Footer: everything left of the color dots is a second drag handle (issue #16 — the note stays movable when the header is scrolled out of view). Presses on the dots fall through to their onClick, so adding more dots later just shrinks the grab area. */} <div onPointerDown={e=>{ if (e.button===0 && !e.target.closest('button')) startDrag(e); }} title="Drag to move" style={{ padding:'5px 10px', display:'flex', alignItems:'center', gap:6, flex:'none', borderTop: tweaks.theme==='terminal' ? `1px solid ${T.panelBorder}` : '1px solid rgba(0,0,0,.05)', background: tweaks.theme==='terminal' ? 'rgba(0,0,0,.2)' : 'transparent', fontSize:10, color:ink, opacity:.75, cursor:'grab', userSelect:'none', touchAction:'none', }}> <svg width="14" height="8" viewBox="0 0 14 8" aria-hidden="true" style={{flex:'none', opacity:.45}}> <g fill={ink}> <circle cx="2" cy="2" r="1"/><circle cx="7" cy="2" r="1"/><circle cx="12" cy="2" r="1"/> <circle cx="2" cy="6" r="1"/><circle cx="7" cy="6" r="1"/><circle cx="12" cy="6" r="1"/> </g> </svg> <div style={{flex:1}}/> <ColorDots current={note.color} onPick={c=>onChange({color:c})} ink={ink}/> </div> <div onPointerDown={onResize} style={{position:'absolute', right:0, bottom:0, width:14, height:14, cursor:'nwse-resize', // Same contract as the header/footer handles: without touch-action // none the browser claims a touch resize as scroll/pan and // pointercancels it mid-gesture (issue #18). touchAction:'none', background: `linear-gradient(135deg, transparent 40%, ${withA(ink,0.25)} 40%, ${withA(ink,0.25)} 50%, transparent 50%, transparent 60%, ${withA(ink,0.25)} 60%, ${withA(ink,0.25)} 70%, transparent 70%)`, }}/> {menu && (() => { const myLinks = linksFor ? linksFor(note.id) : []; const notesById = Object.fromEntries(allNotes.map(x=>[x.id,x])); const linkSubmenu = myLinks.length ? myLinks.map(l => { const otherId = l.from===note.id ? l.to : l.from; const other = notesById[otherId]; const arrow = l.from===note.id ? '→' : '←'; return { label: `${arrow} ${other?.title || '(missing)'}`, onClick: () => onJumpToNote && onJumpToNote(otherId) }; }) : [{label:'(no links yet)', disabled:true}]; const candidates = allNotes.filter(n => n.id !== note.id).slice(0, 20); return ( <ContextMenu T={T} x={menu.x} y={menu.y} fixed onClose={()=>setMenu(null)} items={[ {label: (selected && selectedIds && selectedIds.size > 1) ? 'Copy ' + selectedIds.size + ' notes' : 'Copy', onClick: () => onCopy && onCopy()}, {label:'Paste', disabled: menu.paste === 'none', onClick: () => pasteFromMenu(menu.selection)}, {label:'Download', onClick:()=>downloadNoteAsMarkdown(note)}, {divider:true}, {label:'Edit title', onClick:()=>setEditingTitle(true)}, {label:'Edit body', onClick:()=>setEditing(true)}, {label:'Insert image…', onClick:()=>insertImageFromPicker()}, {label: note.pinned?'Unpin':'Pin to top', onClick:()=>{ if (onTogglePin) onTogglePin(); else onChange({pinned:!note.pinned}); }}, {divider:true}, {label:'Link to note ▶', submenu: candidates.map(n => ({ label: n.title || 'Untitled', dot: (NOTE_COLORS.find(c=>c.id===n.color)||{}).paper, onClick: () => onAddLink && onAddLink(n.id), }))}, {label:'Draw link…', onClick: () => onStartLink && onStartLink()}, myLinks.length ? {label:`Linked notes (${myLinks.length}) ▶`, submenu: linkSubmenu} : null, {divider:true}, {label:'Change color ▶', submenu: NOTE_COLORS.map(c=>({label:c.name, dot:c.paper, onClick:()=>onChange({color:c.id})}))}, childFolders.length ? {label:'Move to folder ▶', submenu: childFolders.map(f=>({label:'  '.repeat(Math.min(f.depth||0, 5)) + f.name, dot:f.hue, onClick:()=>onMoveToFolder(f.id)}))} : null, {divider:true}, {label:'Delete…', destructive:true, onClick:onDelete}, ].filter(Boolean)}/> ); })()} </div> ); } function btnS(ink) { return {background:'transparent', border:'none', cursor:'pointer', padding:4, borderRadius:4, display:'grid', placeItems:'center', color:ink, opacity:.65}; } function ColorDots({current, onPick, ink}) { return <div style={{display:'flex', gap:4}}> {NOTE_COLORS.slice(0,6).map(c => ( // 10px targets with no hover feedback at all before issue #49: a ring // in the note's own ink plus a small pop says which one is under the // cursor without changing the swatch colour itself. <button key={c.id} onClick={()=>onPick(c.id)} title={c.name} onMouseEnter={e=>{ e.currentTarget.style.boxShadow = `0 0 0 2px ${withA(ink,.45)}`; e.currentTarget.style.transform = 'scale(1.25)'; }} onMouseLeave={e=>{ e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'none'; }} style={{ width:10, height:10, borderRadius:'50%', border: current===c.id ? `1.5px solid ${ink}` : '1px solid rgba(0,0,0,.15)', background:c.paper, cursor:'pointer', padding:0, transition:'transform .1s, box-shadow .1s', }}/> ))} </div>; } /* ==================================================================== */ /* CONTEXT MENU */ /* ==================================================================== */ function ContextMenu({T, x, y, items, onClose, fixed}) { const ref = useRef(null); useEffect(() => { const h = (e) => { if (!ref.current || !ref.current.contains(e.target)) onClose(); }; setTimeout(()=>window.addEventListener('mousedown', h), 0); return () => window.removeEventListener('mousedown', h); }, []); // `fixed` mode: render in screen space via a portal, at viewport coords // (pass clientX/clientY). A note's menu can't live inside the note's DOM — // the note clips it with overflow:hidden (issue #13), tilts it with the // paper-theme rotation, and scales it with the canvas zoom. Portaling to // document.body opts out of all three. Screen space also makes "keep the // menu reachable" cheap: after mount, measure and nudge the menu back // inside the viewport if it would spill past an edge. const [nudge, setNudge] = useState({dx:0, dy:0}); useLayoutEffect(() => { if (!fixed || !ref.current) return; const r = ref.current.getBoundingClientRect(); setNudge({ dx: Math.min(0, window.innerWidth - 8 - (x + r.width)), dy: Math.min(0, window.innerHeight - 8 - (y + r.height)), }); }, [fixed, x, y]); const menuEl = ( <div ref={ref} style={{ position: fixed ? 'fixed' : 'absolute', left: x + (fixed ? nudge.dx : 0), top: y + (fixed ? nudge.dy : 0), minWidth:180, zIndex:99999, background:T.panelBg, border:`1px solid ${T.panelBorder}`, borderRadius:8, boxShadow:'0 8px 32px rgba(0,0,0,.15)', padding:4, color:T.panelText, }}> {items.map((it,i) => it.divider ? <div key={i} style={{height:1, background:T.hairline, margin:'4px 0'}}/> : <div key={i} style={{position:'relative'}} className="ctx-row" onMouseEnter={e=>e.currentTarget.classList.add('hover')} onMouseLeave={e=>e.currentTarget.classList.remove('hover')}> {/* NB: background/display of the row button and submenu are driven by the .ctx-row rules in the <style> below — they must not also be set inline, or the inline value wins over the .hover rules and the hover highlight / submenus can never appear. Same for the submenu buttons (.ctx-sub button), which is why neither sets a background of its own. */} <button disabled={!!it.disabled} onClick={()=>{ it.onClick?.(); if(!it.submenu && !it.keepOpen) onClose(); }} style={{ width:'100%', textAlign:'left', border:'none', ...(it.dot ? {display:'flex', alignItems:'center', gap:8} : null), padding:'7px 10px', borderRadius:4, fontSize:13, cursor: it.disabled ? 'default' : 'pointer', color: it.disabled ? T.muted : it.destructive ? T.danger : T.panelText, }}> {/* Same swatch as a submenu row's, for flat colour lists. */} {it.dot && <span style={{width:10, height:10, borderRadius:3, background:it.dot, border:'1px solid rgba(0,0,0,.1)', flex:'none'}}/>} {it.label} </button> {it.submenu && <div className="ctx-sub" style={{ position:'absolute', left:'100%', top:-4, minWidth:160, background:T.panelBg, border:`1px solid ${T.panelBorder}`, borderRadius:8, padding:4, boxShadow:'0 8px 32px rgba(0,0,0,.15)', }}> {it.submenu.map((s,j)=> <button key={j} disabled={!!s.disabled} onClick={()=>{s.onClick?.(); onClose();}} style={{ width:'100%', display:'flex', alignItems:'center', gap:8, textAlign:'left', border:'none', padding:'6px 10px', borderRadius:4, cursor: s.disabled ? 'default' : 'pointer', fontSize:13, color: s.disabled ? T.muted : T.panelText, }}> {s.dot && <span style={{width:10, height:10, borderRadius:3, background:s.dot, border:'1px solid rgba(0,0,0,.1)'}}/>} {s.label} </button> )} </div>} </div> )} {/* Hover/focus painting (issue #49). The highlight is theme-derived — see hoverBg() — because one hard-coded overlay cannot read on both the light panels and the dark terminal one. Keyboard focus paints the same background PLUS an accent outline, so tabbing is never less visible than hovering. Disabled rows opt out of all of it: an inert "(no links yet)" line must not look clickable. */} <style>{` /* No transition here on purpose: a menu is swept through quickly and a fading highlight lags behind the cursor. */ .ctx-row > button, .ctx-sub button { background: transparent; } .ctx-row.hover > button:enabled, .ctx-row > button:enabled:focus-visible, .ctx-sub button:enabled:hover, .ctx-sub button:enabled:focus-visible { background: ${hoverBg(T)}; box-shadow: inset 2px 0 0 ${T.accent}; } .ctx-row > button:enabled:focus-visible, .ctx-sub button:enabled:focus-visible { outline: 2px solid ${T.accent}; outline-offset: -2px; } .ctx-row > button:disabled, .ctx-sub button:disabled { background: transparent; box-shadow: none; } .ctx-sub { display: none; } .ctx-row.hover .ctx-sub, .ctx-row:focus-within .ctx-sub { display: block; } `}</style> </div> ); return fixed ? ReactDOM.createPortal(menuEl, document.body) : menuEl; } /* ==================================================================== */ /* CONFIRM */ /* ==================================================================== */ function ConfirmDialog({T, title, body, onCancel, onConfirm, confirmLabel='Delete'}) { // Escape cancels. Deliberately no key confirms: Delete/Backspace open this // dialog (#47), often under someone who thinks they're typing in a note, // and their next keystroke must not be the one that answers it. useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onCancel(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onCancel]); return ( <div style={{position:'fixed', inset:0, background:'rgba(10,14,20,.35)', zIndex:100000, display:'grid', placeItems:'center'}}> <div style={{background:T.panelBg, color:T.panelText, borderRadius:12, border:`1px solid ${T.panelBorder}`, width:400, padding:22, boxShadow:'0 20px 60px rgba(0,0,0,.3)'}}> <div style={{fontWeight:700, fontSize:16, marginBottom:6}}>{title}</div> <div style={{fontSize:13, color:T.muted, lineHeight:1.5}}>{body}</div> <div style={{display:'flex', gap:8, justifyContent:'flex-end', marginTop:18}}> <button onClick={onCancel} {...hoverProps(T)} style={{padding:'8px 14px', background:'transparent', border:`1px solid ${T.panelBorder}`, borderRadius:8, fontSize:13, cursor:'pointer', color:T.panelText, transition:'background .1s'}}>Cancel</button> <button onClick={onConfirm} onMouseEnter={e=>{ e.currentTarget.style.background = '#a32c2c'; }} onMouseLeave={e=>{ e.currentTarget.style.background = '#c33b3b'; }} style={{padding:'8px 14px', background:'#c33b3b', color:'#fff', border:'none', borderRadius:8, fontSize:13, fontWeight:600, cursor:'pointer', transition:'background .1s'}}>{confirmLabel}</button> </div> </div> </div> ); } // File → Choose where notes are saved… (issue #68): where notes.json and images/ live, and // the way to move them. A dialog of its own, not a Preferences row, so the // warning is in front of the user at the moment they choose: an empty folder // gets a copy of the open notes without asking, so a synced folder whose // notes.json hasn't arrived yet would get this window's notes — on a new // computer, the sample notes — and the sync tool would spread them. The // folder picker itself is native and has room for a title only. function DataFolderDialog({T, folder, onChangeDataFolder, onResetDataFolder, onClose}) { useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); const outline = {padding:'8px 14px', background:'transparent', border:`1px solid ${T.panelBorder}`, borderRadius:8, fontSize:13, cursor:'pointer', color:T.panelText, transition:'background .1s'}; return ( <div style={{position:'fixed', inset:0, background:'rgba(10,14,20,.35)', zIndex:100000, display:'grid', placeItems:'center'}}> <div data-data-folder-dialog style={{background:T.panelBg, color:T.panelText, borderRadius:12, border:`1px solid ${T.panelBorder}`, width:480, maxWidth:'calc(100vw - 32px)', boxSizing:'border-box', padding:22, boxShadow:'0 20px 60px rgba(0,0,0,.3)'}}> <div style={{fontWeight:700, fontSize:16, marginBottom:6}}>Where your notes are saved</div> <div style={{fontSize:13, color:T.muted}}>Your notes are kept in</div> <div data-data-folder-path style={{fontFamily:'ui-monospace, monospace', fontSize:12, wordBreak:'break-all', margin:'4px 0 2px'}}>{folder.path}</div> {folder.isDefault && <div style={{fontSize:12, color:T.muted}}>Default location</div>} <div data-data-folder-warning style={{fontSize:13, lineHeight:1.5, marginTop:14, color: isDarkSurface(T.panelBg) ? '#ff8a80' : '#c33b3b'}}> <div style={{fontWeight:700}}>Make sure the new folder is ready.</div> An empty folder gets a copy of the notes you have open now. Only a folder that already has notes.json lets you use the notes in it. </div> <div style={{display:'flex', gap:8, justifyContent:'flex-end', flexWrap:'wrap', marginTop:18}}> <button onClick={onClose} {...hoverProps(T)} style={outline}>Cancel</button> {!folder.isDefault && ( <button onClick={onResetDataFolder} {...hoverProps(T)} style={outline}>Reset to default location</button> )} <button onClick={() => onChangeDataFolder()} autoFocus onMouseEnter={e=>{ e.currentTarget.style.background = mixHex(T.accent, isDarkSurface(T.panelBg) ? '#ffffff' : '#000000', .18); }} onMouseLeave={e=>{ e.currentTarget.style.background = T.accent; }} style={{padding:'8px 14px', background:T.accent, color: isDarkSurface(T.panelBg) ? '#0a0c10' : '#fff', border:'none', borderRadius:8, fontSize:13, fontWeight:600, cursor:'pointer', transition:'background .1s'}}>Choose folder…</button> </div> </div> </div> ); } // Choosing a data folder that already holds notes (issue #68) — usually // one another computer syncs into, or the default location the notes were // moved out of. Three ways out; "Use the notes there" is the one that // destroys nothing, and overwriting them is in red. function DataFolderChoiceDialog({T, folder, isDefault, onUse, onReplace, onCancel}) { return ( <div style={{position:'fixed', inset:0, background:'rgba(10,14,20,.35)', zIndex:100000, display:'grid', placeItems:'center'}}> <div data-data-folder-choice style={{background:T.panelBg, color:T.panelText, borderRadius:12, border:`1px solid ${T.panelBorder}`, width:480, maxWidth:'calc(100vw - 32px)', boxSizing:'border-box', padding:22, boxShadow:'0 20px 60px rgba(0,0,0,.3)'}}> <div style={{fontWeight:700, fontSize:16, marginBottom:6}}> {isDefault ? 'The default location already has notes' : 'This folder already has notes'} </div> <div style={{fontFamily:'ui-monospace, monospace', fontSize:12, wordBreak:'break-all', margin:'4px 0 10px'}}>{folder}</div> <div style={{fontSize:13, color:T.muted, lineHeight:1.5}}> Using your current notes overwrites the notes there. <br/>No merge option is supported at the moment. </div> <div style={{display:'flex', gap:8, justifyContent:'flex-end', flexWrap:'wrap', marginTop:18}}> <button onClick={onCancel} {...hoverProps(T)} style={{padding:'8px 14px', background:'transparent', border:`1px solid ${T.panelBorder}`, borderRadius:8, fontSize:13, cursor:'pointer', color:T.panelText, transition:'background .1s'}}>Cancel</button> <button onClick={onReplace} onMouseEnter={e=>{ e.currentTarget.style.background = '#a32c2c'; }} onMouseLeave={e=>{ e.currentTarget.style.background = '#c33b3b'; }} style={{padding:'8px 14px', background:'#c33b3b', color:'#fff', border:'none', borderRadius:8, fontSize:13, fontWeight:600, cursor:'pointer', transition:'background .1s'}}>Use my current notes</button> <button onClick={onUse} autoFocus onMouseEnter={e=>{ e.currentTarget.style.background = mixHex(T.accent, isDarkSurface(T.panelBg) ? '#ffffff' : '#000000', .18); }} onMouseLeave={e=>{ e.currentTarget.style.background = T.accent; }} style={{padding:'8px 14px', background:T.accent, color: isDarkSurface(T.panelBg) ? '#0a0c10' : '#fff', border:'none', borderRadius:8, fontSize:13, fontWeight:600, cursor:'pointer', transition:'background .1s'}}>Use the notes there</button> </div> </div> </div> ); } // Shown INSTEAD of the canvas when the chosen data folder can't be used at // startup (issue #68): an unmounted share, an unplugged drive, a sync that // hasn't delivered notes.json yet — or when notes.json, in any folder, can't // be read (a hand edit with a typo). Nothing has been loaded, and main // refuses every write until the user picks one of these — so the real // notes can never be "fixed" by an empty canvas saved over them. const DATA_FOLDER_REASONS = { 'missing': "that folder can't be found. If it's on a network share, an external drive or a sync service, connect it and try again.", 'not-folder': "that path isn't a folder.", 'no-access': "the app isn't allowed to read and write there.", 'no-notes': "there's no notes.json in it. If the folder is synced from another computer, it may not have arrived yet — or the share isn't mounted.", }; function DataFolderUnavailable({folder, onRetry, onChoose, onUseDefault}) { const unreadable = folder.reason === 'unreadable'; const mono = {fontFamily:'ui-monospace, monospace', fontSize:12, color:'#2a241a', wordBreak:'break-all', margin:'6px 0'}; const btn = { padding:'8px 14px', background:'transparent', color:'#2a241a', border:'1px solid #d8cfbc', borderRadius:6, fontSize:13, cursor:'pointer', }; return ( <div style={{ position:'fixed', inset:0, background:'#14181d', overflow:'auto', display:'flex', alignItems:'center', justifyContent:'center', padding:16, fontFamily:'Inter, system-ui, sans-serif', }}> <div data-data-folder-unavailable role="alertdialog" style={{ background:'#fbf7ef', color:'#2a241a', border:'1px solid #d8cfbc', borderRadius:8, boxShadow:'0 10px 40px rgba(0,0,0,.25)', padding:'20px 24px', maxWidth:520, }}> <div style={{fontSize:11, fontWeight:600, color:'#7a6f5b', marginBottom:8, textTransform:'uppercase', letterSpacing:.5}}>Where your notes are saved</div> <div style={{fontSize:15, fontWeight:600, marginBottom:12}}> {unreadable ? "Your notes file can't be read" : "Your notes folder can't be reached"} </div> {unreadable ? ( <div style={{fontSize:13, color:'#5a4a3a', lineHeight:1.5}}> Sticky Notes keeps your notes in <div style={mono}>{folder.notesFile}</div> but it can't be read: <div style={mono}>{folder.detail}</div> Fix the file, then try again. </div> ) : ( <div style={{fontSize:13, color:'#5a4a3a', lineHeight:1.5}}> Sticky Notes keeps your notes in <div style={mono}>{folder.path}</div> but {DATA_FOLDER_REASONS[folder.reason] || "it can't be used right now."} </div> )} <div style={{fontSize:13, color:'#5a4a3a', lineHeight:1.5, marginTop:10}}> Nothing has been loaded or saved, so {unreadable ? 'the file is untouched' : 'whatever is in that folder is safe'}. </div> <div style={{display:'flex', gap:8, justifyContent:'flex-end', flexWrap:'wrap', marginTop:18}}> {!folder.isDefault && <button onClick={onUseDefault} style={btn}>Use the default location</button>} <button onClick={onChoose} style={btn}>Choose another folder…</button> <button onClick={onRetry} autoFocus style={{...btn, background:'#d97757', color:'#fff', border:'none', fontWeight:600}}>Try again</button> </div> {!folder.isDefault && ( <div style={{fontSize:11, color:'#7a6f5b', lineHeight:1.5, marginTop:14}}> The default location is {folder.defaultPath} — it holds the notes from before you chose a folder, if there are any. </div> )} </div> </div> ); } /* ==================================================================== */ /* FOLDERS DRAWER (right side — list of folders) */ /* ==================================================================== */ function FoldersDrawer({T, tweaks, folders, notes, currentFolder, setCurrentFolder, onCreateFolder, onRenameFolder, renamingFolder, setRenamingFolder, onDeleteFolder, onMoveFolderToParent, onRecolorFolder, onDropNoteOnFolder, onDropNotesOnFolder, onCreateNote, open, setOpen, folderOrder, setFolderOrder}) { const isTerm = tweaks.theme==='terminal'; const isPaper = tweaks.theme==='paper'; // Row currently hovered by a folder drag, with the drop zone the pointer // is in. Shape: {id, zone: 'before'|'after'|'into'} | null. const [dragOver, setDragOver] = useState(null); // Right-click context menu on a folder row, at viewport coordinates. // Shape: {x, y, folderId, mode?: 'move'|'color'} | null. const [folderMenu, setFolderMenu] = useState(null); // Collapsed subtrees (drawer-local UI state, not persisted). A collapsed // parent hides all of its descendant rows; its own count still reflects // the whole subtree. const [collapsed, setCollapsed] = useState(() => new Set()); const toggleCollapsed = (id) => setCollapsed(c => { const next = new Set(c); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const expandFolder = (id) => setCollapsed(c => { if (!c.has(id)) return c; const next = new Set(c); next.delete(id); return next; }); // Washi-tape colors for paper variant (slightly lighter/warmer than folder hues) const WASHI = { '#d97757': '#e9a27a', '#5a82c9': '#8cb3d8', '#8a6fbf': '#b89ed6', '#4c9e6b': '#9dc98a', '#c4843a': '#e0c477', '#b84a6b': '#d89aaa', '#3fa89a': '#8ccec4', '#8a8f3d': '#c7cc82', }; // Close the folder context menu on Escape (outside-click is handled by // the shared ContextMenu component itself). useEffect(() => { if (!folderMenu) return; const onKey = (e) => { if (e.key === 'Escape') setFolderMenu(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [folderMenu]); // A press on a row's colour stripe must not turn into a drag of the row. // The drag starts on the (draggable) row, not on the stripe, so the stripe // records the press here and the row's dragstart checks it. const stripePress = useRef(null); useEffect(() => { const clear = () => { stripePress.current = null; }; window.addEventListener('pointerup', clear); window.addEventListener('pointercancel', clear); return () => { window.removeEventListener('pointerup', clear); window.removeEventListener('pointercancel', clear); }; }, []); // DFS-flattened folder tree ({id, depth, hasChildren} rows). Sibling order // comes from folderOrder, with folders not yet in the order appended // alphabetically — the same contract the flat list used before nesting. const tree = useMemo(() => flattenFolderTree(folders, folderOrder), [folders, folderOrder]); // Rows actually rendered: everything inside a collapsed subtree is hidden. const visibleRows = useMemo(() => { const rows = []; const stack = []; // depths of collapsed ancestors currently in effect for (const r of tree) { while (stack.length && r.depth <= stack[stack.length - 1]) stack.pop(); if (stack.length) continue; rows.push(r); if (r.hasChildren && collapsed.has(r.id)) stack.push(r.depth); } return rows; }, [tree, collapsed]); // Only reserve chevron space once the tree actually nests, so flat folder // lists render exactly as they did before subfolders existed. const anyNesting = useMemo(() => tree.some(r => r.hasChildren), [tree]); // Which drop zone of a row the pointer is in: the middle nests the dragged // folder INTO the row's folder, the edges reorder it before/after the row. const zoneOf = (e) => { const r = e.currentTarget.getBoundingClientRect(); const y = (e.clientY - r.top) / Math.max(1, r.height); return y < 0.28 ? 'before' : y > 0.72 ? 'after' : 'into'; }; // Zone-based folder drop. 'into' re-parents under the target; 'before' / // 'after' makes the dragged folder a sibling of the target (re-parenting // when the drag crosses tree levels) and reorders folderOrder. Drops that // would move a folder into its own subtree are silently ignored. const handleFolderDrop = (draggedId, targetId, zone) => { if (draggedId === targetId || !folders[draggedId] || !folders[targetId]) return; if (zone === 'into') { expandFolder(targetId); onMoveFolderToParent(draggedId, targetId); return; } const newParent = folders[targetId].parent; if (!canMoveFolder(folders, draggedId, newParent)) return; if (folders[draggedId].parent !== newParent) onMoveFolderToParent(draggedId, newParent); // Rewrite folderOrder from the full visual order with the dragged id // spliced next to the target; sibling ordering is read back out of it. const seq = tree.map(r => r.id).filter(id => id !== draggedId); const at = seq.indexOf(targetId); if (at < 0) return; seq.splice(zone === 'after' ? at + 1 : at, 0, draggedId); setFolderOrder(seq); }; const renderRow = (f, isAll, depth = 0, hasChildren = false) => { const isActive = currentFolder===f.id; // A folder's count covers its whole subtree, matching what the canvas // shows when the folder is open (and what a collapsed parent contains). const count = isAll ? notes.length : (() => { const ids = folderSubtreeIds(folders, f.id); return notes.filter(n => ids.has(n.folder)).length; })(); const swatch = isAll ? T.accent : f.hue; const idleBg = T.rowBg; // Theme-derived (issue #49): the old pair moved the row by 13/255 on // terminal and 8/255 on flat — a hover you had to hunt for. const rowHoverBg = hoverBg(T); // Clamp the visual indent so very deep trees stay usable in a 300px drawer. const indent = Math.min(depth, 5) * 14; const isCollapsed = collapsed.has(f.id); const dragZone = dragOver && dragOver.id === f.id ? dragOver.zone : null; const isDropTarget = dragZone === 'into'; // 2px accent line hugging the row's top/bottom edge while a folder drag // hovers the reorder zones; nesting ('into') highlights the whole row. const zoneShadow = dragZone === 'before' ? `inset 0 2px 0 ${T.accent}` : dragZone === 'after' ? `inset 0 -2px 0 ${T.accent}` : undefined; // Chevron for folders with subfolders; same-width spacer keeps names // aligned once any nesting exists anywhere in the tree. const chevronSlot = isAll ? null : (hasChildren ? ( <button onClick={e=>{ e.stopPropagation(); toggleCollapsed(f.id); }} onDoubleClick={e=>e.stopPropagation()} title={isCollapsed ? 'Expand subfolders' : 'Collapse subfolders'} style={{ width:16, height:16, flex:'none', display:'grid', placeItems:'center', background:'transparent', border:'none', cursor:'pointer', color:T.muted, fontSize:10, lineHeight:1, padding:0, }}>{isCollapsed ? '▸' : '▾'}</button> ) : (anyNesting ? <span style={{width:16, flex:'none'}}/> : null)); // Context-menu handler shared across variants (skips the All-notes root row). // The menus render in screen space (`fixed`, like a note's): inside the // drawer, its overflow:hidden cut off every item below a low row. const onRowContextMenu = (e) => { if (isAll) return; e.preventDefault(); e.stopPropagation(); setFolderMenu({x: e.clientX, y: e.clientY, folderId: f.id}); }; // Clicking a folder's colour stripe opens its colour list directly (the // menu's "Change color…" list) without opening the folder. The button is // wider than the few-pixel stripe it holds, so it is easy to hit. const stripeProps = { title: 'Change color', 'aria-label': `Change the color of ${f.name}`, onPointerDown: () => { stripePress.current = f.id; }, onClick: (e) => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); setFolderMenu({x: r.right, y: r.top, folderId: f.id, mode: 'color'}); }, onDoubleClick: (e) => e.stopPropagation(), }; // ─── Paper variant: washi-tape row, no chip icon (real folders only) ─── if (isPaper && !isAll) { const washiColor = WASHI[f.hue] || f.hue; const paperIdleBg = 'transparent'; const paperActiveBg = withA(swatch, .14); const paperHoverBg = rowHoverBg; return ( <div key={f.id} data-folder-id={f.id} draggable={renamingFolder !== f.id} onDragStart={e => { if (stripePress.current === f.id) { e.preventDefault(); return; } e.dataTransfer.setData('folder-id', f.id); e.dataTransfer.effectAllowed = 'move'; }} onDragOver={e => { const hasNotes = e.dataTransfer.types.includes('note-ids'); const hasFolder = e.dataTransfer.types.includes('folder-id'); if (!hasNotes && !hasFolder) return; e.preventDefault(); if (hasFolder) { setDragOver({ id: f.id, zone: zoneOf(e) }); } else { e.currentTarget.style.outline = `1px dashed ${T.accent}`; e.currentTarget.style.background = withA(T.accent, .12); } }} onDragLeave={e => { e.currentTarget.style.outline = 'none'; e.currentTarget.style.background = isActive ? paperActiveBg : paperIdleBg; setDragOver(d => d && d.id === f.id ? null : d); }} onDrop={e => { e.currentTarget.style.outline = 'none'; e.currentTarget.style.background = isActive ? paperActiveBg : paperIdleBg; const zone = zoneOf(e); setDragOver(null); const folderId = e.dataTransfer.getData('folder-id'); if (folderId) { handleFolderDrop(folderId, f.id, zone); return; } const raw = e.dataTransfer.getData('note-ids'); if (raw) { const ids = raw.split(',').filter(Boolean); if (ids.length > 1 && onDropNotesOnFolder) onDropNotesOnFolder(ids, f.id); else if (ids.length === 1) onDropNoteOnFolder(ids[0], f.id); } }} onClick={() => setCurrentFolder(f.id)} onDoubleClick={() => setRenamingFolder(f.id)} onContextMenu={onRowContextMenu} style={{ position:'relative', display:'flex', alignItems:'center', gap:10, padding:'9px 12px 9px 18px', marginBottom:3, marginLeft:indent, cursor: renamingFolder === f.id ? 'text' : 'grab', background: isDropTarget ? withA(T.accent, .18) : isActive ? paperActiveBg : paperIdleBg, borderRadius:3, boxShadow: zoneShadow, transition:'background .1s', }} onMouseEnter={e=>{ if(!isActive && !isDropTarget) e.currentTarget.style.background = paperHoverBg; }} onMouseLeave={e=>{ if(!isActive && !isDropTarget) e.currentTarget.style.background = paperIdleBg; }} > {/* Washi tape stripe — click it to recolour the folder */} <button {...stripeProps} style={{ position:'absolute', left:0, top:0, bottom:0, width:16, padding:0, border:'none', background:'transparent', cursor:'pointer', }}> <div data-folder-stripe style={{ position:'absolute', left:4, top:7, bottom:7, width:6, background: washiColor, backgroundImage: 'repeating-linear-gradient(135deg, transparent 0 3px, rgba(255,255,255,.22) 3px 4px)', boxShadow: `inset 0 0 0 0.5px ${washiColor}, 0 1px 2px rgba(0,0,0,.1)`, opacity: .85, }}/> </button> {chevronSlot && <div style={{paddingLeft:8, flex:'none', display:'flex'}}>{chevronSlot}</div>} <div style={{flex:1, minWidth:0, paddingLeft: chevronSlot ? 0 : 8}}> {renamingFolder===f.id ? ( <input autoFocus defaultValue={f.name} dir="auto" onClick={e=>e.stopPropagation()} onBlur={e=>{ onRenameFolder(f.id, e.target.value||f.name); setRenamingFolder(null); }} onKeyDown={e=>{ if(e.key==='Enter'){onRenameFolder(f.id, e.target.value||f.name); setRenamingFolder(null);} if(e.key==='Escape'){setRenamingFolder(null);}}} style={{width:'100%', background:'transparent', border:'none', outline:'none', color:T.panelText, fontSize:14, fontWeight:600, font:'inherit'}} /> ) : ( <div dir="auto" style={{fontSize:13, fontWeight:600, color:T.panelText, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}}> {f.name} </div> )} <div style={{fontSize:11, color:T.muted, marginTop:2, fontStyle:'italic'}}> {count} {count===1?'note':'notes'} </div> </div> {isActive && ( <button onClick={(e)=>{e.stopPropagation(); onDeleteFolder(f.id);}} title="Delete folder" onMouseEnter={e=>{ e.currentTarget.style.background = withA(T.danger, .22); e.currentTarget.style.color = T.danger; }} onMouseLeave={e=>{ e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = T.muted; }} style={{width:20, height:20, display:'grid', placeItems:'center', background:'transparent', border:'none', cursor:'pointer', color:T.muted, borderRadius:4, fontSize:14, lineHeight:1, padding:0, }}>×</button> )} </div> ); } // ─── Flat / terminal row, and the "All notes" row in ALL variants ─── return ( <div key={f.id} data-folder-id={f.id} draggable={!isAll && renamingFolder !== f.id} onDragStart={e => { if (isAll) return; if (stripePress.current === f.id) { e.preventDefault(); return; } e.dataTransfer.setData('folder-id', f.id); e.dataTransfer.effectAllowed = 'move'; }} onDragOver={e=>{ const hasNotes = e.dataTransfer.types.includes('note-ids'); const hasFolder = e.dataTransfer.types.includes('folder-id'); if (isAll) { // The All-notes row accepts folder drops as "move to top level" — // the drag-out escape hatch for un-nesting a subfolder. if (!hasFolder) return; e.preventDefault(); setDragOver({ id: 'root', zone: 'into' }); return; } if (!hasNotes && !hasFolder) return; e.preventDefault(); if (hasFolder) { setDragOver({ id: f.id, zone: zoneOf(e) }); } else { e.currentTarget.style.outline = `1px dashed ${T.accent}`; e.currentTarget.style.background = withA(T.accent, .2); } }} onDragLeave={e=>{ e.currentTarget.style.outline='none'; e.currentTarget.style.background = isActive ? withA(swatch,.16) : idleBg; setDragOver(d => d && d.id === f.id ? null : d); }} onDrop={(e)=>{ e.currentTarget.style.outline='none'; e.currentTarget.style.background = isActive ? withA(swatch,.16) : idleBg; const zone = zoneOf(e); setDragOver(null); const folderId = e.dataTransfer.getData('folder-id'); if (folderId) { if (isAll) onMoveFolderToParent(folderId, 'root'); else handleFolderDrop(folderId, f.id, zone); return; } const raw = e.dataTransfer.getData('note-ids'); if (raw && !isAll) { const ids = raw.split(',').filter(Boolean); if (ids.length > 1 && onDropNotesOnFolder) onDropNotesOnFolder(ids, f.id); else if (ids.length === 1) onDropNoteOnFolder(ids[0], f.id); } }} onClick={()=>setCurrentFolder(f.id)} onDoubleClick={()=>!isAll && setRenamingFolder(f.id)} onContextMenu={onRowContextMenu} style={{ position:'relative', display:'flex', gap:10, padding:'11px 12px', marginBottom:6, marginLeft: indent, borderRadius: isTerm?2:8, background: isDropTarget ? withA(T.accent, .22) : (isActive ? withA(swatch,.16) : idleBg), cursor: isAll ? 'pointer' : 'grab', boxShadow: zoneShadow, transition:'background .1s', }} onMouseEnter={e=>{ if(!isActive && !isDropTarget) e.currentTarget.style.background = rowHoverBg; }} onMouseLeave={e=>{ if(!isActive && !isDropTarget) e.currentTarget.style.background = idleBg; }} > {isAll ? <div style={{width:4, borderRadius:2, background:swatch, flex:'none'}}/> : <button {...stripeProps} style={{ // 16px to hit, 4px in the layout: the negative margins give the // extra width (and the row's full height) back to the padding. position:'relative', flex:'none', alignSelf:'stretch', width:16, margin:'-11px -6px', padding:0, border:'none', background:'transparent', cursor:'pointer', }}> <div data-folder-stripe style={{position:'absolute', left:6, top:11, bottom:11, width:4, borderRadius:2, background:swatch}}/> </button>} <div style={{flex:1, minWidth:0, display:'flex', alignItems:'center', gap:10}}> {chevronSlot} {isAll ? <HomeIcon size={16} color={T.panelText}/> : <FolderIcon size={16} color={f.hue} fill={f.hue} open={isActive}/>} <div style={{flex:1, minWidth:0}}> {(!isAll && renamingFolder===f.id) ? ( <input autoFocus defaultValue={f.name} dir="auto" onClick={e=>e.stopPropagation()} onBlur={e=>{ onRenameFolder(f.id, e.target.value||f.name); setRenamingFolder(null); }} onKeyDown={e=>{ if(e.key==='Enter'){onRenameFolder(f.id, e.target.value||f.name); setRenamingFolder(null);} if(e.key==='Escape'){setRenamingFolder(null);}}} style={{width:'100%', background:'transparent', border:'none', outline:'none', color:T.panelText, fontSize:13, fontWeight:700, font:'inherit'}} /> ) : ( <div dir="auto" style={{fontSize:13, fontWeight:700, color:T.panelText, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontFamily: 'inherit'}}> {isAll ? 'All notes' : f.name} </div> )} <div style={{fontSize:11, color:T.muted, marginTop:2, fontFamily: 'inherit'}}> {count} {count===1?'note':'notes'} </div> </div> {!isAll && isActive && ( <button onClick={(e)=>{e.stopPropagation(); onDeleteFolder(f.id);}} title="Delete folder" onMouseEnter={e=>{ e.currentTarget.style.background = withA(T.danger, .22); e.currentTarget.style.color = T.danger; }} onMouseLeave={e=>{ e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = T.muted; }} style={{width:22, height:22, display:'grid', placeItems:'center', background:'transparent', border:'none', cursor:'pointer', color:T.muted, borderRadius:4, fontSize:14, lineHeight:1, padding:0, }}>×</button> )} </div> </div> ); }; return ( <> {!open && ( <button onClick={()=>setOpen(true)} {...hoverProps(T, T.panelBg)} style={{ position:'absolute', right:0, top:72, zIndex:19000, width:32, height:96, background:T.panelBg, color:T.panelText, border:`1px solid ${T.panelBorder}`, borderRight:'none', borderRadius: isTerm ? 2 : '10px 0 0 10px', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', fontSize:11, fontWeight:700, letterSpacing:1.5, boxShadow:'0 4px 14px rgba(0,0,0,.08)', }}> <span style={{writingMode:'vertical-rl', transform:'rotate(180deg)'}}>FOLDERS · {tree.length}</span> </button> )} {open && ( <div style={{ position:'absolute', right:0, top:62, bottom:36, width:300, background: isPaper ? T.drawerBg : T.panelBg, border:`1px solid ${isPaper ? withA(T.paperEdge, .18) : T.panelBorder}`, borderRadius: isTerm ? 2 : (isPaper ? 4 : 10), margin:'0 10px 0 0', display:'flex', flexDirection:'column', overflow:'hidden', zIndex:18000, boxShadow: isPaper ? T.drawerShadow : '0 10px 30px rgba(0,0,0,.12)', fontFamily: '"'+tweaks.font+'", system-ui, sans-serif', // SVG-noise paper grain for the paper variant backgroundImage: isPaper ? "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence baseFrequency='0.9' numOctaves='2' seed='3'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>\")" : undefined, }}> {/* Header */} {isPaper ? ( <div style={{ fontSize:14, fontWeight:700, color:T.drawerInk, padding:'12px 12px 10px', display:'flex', alignItems:'center', gap:8, borderBottom:`1px solid ${withA(T.paperEdge, .14)}`, }}> <span style={{flex:1}}>Folders</span> <button onClick={onCreateFolder} title="New folder" {...hoverProps(T)} style={{ width:24, height:24, background:'transparent', border:'none', cursor:'pointer', color:T.muted, fontSize:18, lineHeight:1, padding:0, borderRadius:4, }}>+</button> <button onClick={()=>setOpen(false)} title="Hide" {...hoverProps(T)} style={{ width:24, height:24, background:'transparent', border:'none', cursor:'pointer', color:T.muted, fontSize:16, lineHeight:1, padding:0, borderRadius:4, }}>›</button> </div> ) : ( <div style={{padding:'10px 12px', display:'flex', alignItems:'center', gap:8, borderBottom:`1px solid ${T.hairline}`}}> <div style={{fontSize:14, fontWeight:700, color:T.panelText, flex:1, letterSpacing:isTerm?0.5:0}}> {isTerm ? '// folders' : 'Folders'} </div> <button onClick={onCreateFolder} title="New folder" {...hoverProps(T)} style={{ width:24, height:24, background:'transparent', border:'none', cursor:'pointer', color:T.muted, fontSize:18, lineHeight:1, padding:0, borderRadius:4, }}>+</button> <button onClick={()=>setOpen(false)} title="Hide" {...hoverProps(T)} style={{ width:24, height:24, background:'transparent', border:'none', cursor:'pointer', color:T.muted, fontSize:16, lineHeight:1, padding:0, borderRadius:4, }}>›</button> </div> )} <div style={{ flex:1, overflow:'auto', padding: isPaper ? '2px 10px 10px' : '8px', }}> {renderRow({id:'root', name:'All notes'}, true)} {!isPaper && tree.length>0 && ( <div style={{fontSize:10, textTransform:'uppercase', letterSpacing:1.5, opacity:.5, padding:'12px 12px 6px', color:T.panelText}}> Your folders </div> )} {visibleRows.map(r => renderRow(folders[r.id], false, r.depth, r.hasChildren))} {/* Faint full-width affordance to create a folder, sitting in the empty space below the last folder row. The original "+ folder" button in the header still works. */} <button onClick={()=>onCreateFolder()} title="Create folder" style={{ width:'100%', height:30, marginTop: tree.length>0 ? 4 : 12, padding:'0 10px', borderRadius: isTerm ? 2 : (isPaper ? 3 : 6), background:'transparent', color:T.muted, border: `1px dashed ${isPaper ? withA(T.paperEdge, .28) : T.panelBorder}`, fontSize:12, fontWeight:600, cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', gap:6, transition:'background .12s, border-color .12s, color .12s, transform .12s', fontFamily: 'inherit', }} onMouseEnter={e=>{ e.currentTarget.style.background = hoverBg(T); e.currentTarget.style.borderColor = T.accent; e.currentTarget.style.color = T.panelText; e.currentTarget.style.transform = 'translateY(-1px)'; }} onMouseLeave={e=>{ e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = isPaper ? withA(T.paperEdge, .28) : T.panelBorder; e.currentTarget.style.color = T.muted; e.currentTarget.style.transform = 'translateY(0)'; }} > <span style={{fontSize:14, lineHeight:1, marginTop:-1}}>+</span> Create folder </button> </div> {folderMenu && !folderMenu.mode && ( <ContextMenu T={T} x={folderMenu.x} y={folderMenu.y} fixed onClose={()=>setFolderMenu(null)} items={[ {label:'Rename', onClick:()=>setRenamingFolder(folderMenu.folderId)}, {label:'New subfolder', onClick:()=>{ expandFolder(folderMenu.folderId); onCreateFolder(folderMenu.folderId); }}, // Swaps this menu for a second flat "pick the destination" // menu — a hover submenu opens to the right, and the drawer // hugs the window's right edge. {label:'Move to…', keepOpen:true, onClick:()=>setFolderMenu(m => m && ({...m, mode:'move'}))}, // Same swap, for the colour list (issue #55). {label:'Change color…', keepOpen:true, onClick:()=>setFolderMenu(m => m && ({...m, mode:'color'}))}, {divider:true}, {label:'Delete folder', destructive:true, onClick:()=>onDeleteFolder(folderMenu.folderId)}, ]} /> )} {folderMenu && folderMenu.mode === 'move' && ( <ContextMenu T={T} x={folderMenu.x} y={folderMenu.y} fixed onClose={()=>setFolderMenu(null)} items={(() => { const fid = folderMenu.folderId; const cur = folders[fid]; const targets = []; if (cur && cur.parent !== 'root') { targets.push({label:'Top level', onClick:()=>onMoveFolderToParent(fid, 'root')}); } for (const r of tree) { // Skip the folder itself, its current parent (no-op move), // and anything inside its own subtree (would form a cycle). if (!cur || r.id === fid || r.id === cur.parent) continue; if (!canMoveFolder(folders, fid, r.id)) continue; targets.push({ label: '  '.repeat(Math.min(r.depth, 5)) + folders[r.id].name, onClick: ()=>{ expandFolder(r.id); onMoveFolderToParent(fid, r.id); }, }); } return targets.length ? targets : [{label:'No other folder to move into', disabled:true}]; })()} /> )} {folderMenu && folderMenu.mode === 'color' && ( <ContextMenu T={T} x={folderMenu.x} y={folderMenu.y} fixed onClose={()=>setFolderMenu(null)} items={FOLDER_COLORS.map(c => ({ label: c.name, dot: c.hue, onClick: ()=>onRecolorFolder(folderMenu.folderId, c.hue), }))} /> )} {/* Footer: + new sticky */} <div style={{ padding: isPaper ? '10px 14px 14px' : '8px 12px', borderTop: isPaper ? `1px dashed ${withA(T.paperEdge, .28)}` : `1px solid ${T.hairline}`, background: T.drawerFootBg, fontSize:11, color:T.muted, display:'flex', alignItems:'center', gap:8, }}> {isPaper ? ( <button onClick={onCreateNote} onMouseEnter={e=>{ e.currentTarget.style.background = mixHex('#fdf4c5', '#000000', .10); }} onMouseLeave={e=>{ e.currentTarget.style.background = '#fdf4c5'; }} style={{ flex:1, height:30, background:'#fdf4c5', color:'#4a3a12', border:'1px solid rgba(120,80,40,.28)', borderRadius:6, padding:'0 12px', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', gap:6, fontSize:12, fontWeight:700, whiteSpace:'nowrap', boxShadow:'0 1px 0 #fff inset, 0 2px 0 rgba(60,40,20,.06), 0 6px 14px rgba(60,40,20,.08)', transition:'background .1s', }}> <span style={{fontSize:14, lineHeight:1, marginTop:-1}}>+</span> new sticky <kbd style={{fontFamily:'ui-monospace, monospace', fontSize:9, background:'rgba(60,40,20,.18)', color:'#4a3a12', padding:'1px 4px', borderRadius:3, marginLeft:2}}>N</kbd> </button> ) : ( <button onClick={onCreateNote} // A solid accent button can't take the translucent panel // hover; it shifts the accent itself instead — darker on the // light themes, lighter on the dark one. (issue #49) onMouseEnter={e=>{ e.currentTarget.style.background = mixHex(T.accent, isDarkSurface(T.panelBg) ? '#ffffff' : '#000000', .18); }} onMouseLeave={e=>{ e.currentTarget.style.background = T.accent; }} style={{ flex:1, height:28, padding:'0 10px', borderRadius: isTerm?2:6, background:T.accent, color:T.accentInk, border:'none', fontWeight:700, fontSize:12, cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', gap:6, transition:'background .1s', }}> <span style={{fontSize:14, lineHeight:1, marginTop:-1}}>+</span> new sticky <kbd style={{fontFamily:'ui-monospace, monospace', fontSize:9, background:'rgba(0,0,0,.18)', padding:'1px 4px', borderRadius:3, marginLeft:2}}>N</kbd> </button> )} </div> </div> )} </> ); } /* ==================================================================== */ /* TWEAK PANEL */ /* ==================================================================== */ function TweakPanel({T, tweaks, update, onClose, onImportFromImage}) { const act = (fn) => () => { onClose && onClose(); fn(); }; return ( <div style={{ position:'fixed', right:16, bottom:44, width:280, zIndex:90000, background:T.panelBg, color:T.panelText, borderRadius:12, border:`1px solid ${T.panelBorder}`, boxShadow:'0 20px 60px rgba(0,0,0,.25)', padding:14, fontFamily: '"'+tweaks.font+'", system-ui, sans-serif', }}> <div style={{fontWeight:700, fontSize:13, marginBottom:12, display:'flex', alignItems:'center', gap:8}}> <span style={{width:8, height:8, borderRadius:'50%', background:T.accent}}/>Preferences {onClose && ( <button onClick={onClose} aria-label="Close preferences" {...hoverProps(T)} style={{ marginLeft:'auto', background:'none', border:'none', cursor:'pointer', fontSize:16, lineHeight:1, color:T.panelText, opacity:.6, padding:2, borderRadius:4, }}>×</button> )} </div> <Label>Visual style</Label> <Segmented T={T} value={tweaks.theme} onChange={v=>update({theme:v})} options={[ {id:'paper',label:'Paper'},{id:'flat',label:'Flat'},{id:'terminal',label:'Terminal'} ]}/> {/* Independent of the style (#43): every style has a light and a dark palette, and System follows the OS light/dark setting live. */} <Label>Appearance</Label> <Segmented T={T} value={tweaks.appearance} onChange={v=>update({appearance:v})} options={[ {id:'light',label:'Light'},{id:'dark',label:'Dark'},{id:'system',label:'System'} ]}/> <Label>Font</Label> <Segmented T={T} value={tweaks.font} onChange={v=>update({font:v})} options={[ {id:'Inter',label:'Inter'},{id:'Source Serif 4',label:'Serif'},{id:'IBM Plex Mono',label:'Mono'},{id:'Caveat',label:'Handwritten'} ]}/> <Label>Note text size</Label> <TextSizeSlider T={T} value={tweaks.noteTextSize} onChange={v=>update({noteTextSize:v})}/> <Label>Density</Label> <Segmented T={T} value={tweaks.density} onChange={v=>update({density:v})} options={[ {id:'compact',label:'Compact'},{id:'cozy',label:'Cozy'},{id:'spacious',label:'Spacious'} ]}/> <Label>Link overlay</Label> <div style={{display:'flex', alignItems:'center', gap:8}}> <input type="checkbox" checked={tweaks.showLinks} onChange={e=>update({showLinks:e.target.checked})}/> <span style={{fontSize:12}}>Show link arrows between notes</span> </div> <Label>Note rotation (paper theme)</Label> <div style={{display:'flex', alignItems:'center', gap:8}}> <input type="checkbox" checked={tweaks.tilt !== false} onChange={e=>update({tilt:e.target.checked})}/> <span style={{fontSize:12}}>Tilt notes at a slight angle</span> </div> <Label>Note titles</Label> <div style={{display:'flex', alignItems:'center', gap:8}}> <input type="checkbox" checked={!!tweaks.hideNoteTitles} onChange={e=>update({hideNoteTitles:e.target.checked})}/> <span style={{fontSize:12}}>Hide title on every note</span> </div> {!!window.stickyAPI && ( <Fragment> <Label>Menu bar</Label> <div style={{display:'flex', alignItems:'center', gap:8}}> <input type="checkbox" checked={!!tweaks.showMenuBar} onChange={e=>update({showMenuBar:e.target.checked})}/> <span style={{fontSize:12}}>Always show the menu bar</span> </div> </Fragment> )} {onImportFromImage && ( <Fragment> <Label>More</Label> <PanelAction T={T} onClick={act(onImportFromImage)}>Import notes from image…</PanelAction> </Fragment> )} </div> ); } // A full-width text row inside the Preferences panel — the menu bar is // hidden by default (#42) and this action is worth surfacing where people // will find it. function PanelAction({T, onClick, children}) { return ( <button onClick={onClick} {...hoverProps(T)} style={{ display:'block', width:'100%', textAlign:'left', padding:'7px 8px', background:'transparent', border:'none', color:T.panelText, font:'inherit', fontSize:12, cursor:'pointer', borderRadius:6, }}> {children} </button> ); } function Label({children}) { return <div style={{fontSize:11, textTransform:'uppercase', letterSpacing:1, opacity:.6, margin:'12px 0 6px'}}>{children}</div>; } function Segmented({T, value, onChange, options}) { return ( <div style={{display:'flex', background: withA(hoverInk(T), .07), padding:2, borderRadius:8, border:`1px solid ${T.panelBorder}`, gap:2}}> {options.map(o => { const active = value===o.id; // Only the inactive segments take a hover — repainting the selected // one would read as "this is now something else". (issue #49) return ( <button key={o.id} onClick={()=>onChange(o.id)} aria-pressed={active} onMouseEnter={e=>{ if(!active) e.currentTarget.style.background = hoverBg(T); }} onMouseLeave={e=>{ if(!active) e.currentTarget.style.background = 'transparent'; }} style={{ flex:1, border:'none', padding:'6px 8px', fontSize:12, borderRadius:6, background: active ? T.panelBg : 'transparent', boxShadow: active ? `0 1px 2px rgba(0,0,0,.08), 0 0 0 1px ${T.panelBorder}` : 'none', color:T.panelText, fontWeight: active?600:500, cursor:'pointer', transition:'background .1s', }}>{o.label}</button> ); })} </div> ); } // Preferences → Note text size (#67): a slider over the steps, from a small // A to a big A, with the size read out beside it. It snaps to the steps, and // the arrow keys move it one step, as on any range input. The notes follow // it live while it is dragged. function TextSizeSlider({T, value, onChange}) { const pct = Math.round(noteTextScale({noteTextSize: value}) * 100); const glyph = (size) => ( <span aria-hidden="true" style={{flex:'none', fontSize:size, fontWeight:600, lineHeight:'20px', color:T.panelText}}>A</span> ); return ( <div role="group" aria-label="Note text size" style={{display:'flex', alignItems:'center', gap:8, padding:'2px 4px'}}> {glyph(11)} <input type="range" min={0} max={NOTE_TEXT_SIZES.length - 1} step={1} value={noteTextSizeStep(value)} onChange={e=>onChange(NOTE_TEXT_SIZES[Number(e.target.value)])} aria-label="Note text size" aria-valuetext={`${pct}%`} style={{flex:1, minWidth:0, margin:0, accentColor:T.accent, cursor:'pointer'}}/> {glyph(16)} <output aria-live="polite" style={{flex:'none', width:38, textAlign:'right', fontSize:12, fontWeight:600, fontVariantNumeric:'tabular-nums', color:T.panelText}}>{pct}%</output> </div> ); } /* ==================================================================== */ /* STATUS BAR */ /* ==================================================================== */ function StatusBar({T, tweaks, folderName, noteCount, folderCount, onOpenPrefs}) { return ( <div style={{ position:'absolute', left:0, right:0, bottom:0, height:28, background:T.panelBg, borderTop:`1px solid ${T.panelBorder}`, display:'flex', alignItems:'center', padding:'0 14px', gap:16, fontSize:11, color:T.muted, zIndex:20000, fontFamily: 'inherit', }}> <span>in: {folderName}</span> <span style={{opacity:.4}}>·</span> <span>{noteCount} note{noteCount===1?'':'s'}</span> <span style={{opacity:.4}}>·</span> <span>{folderCount} subfolder{folderCount===1?'':'s'}</span> <div style={{flex:1}}/> <button onClick={onOpenPrefs} title="Preferences (Ctrl+,)" style={{ background:'transparent', border:'none', padding:0, margin:0, font:'inherit', color:T.muted, cursor:'pointer', }} onMouseEnter={(e)=>{ e.currentTarget.style.textDecoration='underline'; e.currentTarget.style.color=T.panelText; }} onMouseLeave={(e)=>{ e.currentTarget.style.textDecoration='none'; e.currentTarget.style.color=T.muted; }} >preferences</button> <span style={{opacity:.4}}>·</span> <a href="https://github.com/faridjaff/StickyNotesCanvas" target="_blank" rel="noopener noreferrer" style={{ color:T.muted, textDecoration:'none', cursor:'pointer', display:'inline-flex', alignItems:'center', gap:4, }} onMouseEnter={(e)=>{ e.currentTarget.style.textDecoration='underline'; e.currentTarget.style.color=T.panelText; }} onMouseLeave={(e)=>{ e.currentTarget.style.textDecoration='none'; e.currentTarget.style.color=T.muted; }} > <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> <path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> </svg> github </a> <span style={{opacity:.4}}>·</span> <span>auto-saved</span> <span style={{opacity:.4}}>·</span> <span title="This app only stores your notes locally on your device — no cloud sync, no account.">local only</span> </div> ); } Object.assign(window, { AppGlyph, ColorDots, ConfirmDialog, ContextMenu, DataFolderChoiceDialog, DataFolderDialog, DataFolderUnavailable, Desktop, EmptyState, FolderIcon, FolderTree, FoldersDrawer, HomeIcon, IMPORT_FROM_IMAGE_PROMPT, ImportFromImageDialog, InfoDialog, KeyHint, Label, Loading, MOBILE_BANNER_DISMISSED_KEY, MOBILE_BANNER_MAX_WIDTH, MobileDemoBanner, PanelAction, PasteErrorToast, Segmented, StatusBar, StickyNote, TextSizeSlider, TopChrome, TweakPanel, UpdateBanner, btnS, kbdS, zBtn });