experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(mobile): improve item card UI and keyboard stability

- Add auto-save when editing notes (debounced 500ms, silent save)
- Change Save button to "Done" for note editing
- Restore original content when canceling after auto-save
- More space at top of editor (5rem padding)
- Hide clear button when editing existing items
- Add swipe-down-to-close with dedicated drag handle
- Counter iOS visual viewport scrolling on input focus
- Use fixed keyboard height (350px) for layout stability
- Add empty tags messages ("Add some tags!", "No matching tags")
- Collapsed state for tag section when no tags exist
- Add version marker in header for build verification (v458)
- Native Objective-C swizzle disables input accessory view

+249 -16
+72 -1
backend/tauri-mobile/src/App.css
··· 40 40 padding: 0; 41 41 height: 100vh; 42 42 background-color: var(--dev-bg-light, #f6f6f6); 43 + overflow: hidden; 44 + } 45 + 46 + /* Prevent iOS from scrolling viewport when editor inputs are focused */ 47 + body.editor-open .app { 48 + position: fixed; 49 + top: 0; 50 + left: 0; 51 + right: 0; 52 + bottom: 0; 53 + } 54 + 55 + /* Disable iOS input behaviors that cause viewport shifting */ 56 + input, textarea { 57 + /* Prevent iOS from adding padding for input accessory view */ 58 + -webkit-touch-callout: none; 59 + } 60 + 61 + /* Force inputs to not trigger iOS scroll-into-view behavior */ 62 + .edit-overlay input, 63 + .edit-overlay textarea { 64 + /* Tell iOS these elements handle their own visibility */ 65 + scroll-margin: 0; 66 + scroll-padding: 0; 43 67 } 44 68 45 69 #root { ··· 2355 2379 align-items: flex-start; 2356 2380 justify-content: center; 2357 2381 padding: 0.5rem; 2358 - padding-top: calc(env(safe-area-inset-top, 0px) + 3.5rem); 2382 + padding-top: calc(env(safe-area-inset-top, 0px) + 5rem); 2383 + /* Prevent iOS from scrolling the viewport when keyboard appears */ 2384 + overflow: hidden; 2385 + overscroll-behavior: contain; 2359 2386 } 2360 2387 2361 2388 .edit-overlay.transition-padding { ··· 3184 3211 height: 20px; 3185 3212 cursor: pointer; 3186 3213 } 3214 + 3215 + /* Editor drag handle for swipe-to-close */ 3216 + .editor-drag-handle { 3217 + width: 100%; 3218 + padding: 8px 0; 3219 + display: flex; 3220 + align-items: center; 3221 + justify-content: center; 3222 + cursor: grab; 3223 + touch-action: none; 3224 + } 3225 + 3226 + .editor-drag-handle:active { 3227 + cursor: grabbing; 3228 + } 3229 + 3230 + .editor-drag-handle .drag-handle-bar { 3231 + width: 36px; 3232 + height: 5px; 3233 + background: rgba(0, 0, 0, 0.2); 3234 + border-radius: 2.5px; 3235 + } 3236 + 3237 + body.dark .editor-drag-handle .drag-handle-bar { 3238 + background: rgba(255, 255, 255, 0.3); 3239 + } 3240 + 3241 + /* Empty tags message */ 3242 + .tags-empty-message { 3243 + color: #888; 3244 + font-size: 0.85rem; 3245 + font-style: italic; 3246 + padding: 0.25rem 0; 3247 + } 3248 + 3249 + body.dark .tags-empty-message { 3250 + color: #666; 3251 + } 3252 + 3253 + /* Collapsed tag section when no tags exist */ 3254 + .editor-tags-section.collapsed .expandable-card-section:last-child { 3255 + min-height: 32px; 3256 + padding: 0.5rem; 3257 + }
+177 -15
backend/tauri-mobile/src/App.tsx
··· 1 - import { useState, useEffect, useRef } from "react"; 1 + import { useState, useEffect, useRef, useCallback } from "react"; 2 2 import { invoke } from "@tauri-apps/api/core"; 3 3 import "./App.css"; 4 4 ··· 140 140 className?: string; 141 141 } 142 142 143 - const EditorOverlay: React.FC<EditorOverlayProps> = ({ children, onDismiss, keyboardHeight, className = '' }) => { 144 - const keyboardAppearedRef = useRef(false); 145 - if (keyboardHeight > 0) keyboardAppearedRef.current = true; 143 + const SWIPE_THRESHOLD = 80; 144 + 145 + const EditorOverlay: React.FC<EditorOverlayProps> = ({ children, onDismiss, keyboardHeight: _keyboardHeight, className = '' }) => { 146 + // Use fixed keyboard padding to avoid any dynamic changes 147 + // Standard iOS keyboard is ~300px, we use 350 to be safe 148 + const FIXED_KEYBOARD_PADDING = 350; 149 + 150 + // Swipe-down-to-close state 151 + const [swipeOffset, setSwipeOffset] = useState(0); 152 + const swipeStartYRef = useRef<number | null>(null); 153 + const cardRef = useRef<HTMLDivElement>(null); 154 + 155 + // Counter iOS visual viewport scrolling when switching focus between inputs 156 + // iOS automatically scrolls the viewport to bring focused inputs into view, 157 + // but our fixed layout already handles positioning - so we reset the scroll 158 + useEffect(() => { 159 + const viewport = window.visualViewport; 160 + if (!viewport) return; 161 + 162 + const handleScroll = () => { 163 + // If viewport scrolled, reset it - our layout handles input visibility 164 + if (viewport.offsetTop !== 0) { 165 + window.scrollTo(0, 0); 166 + } 167 + }; 168 + 169 + viewport.addEventListener('scroll', handleScroll); 170 + return () => viewport.removeEventListener('scroll', handleScroll); 171 + }, []); 172 + 173 + const handleTouchStart = (e: React.TouchEvent) => { 174 + swipeStartYRef.current = e.touches[0].clientY; 175 + }; 176 + 177 + const handleTouchMove = (e: React.TouchEvent) => { 178 + if (swipeStartYRef.current === null) return; 179 + const delta = e.touches[0].clientY - swipeStartYRef.current; 180 + // Only allow downward swipe 181 + if (delta > 0) { 182 + setSwipeOffset(Math.min(delta, SWIPE_THRESHOLD + 40)); 183 + } 184 + }; 185 + 186 + const handleTouchEnd = () => { 187 + if (swipeStartYRef.current === null) return; 188 + const wasSwiped = swipeOffset > SWIPE_THRESHOLD; 189 + swipeStartYRef.current = null; 190 + setSwipeOffset(0); 191 + if (wasSwiped) { 192 + onDismiss(); 193 + } 194 + }; 146 195 147 196 return ( 148 197 <div 149 - className={`edit-overlay ${keyboardAppearedRef.current ? 'transition-padding' : ''} ${className}`} 150 - style={{ paddingBottom: keyboardHeight > 0 ? `${keyboardHeight}px` : undefined }} 198 + className={`edit-overlay ${className}`} 199 + style={{ paddingBottom: `${FIXED_KEYBOARD_PADDING}px` }} 151 200 onClick={(e) => e.target === e.currentTarget && onDismiss()} 152 201 > 153 - <div className="expandable-card expanded editor-card"> 202 + <div 203 + ref={cardRef} 204 + className="expandable-card expanded editor-card" 205 + style={{ transform: swipeOffset > 0 ? `translateY(${swipeOffset}px)` : undefined }} 206 + > 207 + {/* Dedicated drag handle - only this area triggers swipe */} 208 + <div 209 + className="editor-drag-handle" 210 + onTouchStart={handleTouchStart} 211 + onTouchMove={handleTouchMove} 212 + onTouchEnd={handleTouchEnd} 213 + > 214 + <div className="drag-handle-bar" /> 215 + </div> 154 216 {children} 155 217 </div> 156 218 </div> ··· 182 244 (!tagInput.trim() || tag.name.toLowerCase().includes(tagInput.toLowerCase().trim())) 183 245 ); 184 246 247 + // Add collapsed class when no available tags to show 248 + const isCollapsed = unusedTags.length === 0 && availableTags.length === 0; 249 + 185 250 return ( 186 - <div className="editor-tags-section"> 251 + <div className={`editor-tags-section ${isCollapsed ? 'collapsed' : ''}`}> 187 252 {selectedTags.size > 0 && ( 188 253 <div className="expandable-card-section"> 189 254 <div className="editing-tags"> ··· 210 275 onAddTag(); 211 276 } 212 277 }} 278 + onFocus={() => { 279 + // Counter iOS viewport scroll on focus 280 + requestAnimationFrame(() => window.scrollTo(0, 0)); 281 + }} 213 282 placeholder={placeholder} 214 283 autoCapitalize="none" 215 284 autoCorrect="off" ··· 224 293 </div> 225 294 </div> 226 295 227 - {unusedTags.length > 0 && ( 296 + {unusedTags.length > 0 ? ( 228 297 <div className="expandable-card-section"> 229 298 <div className="all-tags-list"> 230 299 {unusedTags.map((tag) => ( ··· 237 306 </span> 238 307 ))} 239 308 </div> 309 + </div> 310 + ) : ( 311 + <div className="expandable-card-section"> 312 + {availableTags.length === 0 ? ( 313 + <div className="tags-empty-message">Add some tags!</div> 314 + ) : tagInput.trim() ? ( 315 + <div className="tags-empty-message">No matching tags</div> 316 + ) : null} 240 317 </div> 241 318 )} 242 319 </div> ··· 282 359 minHeightPercent?: number; 283 360 keyboardHeight: number; 284 361 autoFocus?: boolean; 362 + showClearButton?: boolean; 363 + onAutoSave?: (value: string) => void; 285 364 } 286 365 287 366 const ResizableInput: React.FC<ResizableInputProps> = ({ ··· 289 368 onChange, 290 369 placeholder = "Enter text...", 291 370 minHeightPercent = 0.5, 292 - keyboardHeight, 293 - autoFocus = false 371 + keyboardHeight: _keyboardHeight, 372 + autoFocus = false, 373 + showClearButton = true, 374 + onAutoSave 294 375 }) => { 295 376 const [height, setHeight] = useState<number | null>(null); 296 377 const wrapperRef = useRef<HTMLDivElement>(null); ··· 299 380 const dragStartYRef = useRef(0); 300 381 const dragStartHeightRef = useRef(0); 301 382 383 + // Use fixed keyboard height for sizing to avoid layout jumps when QuickType changes 384 + // Standard iOS keyboard is ~300px, we use 350 to match EditorOverlay 385 + const FIXED_KEYBOARD_HEIGHT = 350; 386 + 302 387 // Calculate default height (initial size) and min height (resize floor) 303 388 const headerHeight = 56; 304 389 const buttonsHeight = 70; 305 - const availableHeight = window.innerHeight - headerHeight - keyboardHeight - buttonsHeight - 32; 390 + const availableHeight = window.innerHeight - headerHeight - FIXED_KEYBOARD_HEIGHT - buttonsHeight - 32; 306 391 const defaultHeight = Math.max(80, availableHeight * minHeightPercent); 307 392 const minHeight = 60; 308 393 const currentHeight = height ?? defaultHeight; ··· 437 522 // Normalize: collapse multiple spaces after list markers (iOS swipe/autosuggestion adds extra space) 438 523 const normalized = e.target.value.replace(/^(\s*(?:[-*+]|\d+\.))\s{2,}/gm, '$1 '); 439 524 onChange(normalized); 525 + // Trigger auto-save if provided 526 + if (onAutoSave) { 527 + onAutoSave(normalized); 528 + } 529 + }} 530 + onFocus={() => { 531 + // Counter iOS viewport scroll on focus 532 + requestAnimationFrame(() => window.scrollTo(0, 0)); 440 533 }} 441 534 onKeyDown={handleKeyDown} 442 535 placeholder={placeholder} ··· 445 538 autoComplete="off" 446 539 spellCheck={false} 447 540 /> 448 - <ClearButton show={value.length > 0} onClear={() => onChange('')} className="textarea-clear" /> 541 + {showClearButton && <ClearButton show={value.length > 0} onClear={() => onChange('')} className="textarea-clear" />} 449 542 <div 450 543 className="drag-handle" 451 544 onMouseDown={(e) => { e.preventDefault(); handleDragStart(e.clientY); }} ··· 1363 1456 } 1364 1457 }; 1365 1458 1459 + // Auto-save timer ref 1460 + const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null); 1461 + 1462 + // Debounced auto-save for text editing (saves silently without closing) 1463 + const scheduleAutoSave = useCallback((content: string) => { 1464 + if (!editingTextId || !originalEditValues) return; 1465 + 1466 + // Clear any pending auto-save 1467 + if (autoSaveTimerRef.current) { 1468 + clearTimeout(autoSaveTimerRef.current); 1469 + } 1470 + 1471 + // Only auto-save if content changed from original 1472 + if (content.trim() === originalEditValues.content) return; 1473 + 1474 + autoSaveTimerRef.current = setTimeout(async () => { 1475 + try { 1476 + await invoke("update_text", { id: editingTextId, content: content.trim(), tags: Array.from(editingTextTags) }); 1477 + // Update original values so we know what's saved 1478 + setOriginalEditValues(prev => prev ? { ...prev, content: content.trim() } : null); 1479 + await loadSavedTexts(); 1480 + } catch (error) { 1481 + console.error("Auto-save failed:", error); 1482 + } 1483 + }, 500); 1484 + }, [editingTextId, originalEditValues, editingTextTags]); 1485 + 1486 + // Clear auto-save timer on unmount or when editing ends 1487 + useEffect(() => { 1488 + return () => { 1489 + if (autoSaveTimerRef.current) { 1490 + clearTimeout(autoSaveTimerRef.current); 1491 + } 1492 + }; 1493 + }, [editingTextId]); 1494 + 1366 1495 const saveTextChanges = async () => { 1367 1496 if (!editingTextId) return; 1497 + 1498 + // Clear any pending auto-save 1499 + if (autoSaveTimerRef.current) { 1500 + clearTimeout(autoSaveTimerRef.current); 1501 + } 1368 1502 1369 1503 const finalTags = new Set(editingTextTags); 1370 1504 if (editingTextTagInput.trim()) { ··· 1732 1866 // Check if any edit mode is active 1733 1867 const isEditing = editingUrlId || editingTextId || editingTagsetId || editingImageId; 1734 1868 1869 + // Prevent iOS viewport scrolling when editor is open 1870 + useEffect(() => { 1871 + if (isEditing || addInputExpanded) { 1872 + document.body.classList.add('editor-open'); 1873 + } else { 1874 + document.body.classList.remove('editor-open'); 1875 + } 1876 + return () => { 1877 + document.body.classList.remove('editor-open'); 1878 + }; 1879 + }, [isEditing, addInputExpanded]); 1880 + 1735 1881 // Render edit modal content 1736 1882 const renderEditModal = () => { 1737 1883 if (!isEditing) return null; ··· 1749 1895 className="editor-url-input" 1750 1896 value={editingUrlValue} 1751 1897 onChange={(e) => setEditingUrlValue(e.target.value)} 1898 + onFocus={() => { 1899 + // Counter iOS viewport scroll on focus 1900 + requestAnimationFrame(() => window.scrollTo(0, 0)); 1901 + }} 1752 1902 placeholder="URL" 1753 1903 autoCapitalize="none" 1754 1904 autoCorrect="off" ··· 1782 1932 placeholder="Note text..." 1783 1933 keyboardHeight={keyboardHeight} 1784 1934 autoFocus 1935 + showClearButton={false} 1936 + onAutoSave={scheduleAutoSave} 1785 1937 /> 1786 1938 <TagsSection 1787 1939 selectedTags={editingTextTags} ··· 1795 1947 onSave={saveTextChanges} 1796 1948 onCancel={requestCancelEditingText} 1797 1949 onDelete={() => requestDelete(editingTextId, "text")} 1950 + saveLabel="Done" 1798 1951 /> 1799 1952 </EditorOverlay> 1800 1953 ); ··· 2123 2276 setPendingDiscard(null); 2124 2277 }; 2125 2278 2126 - const confirmDiscard = () => { 2279 + const confirmDiscard = async () => { 2127 2280 if (!pendingDiscard) return; 2128 2281 const { type } = pendingDiscard; 2129 2282 setPendingDiscard(null); ··· 2132 2285 cancelEditing(); 2133 2286 break; 2134 2287 case "text": 2288 + // Restore original content if auto-save changed it 2289 + if (editingTextId && originalEditValues?.content !== undefined) { 2290 + try { 2291 + await invoke("update_text", { id: editingTextId, content: originalEditValues.content, tags: originalEditValues.tags || [] }); 2292 + await loadSavedTexts(); 2293 + } catch (error) { 2294 + console.error("Failed to restore original content:", error); 2295 + } 2296 + } 2135 2297 cancelEditingText(); 2136 2298 break; 2137 2299 case "tagset": ··· 2800 2962 }} 2801 2963 style={{ cursor: "pointer" }} 2802 2964 > 2803 - Peek 2965 + Peek <span style={{ fontSize: '0.5em', opacity: 0.5 }}>v458</span> 2804 2966 </h1> 2805 2967 <div className="filter-icons"> 2806 2968 <button