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 (
);
}
/* ==================================================================== */
/* 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:
Notes separated by a line containing only: ---
2. A blank line, then this literal marker on its own line:
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.
Groceries
- eggs
- milk
- bread
---
Call mom
{"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":[]}
Project ideas
# Q1 priorities
- ship the **flatpak** release
- write \`import-from-image\` doc
- bump to v1.4.0 tag
{"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":[]}
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 \`\`, \`\`, and \`\` tags above are for your understanding only — do NOT include them in your output.
Output ONLY the three-section block.
`;
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 (
Import notes from a photo or screenshot using your AI
Paste this prompt into ChatGPT, Claude, or Gemini with your image,
then copy the response and {pasteShortcut} here.
);
}
/* ==================================================================== */
/* 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 (
Web demo — full app runs natively on Linux & Mac
Download →
);
}
/* ==================================================================== */
/* 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 (
;
}
/* ==================================================================== */
/* 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 && (
Click another note to link·Escto cancel
)}
{ 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 */}
)}
{deskMenu && (() => {
const rect = deskRef.current.getBoundingClientRect();
const sx = deskMenu.x - rect.left;
const sy = deskMenu.y - rect.top;
const world = toWorld(sx, sy);
return (
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 (
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 && (
{isRoot ? 'Your desktop is empty' : `"${folderName}" is empty`}
Press N to add a sticky note, or use New folder to organize by topic.
);
}
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 => ``).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//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 = ``;
} 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
//
; 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.
{ 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',
}}>
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',
}}>
{folder && }
{tweaks.hideNoteTitles ? (
) : editingTitle ? (
{ 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). */
{note.title || Untitled}
)}
{(() => {
// 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 (
);
})()}
{
// 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 ? (