import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo, useDeferredValue, forwardRef, useImperativeHandle } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { openUrl } from "@tauri-apps/plugin-opener"; import snarkdown from "snarkdown"; import "./App.css"; import readerCss from "./reader.css?raw"; import { autoListContinue } from "./auto-list.js"; import { useStore, actions as storeActions } from "./store"; declare const __BUILD_NUMBER__: string; /** Pre-process markdown to protect checkbox syntax from snarkdown's link parsing */ function preProcessCheckboxes(md: string): string { return md .replace(/^(\s*[-*])\s+\[ \]/gm, '$1 CBTODO') .replace(/^(\s*[-*])\s+\[x\]/gim, '$1 CBDONE'); } /** Post-process HTML to render checkbox placeholders as actual checkboxes */ function postProcessCheckboxes(html: string): string { return html .replace(/
  • \s*CBTODO\s*/g, '
  • ') .replace(/
  • \s*CBDONE\s*/g, '
  • '); } type ItemType = "page" | "text" | "tagset" | "image"; interface ItemMetadata { title?: string; selectedText?: string; sourceApp?: string; sharedAt?: string; _sync?: SyncMetadata; } interface SavedUrl { id: string; url: string; tags: string[]; saved_at: string; metadata?: ItemMetadata; } interface SavedText { id: string; content: string; tags: string[]; saved_at: string; metadata?: ItemMetadata; } interface SavedTagset { id: string; tags: string[]; saved_at: string; metadata?: ItemMetadata; } interface SavedImage { id: string; tags: string[]; saved_at: string; metadata?: ItemMetadata; content?: string; thumbnail?: string; // Base64-encoded thumbnail mime_type: string; width?: number; height?: number; } interface TagStats { name: string; frequency: number; lastUsed: string; frecencyScore: number; } interface SyncMetadata { createdBy?: string; createdAt?: number; modifiedBy?: string; modifiedAt?: number; } interface BidirectionalSyncResult { success: boolean; pulled: number; pushed: number; conflicts: number; message: string; } interface SyncStatus { configured: boolean; last_sync_time: string | null; pending_count: number; } interface ProfileEntry { id: string; name: string; createdAt: string; lastUsed: string; } interface SyncSettings { server_url: string; api_key: string; auto_sync: boolean; sync_interval_minutes: number; } interface ProfileInfo { currentProfileId: string; isProductionBuild: boolean; profiles: ProfileEntry[]; sync: SyncSettings; } // Unified item for combined list interface UnifiedItem { id: string; type: ItemType; url?: string; content?: string; tags: string[]; saved_at: string; metadata?: ItemMetadata; thumbnail?: string; mime_type?: string; width?: number; height?: number; } // ============================================================================ // Shared Editor Components // ============================================================================ // Clear button for inputs - round X icon interface ClearButtonProps { onClear: () => void; show: boolean; className?: string; } const ClearButton: React.FC = ({ onClear, show, className = '' }) => { if (!show) return null; return ( ); }; interface EditorOverlayProps { children: React.ReactNode | ((swipeHandlers: SwipeHandlers) => React.ReactNode); onDismiss: () => void; keyboardHeight: number; className?: string; } interface SwipeHandlers { onTouchStart: (e: React.TouchEvent) => void; onTouchMove: (e: React.TouchEvent) => void; onTouchEnd: () => void; } const SWIPE_THRESHOLD = 80; const EditorOverlay: React.FC = ({ children, onDismiss, keyboardHeight, className = '' }) => { const keyboardOpen = keyboardHeight > 0; // Track visual viewport height so the overlay never extends behind the keyboard. // visualViewport.height shrinks when the keyboard opens — we use it directly. const [viewportHeight, setViewportHeight] = useState(() => window.visualViewport ? window.visualViewport.height : window.innerHeight ); useEffect(() => { const vv = window.visualViewport; if (!vv) return; const onResize = () => setViewportHeight(vv.height); vv.addEventListener('resize', onResize); return () => vv.removeEventListener('resize', onResize); }, []); // Swipe-down-to-close state const [swipeOffset, setSwipeOffset] = useState(0); const swipeStartYRef = useRef(null); const cardRef = useRef(null); // Counter iOS visual viewport scrolling when switching focus between inputs // iOS automatically scrolls the viewport to bring focused inputs into view, // but our fixed layout already handles positioning - so we reset the scroll useEffect(() => { const viewport = window.visualViewport; if (!viewport) return; const handleScroll = () => { // If viewport scrolled, reset it - our layout handles input visibility if (viewport.offsetTop !== 0) { window.scrollTo(0, 0); } }; viewport.addEventListener('scroll', handleScroll); return () => viewport.removeEventListener('scroll', handleScroll); }, []); // Set --safe-area-top CSS variable for status bar / notch clearance. // On iOS: probe env(safe-area-inset-top) via a hidden div — this is the correct // notch/dynamic-island inset; set it immediately so CSS picks it up. // On Android: env(safe-area-inset-top) is not supported in WebView, so the probe // returns 0. The Kotlin bridge overwrites --safe-area-top with the real // WindowInsets value when the page loads. We must NOT default to a hard-coded // iOS-specific value (e.g. 40px) here — that leaks iOS-notch spacing onto // Android devices that have no notch. useEffect(() => { const root = document.documentElement; if (!root.style.getPropertyValue('--safe-area-top')) { // Probe env(safe-area-inset-top): non-zero on iOS, 0 on Android WebView. const probe = document.createElement('div'); probe.style.cssText = 'position:fixed;top:0;height:env(safe-area-inset-top,0px);visibility:hidden;pointer-events:none;'; document.body.appendChild(probe); const insetPx = probe.offsetHeight; document.body.removeChild(probe); // On iOS this is the notch height; on Android it's 0 (bridge sets real value). root.style.setProperty('--safe-area-top', `${insetPx}px`); } }, []); const handleTouchStart = (e: React.TouchEvent) => { swipeStartYRef.current = e.touches[0].clientY; }; const handleTouchMove = (e: React.TouchEvent) => { if (swipeStartYRef.current === null) return; const delta = e.touches[0].clientY - swipeStartYRef.current; // Only allow downward swipe if (delta > 0) { setSwipeOffset(Math.min(delta, SWIPE_THRESHOLD + 40)); } }; const handleTouchEnd = () => { if (swipeStartYRef.current === null) return; const wasSwiped = swipeOffset > SWIPE_THRESHOLD; swipeStartYRef.current = null; setSwipeOffset(0); if (wasSwiped) { onDismiss(); } }; const swipeHandlers: SwipeHandlers = { onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, }; const isRenderProp = typeof children === 'function'; return (
    e.target === e.currentTarget && onDismiss()} >
    0 ? `translateY(${swipeOffset}px)` : undefined }} > {/* When children is a render prop, the caller handles the drag handle */} {!isRenderProp && (
    )} {isRenderProp ? (children as (sh: SwipeHandlers) => React.ReactNode)(swipeHandlers) : children}
    ); }; interface TagsSectionProps { selectedTags: Set; availableTags: TagStats[]; tagInput: string; onTagInputChange: (value: string) => void; onToggleTag: (tagName: string) => void; onAddTag: () => void; placeholder?: string; } const TagsSection: React.FC = ({ selectedTags, availableTags, tagInput, onTagInputChange, onToggleTag, onAddTag, placeholder = "Add tag..." }) => { const tagInputRef = useRef(null); const refocusInput = () => { requestAnimationFrame(() => tagInputRef.current?.focus({ preventScroll: true })); }; // Prevent tag buttons/chips from stealing focus (avoids iOS keyboard bounce) const preventFocusLoss = (e: React.TouchEvent | React.MouseEvent) => { e.preventDefault(); }; // Filter available tags based on input and exclude already selected const unusedTags = availableTags.filter((tag) => !selectedTags.has(tag.name) && (!tagInput.trim() || tag.name.toLowerCase().includes(tagInput.toLowerCase().trim())) ); // Add collapsed class when no available tags to show const isCollapsed = unusedTags.length === 0 && availableTags.length === 0; return (
    {selectedTags.size > 0 && (
    {Array.from(selectedTags).map((tag) => ( {tag} ))}
    )}
    onTagInputChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); onAddTag(); refocusInput(); } }} onFocus={() => { // Counter iOS viewport scroll on focus requestAnimationFrame(() => window.scrollTo(0, 0)); }} placeholder={placeholder} autoCapitalize="none" autoCorrect="off" autoComplete="off" spellCheck={false} /> 0} onClear={() => onTagInputChange('')} />
    {unusedTags.length > 0 ? (
    {unusedTags.map((tag) => ( { onToggleTag(tag.name); onTagInputChange(''); refocusInput(); }} > {tag.name} ))}
    ) : (
    {availableTags.length === 0 ? (
    This is where your tags live - tag something!
    ) : tagInput.trim() ? (
    No matching tags
    ) : null}
    )}
    ); }; interface EditorButtonsProps { onSave: () => void; onCancel: () => void; onDelete?: () => void; saveLabel?: string; cancelLabel?: string; saveDisabled?: boolean; } const EditorButtons: React.FC = ({ onSave, onCancel, onDelete, saveLabel = "Save", cancelLabel = "Cancel", saveDisabled = false }) => (
    {onDelete && ( )}
    ); interface EditorLayoutProps { onDismiss: () => void; keyboardHeight: number; children?: React.ReactNode; tags?: TagsSectionProps; buttons: EditorButtonsProps; className?: string; headerActions?: React.ReactNode; } const EditorLayout: React.FC = ({ onDismiss, keyboardHeight, children, tags, buttons, className, headerActions }) => { const sepRef = useRef(null); const isDraggingSepRef = useRef(false); const dragStartYRef = useRef(0); const dragStartContentHeightRef = useRef(0); const getCard = () => sepRef.current?.closest('.editor-card') as HTMLElement | null; const handleSepDragStart = (clientY: number) => { const card = getCard(); if (!card) return; const contentEl = card.querySelector('.editor-segment-content') as HTMLElement | null; if (!contentEl) return; isDraggingSepRef.current = true; dragStartYRef.current = clientY; dragStartContentHeightRef.current = contentEl.offsetHeight; document.body.style.userSelect = 'none'; document.body.style.cursor = 'ns-resize'; document.body.dataset.resizing = 'true'; }; const handleSepDragMove = (clientY: number) => { if (!isDraggingSepRef.current) return; const card = getCard(); if (!card) return; const delta = clientY - dragStartYRef.current; const newHeight = Math.max(60, dragStartContentHeightRef.current + delta); card.style.setProperty('--segment-content-height', `${newHeight}px`); card.style.setProperty('--segment-content-flex', 'none'); }; const handleSepDragEnd = () => { isDraggingSepRef.current = false; document.body.style.userSelect = ''; document.body.style.cursor = ''; delete document.body.dataset.resizing; }; useEffect(() => { const onMouseMove = (e: MouseEvent) => handleSepDragMove(e.clientY); const onTouchMove = (e: TouchEvent) => { if (isDraggingSepRef.current && e.cancelable) e.preventDefault(); if (e.touches.length === 1) handleSepDragMove(e.touches[0].clientY); }; const onEnd = () => handleSepDragEnd(); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onEnd); document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onEnd); return () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onEnd); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onEnd); }; }, []); return ( {(swipeHandlers: SwipeHandlers) => ( <>
    {headerActions}
    {children}
    {tags && ( <>
    { e.preventDefault(); handleSepDragStart(e.clientY); }} onTouchStart={(e) => { handleSepDragStart(e.touches[0].clientY); }} >
    )}
    )} ); }; interface ResizableInputProps { value: string; onChange: (value: string) => void; placeholder?: string; autoFocus?: boolean; showClearButton?: boolean; onAutoSave?: (value: string) => void; autoCapitalize?: string; autoCorrect?: string; showUndoRedo?: boolean; } interface ResizableInputHandle { undo: () => void; redo: () => void; canUndo: boolean; canRedo: boolean; } const ResizableInput = forwardRef(({ value, onChange, placeholder = "Enter text...", autoFocus = false, showClearButton = true, onAutoSave, autoCapitalize: autoCapitalizeProp = "sentences", autoCorrect: autoCorrectProp = "on", showUndoRedo = true, }, ref) => { const [height, setHeight] = useState(null); const wrapperRef = useRef(null); const textareaRef = useRef(null); const isDraggingRef = useRef(false); const dragStartYRef = useRef(0); const dragStartHeightRef = useRef(0); // Undo/redo history const historyRef = useRef([value]); const historyIndexRef = useRef(0); const isUndoRedoRef = useRef(false); const [, forceRender] = useState(0); // Track value changes for undo history // NOTE: useLayoutEffect (not useEffect) is critical here. forceRender updates // undo/redo button disabled state, but useEffect fires AFTER paint, causing a // second re-render that resets cursor position on iOS WKWebView. useLayoutEffect // fires before paint, collapsing both renders into one visual update. // This is a partial fix — other async state updates (e.g. auto-save's // setOriginalEditValues/loadSavedTexts at 500ms) could still cause cursor jumps. // A full fix would save/restore selectionStart/End across all re-renders. useLayoutEffect(() => { if (isUndoRedoRef.current) { isUndoRedoRef.current = false; return; } const history = historyRef.current; const idx = historyIndexRef.current; // Only push if different from current position if (history[idx] !== value) { // Truncate any redo history historyRef.current = history.slice(0, idx + 1); historyRef.current.push(value); // Bound to 100 entries if (historyRef.current.length > 100) { historyRef.current.shift(); } historyIndexRef.current = historyRef.current.length - 1; forceRender(n => n + 1); } }, [value]); const handleUndo = () => { if (historyIndexRef.current > 0) { historyIndexRef.current--; isUndoRedoRef.current = true; onChange(historyRef.current[historyIndexRef.current]); forceRender(n => n + 1); } }; const handleRedo = () => { if (historyIndexRef.current < historyRef.current.length - 1) { historyIndexRef.current++; isUndoRedoRef.current = true; onChange(historyRef.current[historyIndexRef.current]); forceRender(n => n + 1); } }; useImperativeHandle(ref, () => ({ undo: handleUndo, redo: handleRedo, get canUndo() { return historyIndexRef.current > 0; }, get canRedo() { return historyIndexRef.current < historyRef.current.length - 1; }, })); // Min height for resize floor const minHeight = 60; // Find the .editor-segment-content ancestor (if inside EditorLayout) const getSegmentContent = () => { if (!wrapperRef.current) return null; return wrapperRef.current.closest('.editor-segment-content') as HTMLElement | null; }; const handleDragStart = (clientY: number) => { const segment = getSegmentContent(); if (segment) { // Inside EditorLayout: resize the segment split isDraggingRef.current = true; dragStartYRef.current = clientY; dragStartHeightRef.current = segment.offsetHeight; document.body.style.userSelect = 'none'; document.body.style.cursor = 'ns-resize'; document.body.dataset.resizing = 'true'; } else if (wrapperRef.current) { // Standalone: resize the textarea wrapper isDraggingRef.current = true; dragStartYRef.current = clientY; dragStartHeightRef.current = wrapperRef.current.offsetHeight; document.body.style.userSelect = 'none'; document.body.style.cursor = 'ns-resize'; document.body.dataset.resizing = 'true'; } }; const handleDragMove = (clientY: number) => { if (!isDraggingRef.current) return; const delta = clientY - dragStartYRef.current; const segment = getSegmentContent(); if (segment) { // Resize the editor-segment-content via CSS variable on .editor-card const card = segment.closest('.editor-card') as HTMLElement | null; if (card) { const newHeight = Math.max(minHeight, dragStartHeightRef.current + delta); card.style.setProperty('--segment-content-height', `${newHeight}px`); card.style.setProperty('--segment-content-flex', 'none'); } } else { const newHeight = Math.max(minHeight, dragStartHeightRef.current + delta); setHeight(newHeight); } }; const handleDragEnd = () => { isDraggingRef.current = false; document.body.style.userSelect = ''; document.body.style.cursor = ''; delete document.body.dataset.resizing; }; // Auto markdown list continuation on Enter (includes checkbox support) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'Enter') return; const textarea = textareaRef.current; if (!textarea) return; const { selectionStart, value: domValue } = textarea; const result = autoListContinue(domValue, selectionStart); if (!result) return; // Not a list line, let default Enter happen e.preventDefault(); onChange(result.newValue); requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = result.cursorPos; textareaRef.current.selectionEnd = result.cursorPos; } }); }; useEffect(() => { const handleMouseMove = (e: MouseEvent) => handleDragMove(e.clientY); const handleTouchMove = (e: TouchEvent) => { if (isDraggingRef.current && e.cancelable) e.preventDefault(); if (e.touches.length === 1) handleDragMove(e.touches[0].clientY); }; const handleEnd = () => handleDragEnd(); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleEnd); document.addEventListener('touchmove', handleTouchMove, { passive: false }); document.addEventListener('touchend', handleEnd); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleEnd); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleEnd); }; }, [minHeight]); // Auto-focus textarea on mount to transfer keyboard from trigger input useEffect(() => { if (!autoFocus) return; // Short delay lets layout settle before focusing const timer = setTimeout(() => { if (textareaRef.current) { textareaRef.current.focus(); // Place cursor at end of text const len = textareaRef.current.value.length; textareaRef.current.selectionStart = len; textareaRef.current.selectionEnd = len; } }, 50); return () => clearTimeout(timer); }, []); return ( <> {showUndoRedo &&
    }