const { useState, useEffect, useRef, useMemo, useCallback, Fragment } = React;
/* ==================================================================== */
/* APP */
/* ==================================================================== */
function App() {
// Restoring a backup replaces the ENTIRE store, so every entry point
// (Backup dropdown and Electron's File menu) funnels through this confirm
// dialog first; only its Confirm button runs the actual import.
const [confirmRestore, setConfirmRestore] = useState(false);
const requestRestore = useCallback(() => setConfirmRestore(true), []);
const { store, setKey, exportNow, importNow, takeSnapshot, undo, redo,
dataFolder, switchDataFolder, recheckDataFolder } = useStickyStore(requestRestore);
const update = useUpdateCheck();
// Read here, above the loading gate, so the hook order never changes.
const systemDark = useSystemDark();
// File → "Import notes from image using your AI…". Simple modal that surfaces a
// copyable prompt template the user hands to an LLM along with an image;
// the LLM's reply is pasted here (Ctrl+V) and hits the existing paste
// handler. No network calls from the app itself — bring your own LLM.
const [importHelpOpen, setImportHelpOpen] = useState(false);
const openImportHelp = useCallback(() => setImportHelpOpen(true), []);
useEffect(() => {
if (!window.stickyAPI?.onMenuImportHelp) return;
const off = window.stickyAPI.onMenuImportHelp(openImportHelp);
return () => off && off();
}, [openImportHelp]);
// One-time "what's new" after an update — see whatsNewInfo. The recorded
// version updates unconditionally so the note shows exactly once.
const [whatsNew, setWhatsNew] = useState(null);
useEffect(() => {
const current = window.stickyAPI?.appVersion;
if (!current) return;
let seen = null;
try { seen = localStorage.getItem('stickies.whatsNewSeen'); } catch {}
try { localStorage.setItem('stickies.whatsNewSeen', WHATS_NEW_ID); } catch {}
setWhatsNew(whatsNewInfo(current, seen, window.stickyAPI.isFirstRun));
}, []);
// Data folder (issue #68) — File → Choose where notes are saved…, and the screen
// shown instead of the canvas when the chosen folder can't be reached or
// its notes.json can't be read.
// folderPrompt: { path, kind: 'existing' | 'fresh' } — the choice that
// needs the user: the folder already holds notes, or (with nothing
// loaded) it holds none.
const [folderPrompt, setFolderPrompt] = useState(null);
const [folderInfo, setFolderInfo] = useState(null);
const [dataFolderOpen, setDataFolderOpen] = useState(false);
useEffect(() => {
if (!window.stickyAPI?.onMenuDataFolder) return;
const off = window.stickyAPI.onMenuDataFolder(() => setDataFolderOpen(true));
return () => off && off();
}, []);
const loaded = !!store;
// "Try again" on the unreachable-folder screen.
const retryDataFolder = useCallback(async () => {
const f = await recheckDataFolder();
if (!f.available) {
setFolderInfo(f.reason === 'unreadable'
? { title: 'Where your notes are saved', message: "Still can't be read", detail: f.detail }
: { title: 'Where your notes are saved', message: 'Still out of reach', detail: f.path });
}
}, [recheckDataFolder]);
const applyDataFolder = useCallback(async (target, mode, data) => {
setFolderPrompt(null);
const res = await switchDataFolder(target, mode, data);
if (res && !res.ok) {
setFolderInfo({ title: 'Where your notes are saved', message: "Couldn't change where notes are saved", detail: res.error });
}
}, [switchDataFolder]);
// "Choose folder…". Called with no argument it opens the picker in
// main (the file-chooser portal under flatpak); a caller that already
// holds the picker's answer ({ path, hasNotes }) passes it in and skips
// only the dialog — that is the e2e suite's way in. An empty folder gets
// a copy of the open notes; one that already has notes asks first.
const changeDataFolder = useCallback(async (picked) => {
let choice = picked;
if (!choice) {
const res = await window.stickyAPI.chooseDataFolder();
if (!res || res.canceled) return;
if (!res.ok) {
setFolderInfo({ title: 'Where your notes are saved', message: "Can't keep notes in that folder",
detail: res.path ? `${res.path}\n\n${res.error}` : res.error });
return;
}
// Picking the default folder itself means "the default location".
choice = res.isDefault ? { ...res, path: null } : res;
}
if (choice.isCurrent) {
// Picked the very folder that couldn't be reached: it may be back.
if (!loaded) { retryDataFolder(); return; }
setFolderInfo({ title: 'Where your notes are saved', message: 'Your notes are already kept there',
detail: choice.path == null ? dataFolder?.defaultPath : choice.path });
return;
}
if (!loaded) {
// The current folder couldn't be reached, so there is nothing open to
// copy or to keep: open what's there, or start fresh on request.
if (choice.hasNotes || choice.path == null) applyDataFolder(choice.path, 'use', null);
else setFolderPrompt({ path: choice.path, kind: 'fresh' });
return;
}
if (choice.hasNotes) setFolderPrompt({ path: choice.path, kind: 'existing' });
else applyDataFolder(choice.path, 'copy');
}, [loaded, applyDataFolder, retryDataFolder, dataFolder]);
const resetDataFolder = useCallback(() => changeDataFolder({
path: null, hasNotes: !!dataFolder?.defaultHasNotes, isCurrent: !!dataFolder?.isDefault,
}), [changeDataFolder, dataFolder]);
// Confirm a switch after the reload it causes — a copy looks exactly
// like the canvas before it.
useEffect(() => {
if (!loaded) return;
let moved = null;
try {
moved = JSON.parse(sessionStorage.getItem('stickies.dataFolderChanged') || 'null');
sessionStorage.removeItem('stickies.dataFolderChanged');
} catch {}
if (moved && moved.path) {
setFolderInfo({ title: 'Where your notes are saved',
message: moved.isDefault ? 'Notes are back in the default location' : 'Notes are now saved in this folder',
detail: moved.path });
}
}, [loaded]);
const folderDialogs = (
{dataFolderOpen && dataFolder && (
setDataFolderOpen(false)}
onChangeDataFolder={(picked) => { setDataFolderOpen(false); changeDataFolder(picked); }}
onResetDataFolder={() => { setDataFolderOpen(false); resetDataFolder(); }}
/>
)}
setFolderInfo(null)} />
{folderPrompt && folderPrompt.kind === 'existing' && (
setFolderPrompt(null)}
onUse={() => applyDataFolder(folderPrompt.path, 'use')}
onReplace={() => applyDataFolder(folderPrompt.path, 'copy')}
/>
)}
{folderPrompt && folderPrompt.kind === 'fresh' && (
setFolderPrompt(null)}
onConfirm={() => applyDataFolder(folderPrompt.path, 'copy', {})}
/>
)}
);
if (!store) {
if (dataFolder && !dataFolder.available) {
return (
changeDataFolder()}
onUseDefault={resetDataFolder}
/>
{folderDialogs}
);
}
return ;
}
return (
setWhatsNew(null)} />
setImportHelpOpen(false)} />
{folderDialogs}
{confirmRestore && (
setConfirmRestore(false)}
onConfirm={()=>{ setConfirmRestore(false); importNow(); }}
/>
)}
);
}
function AppInner({ store, setKey, exportNow, importNow, takeSnapshot, undo, redo, onImportFromImage, systemDark }) {
const tweaks = store.tweaks;
const folders = store.folders;
const notes = store.notes;
const links = store.links;
const currentFolder = store.cwd;
const setTweaks = (v) => setKey('tweaks', v);
const setFolders = (v) => setKey('folders', v);
const setNotes = (v) => setKey('notes', v);
const setLinks = (v) => setKey('links', v);
const setCurrentFolder = (v) => setKey('cwd', v);
const [tweakActive, updateTweak] = useTweakMode(setTweaks);
const [prefsOpen, setPrefsOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [query, setQuery] = useState('');
// Transient banner shown when Ctrl+V is pressed but the clipboard text
// doesn't parse as a sticky-notes payload. Auto-cleared by the effect
// below 5 seconds after it's set; manually cleared by the toast's × button.
const [pasteError, setPasteError] = useState(null);
useEffect(() => {
if (!pasteError) return;
const t = setTimeout(() => setPasteError(null), 10000);
return () => clearTimeout(t);
}, [pasteError]);
const [confirmDel, setConfirmDel] = useState(null);
const [renamingFolder, setRenamingFolder] = useState(null);
const zRef = useRef(10);
// Ctrl/Cmd+, toggles the preferences (tweaks) panel. In Electron, the
// accelerator is registered on the File → Preferences… menu item, which
// handles the keystroke before it reaches the window — the menu bar is
// hidden (#42) but the menu itself is still set, so the accelerator lives.
// This window-level handler is a fallback for the browser case (no
// stickyAPI).
useEffect(() => {
if (window.stickyAPI) return;
const onKey = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === ',') {
e.preventDefault();
setPrefsOpen(o => !o);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// Menu → Preferences… (the app menu's accelerator, or Alt to summon the
// hidden bar and click it) toggles the panel.
useEffect(() => {
if (!window.stickyAPI?.onMenuPreferences) return;
const off = window.stickyAPI.onMenuPreferences(() => setPrefsOpen(o => !o));
return () => off && off();
}, []);
// Suppress the host browser's Ctrl/Cmd+wheel page zoom across the whole
// app. We only preventDefault when a modifier is held so plain wheel
// events on text bodies / drawer scroll still work naturally; the canvas
// itself handles plain wheel = zoom in Desktop's onWheel.
useEffect(() => {
const guard = (e) => {
if (e.ctrlKey || e.metaKey) e.preventDefault();
};
window.addEventListener('wheel', guard, { passive: false });
return () => window.removeEventListener('wheel', guard);
}, []);
// Keep the window's menu bar in step with the preference (#42): hidden and
// Alt-summoned by default, pinned open when the user asks for it.
useEffect(() => {
window.stickyAPI?.setMenuBarVisible?.(!!tweaks.showMenuBar);
}, [tweaks.showMenuBar]);
// Style × appearance (#43). With Appearance on System, systemDark flips
// live with the OS and the whole chrome re-renders from the other palette.
const T = themeTokens(tweaks.theme, resolveAppearance(tweaks.appearance, systemDark));
// The global stylesheet is built once at load, long before a theme exists,
// so the one theme-derived value it needs — the accent for the keyboard
// focus ring — is published as a CSS variable here (issue #49).
useEffect(() => {
document.documentElement.style.setProperty('--sticky-accent', T.accent);
}, [T.accent]);
// The installed web app (#60) paints its title bar in the page's
// theme-color: keep that the colour of the top bar right under it.
useEffect(() => {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', T.panelBg);
}, [T.panelBg]);
/* ----- derived ----- */
const isAll = currentFolder==='root';
// Notes visible on the canvas. A folder view rolls up its whole subtree —
// the folder's own notes plus everything in nested subfolders — and also
// surfaces any pinned note from elsewhere ("follow me across folders").
const visibleFolderIds = useMemo(() =>
isAll ? null : folderSubtreeIds(folders, currentFolder),
[folders, currentFolder, isAll]);
const folderNotes = useMemo(() =>
isAll ? notes : notes.filter(n => visibleFolderIds.has(n.folder) || n.pinned),
[notes, visibleFolderIds, isAll]);
const filteredNotes = useMemo(() => {
if (!query.trim()) return folderNotes;
const q = query.toLowerCase();
return folderNotes.filter(n => (n.title+' '+n.body).toLowerCase().includes(q));
}, [folderNotes, query]);
/* ----- actions ----- */
// Always derive the new z from the actual current max(notes.z) instead of
// trusting zRef alone — zRef can drift if any other code path mutates a
// note's z directly (e.g., group-drag promotion). Take the max of zRef+1
// and observed-max+1 so we're guaranteed to land above everything visible.
const bringToFront = (id) => {
setNotes(ns => {
const observedMax = ns.reduce((m, n) => Math.max(m, n.z || 0), 0);
const newZ = Math.max(zRef.current + 1, observedMax + 1);
zRef.current = newZ;
return ns.map(n => n.id === id ? {...n, z: newZ} : n);
});
};
// Bring an entire group to the top, preserving the relative ordering
// among its members (oldest stays under newest within the group). Used
// by the multi-note drag path so no group member ends up beneath an
// unselected note. Single bringToFront has the same out-of-sync guard
// built in, so this also stays correct under any z mutation.
const bringGroupToFront = (ids) => {
if (!ids || ids.length === 0) return;
const idSet = new Set(ids);
setNotes(ns => {
const inGroup = ns.filter(x => idSet.has(x.id))
.sort((a, b) => (a.z || 0) - (b.z || 0));
if (inGroup.length === 0) return ns;
const observedMax = ns.reduce((m, x) => Math.max(m, x.z || 0), 0);
const baseZ = Math.max(zRef.current, observedMax);
const newZ = new Map();
inGroup.forEach((x, i) => newZ.set(x.id, baseZ + 1 + i));
zRef.current = baseZ + inGroup.length;
return ns.map(x => newZ.has(x.id) ? {...x, z: newZ.get(x.id)} : x);
});
};
const focusNote = (id) => { bringToFront(id); setSelectedIds(new Set([id])); };
// applyNotePatch stamps `updated` when the title or body changes (#40).
const updateNote = (id, patch) => setNotes(ns => ns.map(n => n.id===id ? applyNotePatch(n, patch, new Date().toISOString()) : n));
const deleteNote = (id) => { takeSnapshot(); setNotes(ns => ns.filter(n => n.id!==id)); setConfirmDel(null); };
// Delete/Backspace on the selection. Batch multi-delete: one snapshot
// covers the notes + link cleanup so a single Ctrl+Z reverts the whole
// delete, not just the links.
const deleteNotes = (ids) => {
const doomed = new Set(ids);
takeSnapshot();
setNotes(ns => ns.filter(n => !doomed.has(n.id)));
setLinks(ls => ls.filter(l => !doomed.has(l.from) && !doomed.has(l.to)));
setSelectedIds(new Set());
setConfirmDel(null);
};
const updateFolder = (id, patch) => setFolders(fs => ({...fs, [id]: {...fs[id], ...patch}}));
const createNote = (x, y) => {
const id = uid('n');
const colors = NOTE_COLORS.filter(c=>c.id!=='white');
const color = colors[Math.floor(Math.random()*colors.length)].id;
const targetFolder = isAll
? (Object.keys(folders).find(k => k!=='root') || 'root')
: currentFolder;
// Only use x/y if they're real numbers (buttons pass event objects)
const nx = typeof x === 'number' ? x : (120 + Math.random()*240);
const ny = typeof y === 'number' ? y : (100 + Math.random()*180);
const now = new Date().toISOString();
const n = { id, folder: targetFolder, title:'New note', body:'', color,
x: nx, y: ny, w:260, h:180, pinned:false, created: now, updated: now };
takeSnapshot();
setNotes(ns => [...ns, n]);
setTimeout(()=>focusNote(id), 0);
};
const folderOrder = store.folderOrder;
const setFolderOrder = (v) => setKey('folderOrder', v);
// With no argument (toolbar/drawer "+" buttons) the folder lands at the
// top level; the drawer's "New subfolder" context-menu item passes the
// parent folder's id. The guard also swallows the event objects that
// onClick handlers pass when called directly.
const createFolder = (parentId) => {
const parent = (typeof parentId === 'string' && parentId !== 'root' && folders[parentId])
? parentId : 'root';
const id = uid('f');
const hue = FOLDER_HUES[Object.keys(folders).length % FOLDER_HUES.length];
setFolders(fs => ({...fs, [id]: { id, name: parent==='root' ? 'New folder' : 'New subfolder', parent, hue }}));
setFolderOrder(order => [...(order || []), id]);
setCurrentFolder(id);
setRenamingFolder(id);
};
const deleteFolder = (id) => {
// Deleting a folder removes its whole subtree: nested subfolders, every
// note inside any of them, and their folderOrder entries. One snapshot
// covers it all so a single Ctrl+Z restores everything together.
const doomed = folderSubtreeIds(folders, id);
takeSnapshot();
setFolders(fs => {
const next = {...fs};
for (const fid of doomed) delete next[fid];
return next;
});
setNotes(ns => ns.filter(n => !doomed.has(n.folder)));
setFolderOrder(order => (order || []).filter(fid => !doomed.has(fid)));
if (doomed.has(currentFolder)) setCurrentFolder('root');
setConfirmDel(null);
};
// Re-parent a folder (drag into another folder's row, drop on "All notes",
// or the context menu's "Move to…"). canMoveFolder refuses moves into the
// folder's own subtree, so the tree can never acquire a cycle.
const moveFolderToParent = (id, parentId) => {
if (!canMoveFolder(folders, id, parentId)) return;
if (folders[id].parent === parentId) return;
takeSnapshot();
updateFolder(id, { parent: parentId });
};
// The drawer's "Change color…" (issue #55). Undoable like a move; picking
// the colour the folder already has is not a change and records nothing.
const recolorFolder = (id, hue) => {
if (!folders[id] || folders[id].hue === hue) return;
takeSnapshot();
updateFolder(id, { hue });
};
const moveNoteToFolder = (noteId, folderId) => {
// Only snapshot if the folder is actually changing — drag-to-same-folder
// (e.g. a header drag that hovers a drop zone briefly) shouldn't log an
// undoable step. This mirrors the "pin-drop across folders WITHOUT a
// folder change" exclusion in the task spec.
const current = notes.find(n => n.id === noteId);
if (!current || current.folder === folderId) return;
takeSnapshot();
setNotes(ns => ns.map(n => n.id===noteId ? {...n, folder: folderId, x: 80+Math.random()*100, y: 80+Math.random()*80} : n));
};
// Batch move for multi-selection drag. Preserves relative positions of
// the moved cluster so dropping N notes on a folder lands them in the
// same arrangement near the target folder's top-left.
const moveNotesToFolder = (noteIds, folderId) => {
if (!noteIds || !noteIds.length) return;
const idSet = new Set(noteIds);
// Skip if NO selected note would actually change folder — mirrors the
// single-move guard above. If even one crosses folders, we snapshot once
// for the whole batch so Ctrl+Z reverts the entire cluster move.
const anyCrossing = notes.some(n => idSet.has(n.id) && n.folder !== folderId);
if (!anyCrossing) return;
takeSnapshot();
setNotes(ns => {
const targets = ns.filter(n => idSet.has(n.id));
if (!targets.length) return ns;
const minX = Math.min(...targets.map(n => n.x));
const minY = Math.min(...targets.map(n => n.y));
const baseX = 80 + Math.random() * 100;
const baseY = 80 + Math.random() * 80;
return ns.map(n => idSet.has(n.id)
? { ...n, folder: folderId, x: n.x - minX + baseX, y: n.y - minY + baseY }
: n);
});
setSelectedIds(new Set());
};
/* ----- copy / paste ----- */
// Resolve which notes a copy action should target. If a specific noteId is
// passed (from a context-menu Copy) and that note is part of the current
// multi-selection, copy the whole selection. Otherwise copy just that note.
// With no noteId, copy whatever is selected.
const resolveCopyIds = (noteId) => {
if (noteId) {
if (selectedIds.has(noteId) && selectedIds.size > 1) return [...selectedIds];
return [noteId];
}
return selectedIds.size ? [...selectedIds] : [];
};
// Returns true on success, false if the clipboard write failed (no user
// gesture, denied permission, etc.) or if there was nothing to copy. Cut
// depends on this so it can refuse to delete the originals when the copy
// half didn't actually land in the clipboard.
const copySelected = async (noteId) => {
const ids = resolveCopyIds(noteId);
if (!ids.length) return false;
const idSet = new Set(ids);
// Preserve canvas (z-order) order so the human-readable text reads
// top-to-bottom roughly as the user sees the cluster.
const ordered = notes.filter(n => idSet.has(n.id));
// Bundle the pictures these notes reference so they survive the paste
// (#38). Best effort on top of the copy that already worked: if the
// bytes can't be read, or the set is over the clipboard budget, the
// notes still copy with their references alone, exactly as before.
let images = null;
try {
const names = imageRefsInNotes(ordered);
if (names.length && window.stickyAPI?.readImages) {
const res = await window.stickyAPI.readImages(names);
if (res?.ok) images = res.images;
}
} catch (e) { console.warn('[copy] images', e); }
try {
await navigator.clipboard.writeText(notesToClipboardText(ordered, links, images));
return true;
} catch (e) {
return false;
}
};
// `given` is clipboard text a caller has already read — a note's
// context-menu "Paste" (#59) reads it in the main process and hands the
// app's copied-notes payloads over here.
const pasteFromClipboard = async (given) => {
let text = '';
if (typeof given === 'string') text = given;
else try { text = await navigator.clipboard.readText(); } catch { return; }
if (!text) return; // empty clipboard — silent (no intent)
// Plain text with no sticky-notes marker anywhere is not a failed
// import — it's text. Make a note out of it where the user is looking
// (#29). A marker that IS present but unusable still falls through to
// the error toasts below.
if (canvasPasteAction(text) === 'note') {
const v = store.view || { x: 0, y: 0, z: 1 };
const palette = NOTE_COLORS.filter(c => c.id !== 'white');
zRef.current += 1;
const now = new Date().toISOString();
const note = {
id: uid('n'),
folder: isAll ? (Object.keys(folders).find(k => k!=='root') || 'root') : currentFolder,
title: '', // body only — plain text carries no title
body: text.replace(/\r\n?/g, '\n'),
color: palette[Math.floor(Math.random() * palette.length)].id,
w: 260, h: 180,
x: (80 + Math.random() * 100 - v.x) / v.z,
y: (80 + Math.random() * 80 - v.y) / v.z,
pinned: false, z: zRef.current,
created: now, updated: now,
};
takeSnapshot();
setNotes(ns => [...ns, note]);
setSelectedIds(new Set([note.id]));
return;
}
const payload = clipboardTextToNotes(text);
if (!payload) {
// Clipboard had text, but no sticky-notes marker / invalid JSON.
// Most common case for this branch is an LLM that produced output in
// the wrong format (trailing comma, single quotes, code fences).
setPasteError("Couldn't import — what's on your clipboard isn't in a format this app can read.");
return;
}
if (!payload.notes.length) {
setPasteError("Nothing to import — the clipboard contained an empty notes set.");
return;
}
// Pictures the payload carried (#38): store them under their content-hash
// names before the notes render, so the
resolves on first paint.
// Fails soft — main re-hashes every one and refuses the rest, and a
// picture that doesn't make it just leaves its note without the image.
if (Object.keys(payload.images || {}).length && window.stickyAPI?.writeImages) {
try { await window.stickyAPI.writeImages(payload.images); }
catch (err) { console.warn('[paste] images', err); }
}
// Anchor near the visible viewport's top-left (in screen pixels, then
// converted to world coords via the current pan/zoom) so pasted notes
// land where the user is looking instead of at world origin — which
// would silently appear off-screen if they've panned/zoomed away.
const v = store.view || { x: 0, y: 0, z: 1 };
const baseX = (80 + Math.random() * 100 - v.x) / v.z;
const baseY = (80 + Math.random() * 80 - v.y) / v.z;
const STEP = 24 / v.z;
const targetFolder = isAll
? (Object.keys(folders).find(k => k!=='root') || 'root')
: currentFolder;
// Map original-ids → freshly-minted ids so any links carried in the
// payload can be re-attached to the new notes.
const idMap = new Map();
const newIds = [];
// Excludes white; used when payload color is missing/invalid (e.g. LLM-pasted batches).
const palette = NOTE_COLORS.filter(c => c.id !== 'white');
const now = new Date().toISOString();
const fresh = payload.notes.map((p, idx) => {
const id = uid('n');
newIds.push(id);
if (p.id) idMap.set(p.id, id);
zRef.current += 1;
const validColor = NOTE_COLORS.some(c => c.id === p.color);
return {
id,
folder: targetFolder,
title: typeof p.title === 'string' ? p.title : '',
body: typeof p.body === 'string' ? p.body : '',
color: validColor ? p.color : palette[Math.floor(Math.random() * palette.length)].id,
w: typeof p.w === 'number' ? p.w : 260,
h: typeof p.h === 'number' ? p.h : 180,
x: baseX + STEP * idx,
y: baseY + STEP * idx,
pinned: !!p.pinned,
z: zRef.current,
created: now, updated: now,
};
});
// Single snapshot covers both the notes push and the follow-up links push
// below, so one Ctrl+Z reverts the whole paste (notes + restored links).
takeSnapshot();
setNotes(ns => [...ns, ...fresh]);
// Internal links (both endpoints in the payload) remap via idMap.
// Cross-boundary links keep the outside endpoint's ORIGINAL id and
// re-attach if that note still exists in the current store; if the
// outside note has been deleted between cut and paste, drop the link.
const existingIds = new Set(notes.map(n => n.id));
const freshLinks = (payload.links || []).map(l => {
const fromIn = idMap.has(l.from), toIn = idMap.has(l.to);
if (fromIn && toIn) return { id: uid('l'), from: idMap.get(l.from), to: idMap.get(l.to) };
if (fromIn && existingIds.has(l.to)) return { id: uid('l'), from: idMap.get(l.from), to: l.to };
if (toIn && existingIds.has(l.from)) return { id: uid('l'), from: l.from, to: idMap.get(l.to) };
return null;
}).filter(Boolean);
if (freshLinks.length) {
setLinks(ls => [...ls, ...freshLinks]);
}
setSelectedIds(new Set(newIds));
};
/* ----- import markdown files as notes (issue #44) -----
* The inverse of a note's context-menu "Download": one note per file, the
* file's contents its body. The frontmatter Download wrote (#40) gives the
* note back its title, colour and timestamps; a file without it is titled
* from its filename. Either way the round trip is lossless.
*/
// Everything after the picker: files whose contents have already been read
// ({ name, content }, or { name, error } for one main couldn't read) turn
// into notes. Split out from the picker half because a native file dialog
// is the one thing no test can drive, so this is where the coverage lives.
const importMarkdownFiles = (files) => {
const results = (Array.isArray(files) ? files : []).map(markdownFileToNote);
const usable = results.filter(r => !r.error);
const failed = results.filter(r => r.error);
if (usable.length) {
// Placement, colour and size follow the canvas paste exactly: anchored
// near the visible viewport (converted to world coords through the
// current pan/zoom) so the notes land where the user is looking, and
// cascaded so a multi-file import doesn't stack into one pile.
const v = store.view || { x: 0, y: 0, z: 1 };
const baseX = (80 + Math.random() * 100 - v.x) / v.z;
const baseY = (80 + Math.random() * 80 - v.y) / v.z;
const STEP = 24 / v.z;
const targetFolder = isAll
? (Object.keys(folders).find(k => k!=='root') || 'root')
: currentFolder;
const palette = NOTE_COLORS.filter(c => c.id !== 'white');
const now = new Date().toISOString();
const fresh = usable.map((r, idx) => {
zRef.current += 1;
return {
id: uid('n'),
folder: targetFolder,
title: r.title,
body: r.body,
// Colour and timestamps come from the file's frontmatter when it
// has them; anything it lacks is what a fresh note would get.
color: r.color || palette[Math.floor(Math.random() * palette.length)].id,
w: 260, h: 180,
x: baseX + STEP * idx,
y: baseY + STEP * idx,
pinned: false, z: zRef.current,
created: r.created || now,
updated: r.updated || now,
};
});
// One snapshot for the whole import, however many files were picked:
// a single Ctrl+Z takes all of them back out again.
takeSnapshot();
setNotes(ns => [...ns, ...fresh]);
setSelectedIds(new Set(fresh.map(n => n.id)));
}
// Unreadable / oversized / non-text files surface in the same toast the
// paste failures use instead of vanishing. The rest of the batch is
// still imported above.
if (failed.length) {
setPasteError(`Couldn't import ${failed.length} file${failed.length > 1 ? 's' : ''}:\n`
+ failed.map(r => `${r.name} — ${r.error}`).join('\n'));
}
};
// Desk context-menu "Import markdown file…". Called with no argument — as
// the menu does — it opens the picker: the native dialog under Electron,
// which main also reads the chosen files through (inside the flatpak
// sandbox the app has no filesystem permission of its own; the
// file-chooser portal grants the file to the main process), or a hidden
// file input in the web demo. A caller that already holds the files
// passes them in and skips the dialog — that is the e2e suite's way in.
const importMarkdown = async (files) => {
if (Array.isArray(files)) { importMarkdownFiles(files); return; }
const api = window.stickyAPI;
try {
if (api && api.importMarkdown) {
const res = await api.importMarkdown();
if (!res || res.canceled) return;
if (!res.ok) {
setPasteError(`Couldn't import — ${res.error || 'the file could not be read'}.`);
return;
}
importMarkdownFiles(res.files);
} else {
importMarkdownFiles(await pickMarkdownFiles());
}
} catch (err) {
setPasteError(`Couldn't import — ${(err && err.message) || 'the file could not be read'}.`);
}
};
/* ----- link operations ----- */
const addLink = (fromId, toId) => {
if (!fromId || !toId || fromId===toId) return;
setLinks(ls => {
// dedupe in either direction
if (ls.some(l => (l.from===fromId && l.to===toId) || (l.from===toId && l.to===fromId))) return ls;
return [...ls, { id: uid('l'), from: fromId, to: toId }];
});
};
const removeLink = (id) => setLinks(ls => ls.filter(l => l.id!==id));
const linksFor = (noteId) => links.filter(l => l.from===noteId || l.to===noteId);
const jumpToNote = (id) => {
const n = notes.find(x => x.id===id); if (!n) return;
// Only switch folders when the target is genuinely out of view — a note
// in a nested subfolder is already visible in an ancestor's rolled-up
// canvas, so jumping to it shouldn't yank the user into the subfolder.
if (!isAll && !visibleFolderIds.has(n.folder) && !n.pinned) setCurrentFolder(n.folder);
setTimeout(()=>focusNote(id), 50);
};
/* ----- link lines (computed in WORLD space from note positions) ----- */
const noteRefs = useRef({});
const linkLines = useMemo(() => {
if (!tweaks.showLinks) return [];
const byId = Object.fromEntries(notes.map(n => [n.id, n]));
const visible = new Set(filteredNotes.map(n => n.id));
// clip a line (from cx,cy to tx,ty) to the edge of the rect around (cx,cy)
const clipToRect = (cx, cy, w, h, tx, ty) => {
const dx = tx - cx, dy = ty - cy;
if (dx === 0 && dy === 0) return { x: cx, y: cy };
const hw = w/2, hh = h/2;
const tX = dx === 0 ? Infinity : hw / Math.abs(dx);
const tY = dy === 0 ? Infinity : hh / Math.abs(dy);
const t = Math.min(tX, tY);
return { x: cx + dx*t, y: cy + dy*t };
};
return links.map(l => {
const a = byId[l.from], b = byId[l.to];
if (!a || !b) return null;
if (!visible.has(l.from) || !visible.has(l.to)) return null;
const acx = a.x + a.w/2, acy = a.y + a.h/2;
const bcx = b.x + b.w/2, bcy = b.y + b.h/2;
const p1 = clipToRect(acx, acy, a.w, a.h, bcx, bcy);
const p2 = clipToRect(bcx, bcy, b.w, b.h, acx, acy);
return { id: l.id, x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y, fromId: l.from, toId: l.to };
}).filter(Boolean);
}, [links, notes, filteredNotes, tweaks.showLinks]);
// Ctrl/Cmd+Z → undo, Ctrl/Cmd+Shift+Z → redo. CRITICAL: when the keyboard
// event target is a text field (input, textarea, contentEditable), we must
// NOT preventDefault and NOT fire our undo/redo — the browser's native text
// undo needs to win for edits-in-progress on a note body/title. We gate on
// document.activeElement so a focused textarea swallows these chords even
// if the event was dispatched on document.body.
useEffect(() => {
const onKey = (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.key.toLowerCase() !== 'z') return;
const ae = document.activeElement;
if (ae) {
const tag = ae.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || ae.isContentEditable) return;
}
e.preventDefault();
if (e.shiftKey) { redo && redo(); }
else { undo && undo(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [undo, redo]);
/* ----- keyboard ----- */
useEffect(() => {
const h = (e) => {
// An open confirmation owns the keyboard. Whoever it opened under may
// still think they're typing (#47): another Backspace, a letter, an
// Escape must not act on the canvas behind it.
if (confirmDel) return;
if (e.target.matches('input, textarea, [contenteditable], [contenteditable="true"]')) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase()==='c') {
// Text highlighted in a note's preview body (#30): the native copy
// must win. preventDefault + copySelected here would clobber the
// clipboard with whole-note payloads instead of the selected text.
if (hasTextSelection(window.getSelection())) return;
if (selectedIds.size === 0) return;
e.preventDefault();
copySelected();
return;
}
if (mod && e.key.toLowerCase()==='v') {
e.preventDefault();
pasteFromClipboard();
return;
}
if (mod && e.key.toLowerCase()==='x') {
// Same guard as Ctrl+C: while text is highlighted, Ctrl+X must not
// cut (delete!) the selected notes out from under the user.
if (hasTextSelection(window.getSelection())) return;
if (selectedIds.size === 0) return;
e.preventDefault();
const ids = selectedIds;
// Only delete the originals if the clipboard write succeeded —
// otherwise the user would be left with neither a paste-able copy
// nor the original notes. Single snapshot covers the deletion of
// notes + orphan-link cleanup so Ctrl+Z reverts the whole cut.
(async () => {
const ok = await copySelected();
if (!ok) return;
takeSnapshot();
setNotes(ns => ns.filter(n => !ids.has(n.id)));
setLinks(ls => ls.filter(l => !ids.has(l.from) && !ids.has(l.to)));
setSelectedIds(new Set());
})();
return;
}
if (e.key.toLowerCase()==='n') { e.preventDefault(); createNote(); }
if (e.key.toLowerCase()==='f' && (e.metaKey||e.ctrlKey)) { e.preventDefault(); document.getElementById('qs')?.focus(); }
if (e.key==='Escape') { setSelectedIds(new Set()); }
if (e.key==='Delete' || e.key==='Backspace') {
// Same guard as Ctrl+C / Ctrl+X: text highlighted in a note means
// the user is working with that text — Delete reads as "erase it",
// Shift+Delete as "cut it" — never as "delete the whole note".
if (hasTextSelection(window.getSelection())) return;
// Only notes that are on the canvas right now. The selection
// survives switching folders and searching, so without this a key
// pressed in one folder deleted notes left selected in another.
const visible = new Set(filteredNotes.map(n => n.id));
const ids = [...selectedIds].filter(id => visible.has(id));
if (!ids.length) return;
e.preventDefault();
// Shift+Delete / Shift+Backspace delete at once; the bare key asks
// first, with the same dialog as the note's × button (#47).
if (e.shiftKey && !mod && !e.altKey) deleteNotes(ids);
else setConfirmDel({kind:'notes', ids});
}
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
});
// Breadcrumb path for nested folders: "Work / Sprints" instead of just
// "Sprints", shown in the top chrome and the status bar.
const currentFolderName = isAll ? 'All notes' : (folderPath(folders, currentFolder).join(' / ') || '');
// The delete confirmation counts several notes (Delete/Backspace on a
// multi-selection); a single one reads exactly as its × button's does.
const confirmMany = confirmDel?.kind==='notes' && confirmDel.ids.length > 1;
const confirmNoteId = confirmDel?.kind==='notes' ? confirmDel.ids[0] : confirmDel?.id;
return (
setPasteError(null)} />
updateFolder(id,{name})}
renamingFolder={renamingFolder} setRenamingFolder={setRenamingFolder}
onDeleteFolder={(id)=>setConfirmDel({kind:'folder', id})}
onMoveFolderToParent={moveFolderToParent}
onRecolorFolder={recolorFolder}
onDropNoteOnFolder={moveNoteToFolder}
onDropNotesOnFolder={moveNotesToFolder}
onCreateNote={createNote}
open={store.drawer}
setOpen={(v) => setKey('drawer', v)}
folderOrder={folderOrder}
setFolderOrder={setFolderOrder}
/>
setConfirmDel({kind:'note', id})}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
setNotes={setNotes}
jumpToNote={jumpToNote}
moveNoteToFolder={moveNoteToFolder}
moveNotesToFolder={moveNotesToFolder}
onCreateNote={createNote}
onImportMarkdown={importMarkdown}
onCopyNotes={copySelected}
onPasteNotes={pasteFromClipboard}
view={store.view}
setView={(v) => setKey('view', v)}
drawerOpen={store.drawer}
takeSnapshot={takeSnapshot}
/>
{confirmDel && (
n.id===confirmNoteId)?.title || 'note'}"?`}
body={confirmDel.kind==='folder'
? (folderSubtreeIds(folders, confirmDel.id).size > 1
? 'All subfolders and notes inside this folder will also be deleted.'
: 'All notes inside this folder will also be deleted.')
: confirmMany
? 'These notes will be permanently removed.'
: 'This note will be permanently removed.'}
onCancel={()=>setConfirmDel(null)}
onConfirm={()=>{
if (confirmDel.kind==='note') deleteNote(confirmDel.id);
else if (confirmDel.kind==='notes') deleteNotes(confirmDel.ids);
else deleteFolder(confirmDel.id);
setConfirmDel(null);
}}
/>
)}
{(tweakActive || prefsOpen) && setPrefsOpen(false)} onImportFromImage={onImportFromImage}/>}
setPrefsOpen(o=>!o)}
/>
);
}
/* ==================================================================== */
/* GLOBAL CSS */
/* ==================================================================== */
const globalStyle = document.createElement('style');
globalStyle.textContent = `
/* Preview <-> edit parity (issue #26): outside edit mode the rendered body
must occupy the same lines as the raw text in the editing textarea.
pre-wrap stops HTML from collapsing space runs / leading spaces;
overflow-wrap matches the textarea's UA break-word; zeroed block margins
make consecutive lines stack purely at line-height, so all vertical
rhythm comes from the text itself (blank lines arrive from mdToHtml as
explicit empty paragraphs). Every block element markdown-it can emit
keeps that flat rhythm: margin 0, no vertical padding surprises. */
.md-body { white-space: pre-wrap; overflow-wrap: break-word; }
.md-body h3 { font-size: 1.6em; margin: 0; font-weight: 700; line-height: 1.25; }
.md-body h4 { font-size: 1.35em; margin: 0; font-weight: 700; line-height: 1.3; }
.md-body h5 { font-size: 1.15em; margin: 0; font-weight: 700; opacity: .9; }
.md-body h6 { font-size: 1em; margin: 0; font-weight: 700; opacity: .75; text-transform: uppercase; letter-spacing: .05em; }
.md-body p { margin: 0; }
/* --note-text-scale is Preferences → Note text size (#67), set on the note
body. Text sizes here are em and follow it on their own; the list indent
is px, so it is scaled explicitly — the markers grow with the text and
were clipped off the note's left edge at 200% without this. */
.md-body ul { margin: 0; padding-left: calc(18px * var(--note-text-scale, 1)); }
.md-body ol { margin: 0; padding-left: calc(18px * var(--note-text-scale, 1)); }
.md-body ol ol { list-style: lower-alpha; }
.md-body ol ol ol { list-style: lower-roman; }
.md-body li { margin: 0; }
/* Task list items (#65). The checkbox is drawn in the note's own ink, so it
suits every theme, colour and font; it is inline and shorter than the
text, so the line keeps exactly its height (#26). In a bullet list it
takes the bullet's place — the hang is capped at the list indent, which
grows with the note text size (#67), so a big font can't push it out of
the list; numbered items keep their number. A ticked item's text dims a
little, never its sub-items. */
.md-body ul > li.task-list-item { list-style: none; }
.md-body .task-list-item-checkbox { appearance: none; box-sizing: border-box; position: relative;
width: .8em; height: .8em; margin: 0; margin-inline-end: .3em; padding: 0; vertical-align: -.1em;
font-size: inherit; color: inherit; background: transparent; cursor: pointer;
border: max(1px, .09em) solid currentColor; border-radius: .18em; opacity: .7; }
.md-body ul > li.task-list-item > .task-list-item-checkbox { margin-inline-start: max(-1.1em, calc(-18px * var(--note-text-scale, 1))); }
.md-body .task-list-item-checkbox:checked { opacity: 1; }
.md-body .task-list-item-checkbox:checked::after { content: ""; position: absolute; left: 32%; top: 6%;
width: 24%; height: 54%; border: solid currentColor; border-width: 0 max(1px, .1em) max(1px, .1em) 0;
transform: rotate(45deg); }
.md-body .task-list-item-checkbox:checked + .task-list-item-text { opacity: .6; }
.md-body blockquote { margin: 0; padding: 0 0 0 10px; border-left: 3px solid rgba(0,0,0,.25); opacity: .85; }
.md-body pre { margin: 0; padding: 6px 8px; background: rgba(0,0,0,.06); border-radius: 4px; overflow-x: auto; }
.md-body pre code { display: block; background: transparent; padding: 0; }
.md-body table { border-collapse: collapse; margin: 0; font-size: .95em; }
.md-body th, .md-body td { border: 1px solid rgba(0,0,0,.2); padding: 2px 6px; }
.md-body img { max-width: 100%; }
/* A --- divider is a whole line in the editor, so it is a whole line here
too, with the rule drawn through its middle (#63). height: 1px first is
the fallback for a browser without the lh unit (web demo): today's bare
rule, still visible. */
.md-body hr { border: none; margin: 0; height: 1px; height: 1lh; background: linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)) no-repeat center / 100% 1px; }
/* A diagram's labels are note text too (#67). zoom grows its natural size
with the text; the svg's own max-width: 100% still caps it at the width
of the note, so a big diagram in a narrow note is unchanged. */
.md-body .mermaid-diagram { margin: 0; zoom: var(--note-text-scale, 1); }
.md-body .mermaid-diagram svg { max-width: 100%; height: auto; }
.md-body code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: .88em; background: rgba(0,0,0,.06); padding: 1px 5px; border-radius: 3px; }
.md-body a.note-link { color: inherit; text-decoration: underline dotted; cursor: pointer; background: rgba(0,0,0,.05); padding: 0 3px; border-radius: 2px; }
/* Note paper is light in every theme (the dark appearances darken only the
chrome around the notes, #43), so a black overlay is the right
direction here — it just has to be a step you can actually see next to
the resting .05 (issue #49), and the underline goes solid to back it up. */
.md-body a.note-link:hover { background: rgba(0,0,0,.18); text-decoration: underline solid; }
/* Keyboard focus is never less visible than hover: an accent ring on top
of whatever hover paint the control already carries. Controls that set
their own inline outline (the selected note) keep it — inline wins. */
:focus-visible { outline: 2px solid var(--sticky-accent, #3584e4); outline-offset: 1px; }
kbd { font-family: ui-monospace, monospace; }
/* While a text selection drags inside one note body, nothing else on the
page is selectable — the browser then clamps the selection to that note
natively. Poor man's user-select: contain, which Chromium lacks. (#30) */
body.sel-lock * { user-select: none !important; }
body.sel-lock .sel-src, body.sel-lock .sel-src * { user-select: text !important; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(0,0,0,.15); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
`;
document.head.appendChild(globalStyle);
ReactDOM.createRoot(document.getElementById('root')).render();