experiments in a post-browser web
10
fork

Configure Feed

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

fix(page): improve loading glow visibility, lifecycle, and background fade-in

- Make loading glow more prominent: brighter colors, wider border/outline,
faster 1.5s pulse cycle
- Add background fade-in animation so webview shows white bg immediately
while loading instead of being transparent
- Add did-start-loading handler to re-apply loading class on navigation
- Add did-stop-loading fallback to ensure glow stops if dom-ready doesn't fire
- Transition ready state uses transparent border/outline for smooth fade-out
- Sequence: fade in background (0.3s) → start glow pulse → page loads → end glow

+288 -46
+8 -1
app/config.js
··· 12 12 hideTitleBar: 'Hide title bars', 13 13 restoreSession: 'Restore session on startup', 14 14 sessionAutosaveInterval: 'Autosave interval (minutes)', 15 + dragHoldDelay: 'Drag hold delay (seconds)', 15 16 } 16 17 }; 17 18 ··· 87 88 "type": "integer", 88 89 "default": 600 89 90 }, 91 + "dragHoldDelay": { 92 + "description": "Seconds to hold still before drag mode activates (0 = instant)", 93 + "type": "number", 94 + "default": 2 95 + }, 90 96 }, 91 97 "required": [ "shortcutKey", "startupFeature", "enableTrayIcon", "showInDockAndSwitcher", "quitShortcut" ] 92 98 }; ··· 161 167 restoreSession: true, 162 168 sessionAutosaveInterval: 5, 163 169 pageWidth: 800, 164 - pageHeight: 600 170 + pageHeight: 600, 171 + dragHoldDelay: 2 165 172 }, 166 173 items: [ 167 174 { id: '82de735f-a4b7-4fe6-a458-ec29939ae00d',
+17 -11
app/page/index.html
··· 45 45 overflow: hidden; 46 46 -webkit-mask-image: -webkit-radial-gradient(white, white); 47 47 opacity: 0; 48 - transition: opacity 0.15s ease, border-color 0.3s ease, outline-color 0.3s ease; 48 + transition: opacity 0.15s ease, border-color 0.3s ease, outline-color 0.3s ease, background-color 0.3s ease; 49 49 } 50 50 51 51 @keyframes loading-glow { 52 52 0%, 100% { 53 - border-color: rgba(140, 170, 255, 0.25); 54 - outline-color: rgba(120, 160, 255, 0.08); 53 + border-color: rgba(120, 160, 255, 0.4); 54 + outline-color: rgba(100, 150, 255, 0.15); 55 55 } 56 56 50% { 57 - border-color: rgba(140, 170, 255, 0.6); 58 - outline-color: rgba(120, 160, 255, 0.25); 57 + border-color: rgba(120, 160, 255, 0.85); 58 + outline-color: rgba(100, 150, 255, 0.4); 59 59 } 60 60 } 61 61 62 + @keyframes bg-fade-in { 63 + from { background-color: transparent; } 64 + to { background-color: rgba(255, 255, 255, 0.95); } 65 + } 66 + 62 67 webview.loading { 63 68 opacity: 1; 64 - background: rgba(255, 255, 255, 0.04); 65 - border: 1.5px solid rgba(140, 170, 255, 0.25); 66 - outline: 4px solid rgba(120, 160, 255, 0.08); 69 + background-color: rgba(255, 255, 255, 0.95); 70 + animation: bg-fade-in 0.3s ease forwards, loading-glow 1.5s ease-in-out 0.3s infinite; 71 + border: 2px solid rgba(120, 160, 255, 0.4); 72 + outline: 6px solid rgba(100, 150, 255, 0.15); 67 73 outline-offset: 0px; 68 - animation: loading-glow 2s ease-in-out infinite; 69 74 } 70 75 71 76 webview.ready { 72 77 opacity: 1; 73 - border: none; 74 - outline: none; 78 + border: 2px solid transparent; 79 + outline: 6px solid transparent; 75 80 background: initial; 81 + animation: none; 76 82 } 77 83 78 84 /*
+71 -11
app/page/page.js
··· 319 319 let dragStartBoundsY = 0; 320 320 let pageMouseButtonDown = false; 321 321 322 - // Hold-to-drag state 323 - const DRAG_HOLD_THRESHOLD = 80; 322 + // Hold-to-drag state (configurable, read from prefs at init) 323 + const JITTER_TOLERANCE = 3; // px of movement allowed during hold without cancelling 324 + let DRAG_HOLD_THRESHOLD = 2000; // ms, updated from prefs 324 325 let holdDragTimer = null; 325 326 let holdDragPending = false; 327 + let holdDragReady = false; // true when hold fires, cursor is 'grab', waiting for movement 326 328 let holdDragStartScreenX = 0; 327 329 let holdDragStartScreenY = 0; 328 330 331 + // Read drag hold delay from prefs 332 + (async () => { 333 + try { 334 + const prefs = await api.invoke('get-app-prefs'); 335 + if (prefs && typeof prefs.dragHoldDelay === 'number') { 336 + DRAG_HOLD_THRESHOLD = Math.max(0, prefs.dragHoldDelay * 1000); 337 + } 338 + } catch {} 339 + })(); 340 + 329 341 function startDrag(screenX, screenY) { 330 342 isDragging = true; 331 343 dragStartScreenX = screenX; ··· 341 353 holdDragTimer = null; 342 354 } 343 355 holdDragPending = false; 356 + holdDragReady = false; 344 357 dragOverlay.classList.remove('active'); 358 + document.body.style.cursor = ''; 345 359 } 346 360 347 361 // Instant drag on navbar background (excluding buttons and URL text inside shadow DOM) ··· 354 368 e.preventDefault(); 355 369 }); 356 370 357 - // Hold-to-drag from anywhere on the document 371 + // Hold-to-drag from anywhere on the document. 372 + // User must hold still (within jitter tolerance) for DRAG_HOLD_THRESHOLD ms. 373 + // When hold fires, cursor changes to 'grab'. Movement after that starts drag. 374 + // Any movement during hold period cancels the timer, allowing text selection. 358 375 document.addEventListener('mousedown', (e) => { 359 376 if (e.target.closest('.resize-handle')) return; 360 377 if (isDragging) return; ··· 367 384 368 385 holdDragTimer = setTimeout(() => { 369 386 if (holdDragPending) { 370 - startDrag(holdDragStartScreenX, holdDragStartScreenY); 387 + // Hold fired — show grab cursor as visual feedback, wait for movement 371 388 holdDragPending = false; 389 + holdDragReady = true; 390 + document.body.style.cursor = 'grab'; 372 391 } 373 392 }, DRAG_HOLD_THRESHOLD); 374 393 }); 375 394 395 + document.addEventListener('mousemove', (e) => { 396 + if (!pageMouseButtonDown) return; 397 + 398 + // During hold period: cancel if mouse moves beyond jitter tolerance 399 + if (holdDragPending && holdDragTimer) { 400 + const dx = Math.abs(e.screenX - holdDragStartScreenX); 401 + const dy = Math.abs(e.screenY - holdDragStartScreenY); 402 + if (dx > JITTER_TOLERANCE || dy > JITTER_TOLERANCE) { 403 + cancelHoldDrag(); 404 + } 405 + return; 406 + } 407 + 408 + // After hold fired (cursor is 'grab'): movement starts drag 409 + if (holdDragReady && !isDragging) { 410 + holdDragReady = false; 411 + startDrag(holdDragStartScreenX, holdDragStartScreenY); 412 + } 413 + }); 414 + 376 415 document.addEventListener('mouseup', () => { 377 416 pageMouseButtonDown = false; 378 417 cancelHoldDrag(); ··· 431 470 clearTimeout(webviewHoldTimer); 432 471 webviewHoldTimer = null; 433 472 } 473 + if (webviewHoldReady) { 474 + document.body.style.cursor = ''; 475 + } 434 476 webviewHoldReady = false; 435 477 } 436 478 ··· 452 494 webviewHoldTimer = setTimeout(() => { 453 495 webviewHoldTimer = null; 454 496 if (!webviewMouseDown) return; // Released before threshold 455 - // Don't activate overlay yet — wait for mouse movement to confirm drag intent. 456 - // Activating immediately on hold would block the webview from receiving mouseup, 457 - // preventing click events (e.g., YouTube pause button) for holds > 80ms. 497 + // Hold fired — show grab cursor as visual feedback, wait for movement to confirm drag. 458 498 webviewHoldReady = true; 459 - DEBUG && console.log('[page] Webview hold ready, waiting for movement'); 499 + document.body.style.cursor = 'grab'; 500 + DEBUG && console.log('[page] Webview hold ready (cursor→grab), waiting for movement'); 460 501 }, DRAG_HOLD_THRESHOLD); 461 502 return; 462 503 } ··· 508 549 if (!webviewHoldTimer) return; // No pending hold 509 550 const dx = Math.abs(screenX - webviewHoldScreenX); 510 551 const dy = Math.abs(screenY - webviewHoldScreenY); 511 - if (dx > 5 || dy > 5) { 552 + if (dx > JITTER_TOLERANCE || dy > JITTER_TOLERANCE) { 512 553 cancelWebviewHold(); 513 554 } 514 555 return; ··· 1015 1056 } 1016 1057 }); 1017 1058 1018 - // Note: we do NOT remove .ready on did-start-loading — keeping the webview 1019 - // visible during navigation prevents flash when flipping between pages. 1059 + // Re-add loading glow on new navigations (but keep webview visible — don't reset opacity) 1060 + webview.addEventListener('did-start-loading', () => { 1061 + // Only show loading glow if we're not already displaying content 1062 + // (avoid flash on in-page navigations) 1063 + if (!webview.classList.contains('ready')) { 1064 + webview.classList.add('loading'); 1065 + } 1066 + DEBUG && console.log('[page] did-start-loading'); 1067 + }); 1068 + 1069 + // Fallback: ensure loading glow stops even if dom-ready doesn't fire 1070 + webview.addEventListener('did-stop-loading', () => { 1071 + if (webview.classList.contains('loading')) { 1072 + webview.classList.remove('loading'); 1073 + webview.classList.add('ready'); 1074 + if (showSource === 'loading') { 1075 + scheduleHide(); 1076 + } 1077 + DEBUG && console.log('[page] did-stop-loading: removed loading glow (fallback)'); 1078 + } 1079 + }); 1020 1080 1021 1081 // --- OpenSearch discovery & page:loaded event --- 1022 1082
+5
backend/electron/ipc.ts
··· 3530 3530 return win ? win.id : null; 3531 3531 }); 3532 3532 3533 + // Get app-level preferences 3534 + ipcMain.handle('get-app-prefs', () => { 3535 + return getPrefs(); 3536 + }); 3537 + 3533 3538 // Check if current window is transient (opened when app wasn't focused) 3534 3539 // Used for IZUI policy: transient windows have different escape/mode behavior 3535 3540 ipcMain.handle('window-is-transient', (ev) => {
+1 -1
extensions/entities/background.js
··· 20 20 // Configuration 21 21 const REEXTRACT_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour 22 22 const EXTRACTION_DELAY_MS = 2000; // Wait for page to finish rendering 23 - const CONFIDENCE_THRESHOLD = 0.5; 23 + const CONFIDENCE_THRESHOLD = 0.7; 24 24 25 25 // In-memory cache of recently extracted URLs 26 26 const extractionCache = new Map();
+38 -3
extensions/entities/entity-matcher.js
··· 5 5 * Uses exact name matching and alias matching for Phase 1. 6 6 */ 7 7 8 - import { findEntity, createEntity, addObservation, normalizeName } from './entity-store.js'; 8 + import { findEntity, createEntity, addObservation, normalizeName, isEntitySuppressed } from './entity-store.js'; 9 + 10 + // Generic names that should never be stored as entities 11 + const BLOCKLIST = new Set([ 12 + 'event', 'article', 'person', 'organization', 'product', 'news', 13 + 'home', 'about', 'contact', 'menu', 'search', 'loading', 'unknown', 14 + 'untitled', 'none', 'n/a', 'tbd', 'lorem ipsum', 'test', 'example', 15 + 'admin', 'user', 'guest', 'anonymous', 'null', 'undefined', 16 + 'website', 'page', 'site', 'blog', 'shop', 'store', 'app', 17 + 'privacy policy', 'terms of service', 'cookie policy', 'subscribe', 18 + 'sign in', 'sign up', 'log in', 'register', 'learn more', 'read more', 19 + 'click here', 'view more', 'see more', 'show more', 'load more', 20 + ]); 21 + 22 + /** 23 + * Check if an entity name should be blocked (too generic or noise) 24 + */ 25 + function isBlocklisted(name) { 26 + if (!name) return true; 27 + const n = name.trim(); 28 + // Too short 29 + if (n.length < 2) return true; 30 + // All numbers 31 + if (/^\d+$/.test(n)) return true; 32 + // All punctuation/symbols 33 + if (/^[\d\s.,;:!?$%/\-@#&*()[\]{}|\\<>]+$/.test(n)) return true; 34 + // Generic blocklist 35 + if (BLOCKLIST.has(n.toLowerCase())) return true; 36 + return false; 37 + } 9 38 10 39 /** 11 40 * Process a batch of extracted entities from a page. ··· 15 44 * @param {Object} pageContext - Page context 16 45 * @param {string} pageContext.url - Source page URL 17 46 * @param {string} [pageContext.title] - Page title 18 - * @param {number} [confidenceThreshold=0.5] - Minimum confidence to store 47 + * @param {number} [confidenceThreshold=0.7] - Minimum confidence to store 19 48 * @returns {Promise<Array<Object>>} Processed entities with IDs 20 49 */ 21 - export async function processEntities(rawEntities, pageContext, confidenceThreshold = 0.5) { 50 + export async function processEntities(rawEntities, pageContext, confidenceThreshold = 0.7) { 22 51 const processed = []; 23 52 24 53 // Deduplicate within the batch by name+type ··· 28 57 for (const entity of rawEntities) { 29 58 // Filter by confidence threshold 30 59 if (entity.confidence < confidenceThreshold) continue; 60 + 61 + // Filter blocklisted names 62 + if (isBlocklisted(entity.name)) continue; 63 + 64 + // Filter entities suppressed by user feedback 65 + if (await isEntitySuppressed(entity.name, entity.entityType)) continue; 31 66 32 67 const key = `${normalizeName(entity.name)}:${entity.entityType}`; 33 68
+63 -2
extensions/entities/entity-store.js
··· 257 257 } 258 258 259 259 /** 260 - * Set feedback score on an entity 260 + * Set feedback score on an entity. 261 + * Thumbs-down (-1) also adds the entity name+type to the suppressed set, 262 + * preventing future extractions of the same name+type. 261 263 * @param {string} entityId - Entity item ID 262 264 * @param {number} score - -1 (thumbs down/hide), 0 (unscored), 1 (thumbs up/correct) 263 265 * @returns {Promise<{success: boolean}>} 264 266 */ 265 267 export async function setEntityFeedback(entityId, score) { 266 - return updateEntityMetadata(entityId, { 268 + const result = await updateEntityMetadata(entityId, { 267 269 feedback: { 268 270 score, 269 271 scoredAt: Date.now() 270 272 } 271 273 }); 274 + 275 + // Update suppressed set 276 + if (result.success) { 277 + const item = await api.datastore.getItem(entityId); 278 + if (item.success && item.data) { 279 + let meta = {}; 280 + try { meta = JSON.parse(item.data.metadata || '{}'); } catch {} 281 + const key = `${normalizeName(item.data.content)}:${meta.entityType}`; 282 + if (score === -1) { 283 + _suppressedEntities.add(key); 284 + } else { 285 + _suppressedEntities.delete(key); 286 + } 287 + } 288 + } 289 + 290 + return result; 291 + } 292 + 293 + // --- Suppressed entity tracking --- 294 + // Entities that the user has thumbs-downed are suppressed so future extractions 295 + // of the same name+type are automatically skipped. 296 + 297 + /** @type {Set<string>} - Set of "normalizedName:entityType" keys */ 298 + let _suppressedEntities = null; 299 + 300 + /** 301 + * Load suppressed entities from existing thumbs-down feedback. 302 + * Called lazily on first check. 303 + */ 304 + async function loadSuppressedEntities() { 305 + if (_suppressedEntities !== null) return; 306 + _suppressedEntities = new Set(); 307 + try { 308 + const result = await api.datastore.queryItems({ type: 'entity' }); 309 + if (!result.success) return; 310 + for (const item of result.data) { 311 + if (item.deletedAt > 0) continue; 312 + let meta = {}; 313 + try { meta = JSON.parse(item.metadata || '{}'); } catch {} 314 + if (meta.feedback?.score === -1) { 315 + _suppressedEntities.add(`${normalizeName(item.content)}:${meta.entityType}`); 316 + } 317 + } 318 + console.log(`[entities:store] Loaded ${_suppressedEntities.size} suppressed entities`); 319 + } catch (err) { 320 + console.warn('[entities:store] Failed to load suppressed entities:', err.message); 321 + } 322 + } 323 + 324 + /** 325 + * Check if an entity name+type has been suppressed by user feedback 326 + * @param {string} name - Entity name 327 + * @param {string} entityType - Entity type 328 + * @returns {Promise<boolean>} 329 + */ 330 + export async function isEntitySuppressed(name, entityType) { 331 + await loadSuppressedEntities(); 332 + return _suppressedEntities.has(`${normalizeName(name)}:${entityType}`); 272 333 } 273 334 274 335 /**
+11 -3
extensions/entities/extractors/regex.js
··· 158 158 159 159 const entities = []; 160 160 161 - // Extract emails 161 + // Extract emails (filter automated/system addresses) 162 + const BLOCKED_EMAIL_PREFIXES = [ 163 + 'noreply', 'no-reply', 'no_reply', 'donotreply', 'do-not-reply', 'do_not_reply', 164 + 'notification', 'notifications', 'automated', 'mailer-daemon', 'postmaster', 165 + 'webmaster', 'hostmaster', 'abuse', 'bounce', 'auto', 'daemon', 166 + ]; 162 167 const emails = text.match(EMAIL_RE) || []; 163 168 const seenEmails = new Set(); 164 169 for (const email of emails) { 165 170 const normalized = email.toLowerCase(); 166 171 if (seenEmails.has(normalized)) continue; 167 172 seenEmails.add(normalized); 173 + // Skip automated/system email addresses 174 + const localPart = normalized.split('@')[0]; 175 + if (BLOCKED_EMAIL_PREFIXES.some(p => localPart === p || localPart.startsWith(p + '+'))) continue; 168 176 entities.push({ 169 177 name: normalized, 170 178 entityType: 'email', ··· 193 201 entities.push({ 194 202 name: trimmed, 195 203 entityType: 'phone', 196 - confidence: 0.85, 204 + confidence: 0.7, 197 205 extractor: 'regex', 198 206 attributes: { number: trimmed }, 199 207 sourceUrl: url ··· 267 275 entities.push({ 268 276 name: normalized, 269 277 entityType: 'place', 270 - confidence: 0.75, 278 + confidence: 0.6, 271 279 extractor: 'regex', 272 280 attributes: { address: normalized }, 273 281 sourceUrl: url
+30 -3
extensions/entities/extractors/structured-data.js
··· 250 250 break; 251 251 } 252 252 253 + // Validate: require sufficient fields to avoid storing generic/templated entries 254 + const meaningfulFields = Object.values(attributes).filter(v => 255 + v && (typeof v === 'string' ? v.trim().length > 0 : true) 256 + ).length; 257 + 258 + // Type-specific minimum field requirements 259 + switch (entityType) { 260 + case 'person': 261 + // Person needs at least 1 additional field beyond name (email, url, jobTitle, org, etc.) 262 + if (meaningfulFields < 1) return null; 263 + break; 264 + case 'organization': 265 + if (meaningfulFields < 1) return null; 266 + break; 267 + case 'event': 268 + // Events need a start date at minimum 269 + if (!attributes.startDate) return null; 270 + break; 271 + case 'product': 272 + // Products need at least price or description 273 + if (!attributes.price && !attributes.description) return null; 274 + break; 275 + } 276 + 277 + // Confidence based on field richness: 0.9 for 3+ fields, 0.85 for fewer 278 + const confidence = meaningfulFields >= 3 ? 0.9 : 0.85; 279 + 253 280 return { 254 281 name: name.trim(), 255 282 entityType, 256 - confidence: 1.0, 283 + confidence, 257 284 extractor: 'json-ld', 258 285 attributes, 259 286 sourceUrl: url, ··· 326 353 }); 327 354 } 328 355 329 - // Extract article author 356 + // Extract article author (only if it looks like a real name — at least 2 words) 330 357 const articleAuthor = getMetaContent(doc, 'article:author'); 331 - if (articleAuthor && !articleAuthor.startsWith('http')) { 358 + if (articleAuthor && !articleAuthor.startsWith('http') && articleAuthor.trim().includes(' ')) { 332 359 entities.push({ 333 360 name: articleAuthor.trim(), 334 361 entityType: 'person',
+44 -11
preload.js
··· 2157 2157 // - Comprehensive console.log('[DRAG]') breadcrumbs for diagnostics. 2158 2158 // ============================================================================ 2159 2159 (function initWindowDrag() { 2160 - const HOLD_DELAY = 80; // ms before drag engages 2161 - const MOVE_THRESHOLD = 3; // px of movement required to engage drag 2160 + const MOVE_THRESHOLD = 3; // px of movement required to engage drag after hold fires 2161 + const JITTER_TOLERANCE = 3; // px of movement allowed during hold without cancelling 2162 + 2163 + // Configurable hold delay (read from prefs at init, default 2s) 2164 + let HOLD_DELAY = 2000; 2162 2165 2163 2166 // State 2164 2167 let isDragging = false; ··· 2170 2173 // Mouse positions 2171 2174 let lastScreenX = 0; // updated every mousemove while button is held 2172 2175 let lastScreenY = 0; 2176 + let holdOriginX = 0; // screen coords at mousedown (for jitter detection during hold) 2177 + let holdOriginY = 0; 2173 2178 let dragOriginX = 0; // screen coords at the moment drag began 2174 2179 let dragOriginY = 0; 2175 2180 let windowOriginX = 0; // window position at the moment drag began ··· 2221 2226 /** End everything: cancel hold and/or stop drag. */ 2222 2227 const endDrag = (reason) => { 2223 2228 const wasDragging = isDragging; 2229 + const wasHoldReady = holdReady; 2224 2230 cancelHold(); 2225 2231 holdReady = false; 2226 2232 if (isDragging) { 2227 2233 isDragging = false; 2228 2234 suppressClick = true; 2229 - document.body.style.cursor = ''; 2230 2235 document.body.classList.remove('is-dragging'); 2231 2236 document.body.style.userSelect = ''; 2232 2237 document.body.style.webkitUserSelect = ''; 2233 2238 } 2239 + // Reset cursor if it was set to 'grab' (hold ready) or 'grabbing' (dragging) 2240 + if (wasDragging || wasHoldReady) { 2241 + document.body.style.cursor = ''; 2242 + } 2234 2243 positionPromise = null; 2235 2244 if (wasDragging) { 2236 2245 console.log('[DRAG] ended:', reason); ··· 2248 2257 // Always record position (used by hold timer to set drag origin) 2249 2258 lastScreenX = e.screenX; 2250 2259 lastScreenY = e.screenY; 2260 + holdOriginX = e.screenX; 2261 + holdOriginY = e.screenY; 2251 2262 2252 2263 const blocked = shouldBlockDrag(e.target); 2253 2264 console.log('[DRAG] mousedown', e.target?.tagName, ··· 2264 2275 positionPromise = ipcRenderer.invoke('window-get-position', { id: cachedWindowId }) 2265 2276 .catch((err) => { console.log('[DRAG] pos fetch fail:', err); return null; }); 2266 2277 2267 - // Start hold timer. NO movement threshold: mouse can move freely during 2268 - // the hold period. When this fires we snapshot the current mouse position. 2278 + // Start hold timer. Mouse must stay still (within jitter tolerance) during 2279 + // the hold period. Movement cancels the timer, allowing text selection. 2269 2280 holdTimer = setTimeout(async () => { 2270 2281 holdTimer = null; 2271 2282 ··· 2292 2303 return; 2293 2304 } 2294 2305 2295 - // Mark ready — actual drag engages on first mousemove with enough movement. 2296 - // This prevents click-and-hold (no movement) from engaging drag and 2297 - // swallowing the subsequent click event. 2306 + // Hold fired — show visual cursor feedback. Drag engages on next movement. 2298 2307 dragOriginX = lastScreenX; 2299 2308 dragOriginY = lastScreenY; 2300 2309 windowOriginX = pos.x; 2301 2310 windowOriginY = pos.y; 2302 2311 holdReady = true; 2312 + document.body.style.cursor = 'grab'; 2303 2313 2304 - console.log('[DRAG] HOLD READY origin=(' + dragOriginX + ',' + dragOriginY + 2314 + console.log('[DRAG] HOLD READY (cursor→grab) origin=(' + dragOriginX + ',' + dragOriginY + 2305 2315 ') window=(' + windowOriginX + ',' + windowOriginY + ')'); 2306 2316 }, HOLD_DELAY); 2307 2317 }; ··· 2313 2323 lastScreenX = e.screenX; 2314 2324 lastScreenY = e.screenY; 2315 2325 2316 - // Hold timer fired — engage drag once movement exceeds threshold 2326 + // During hold period (timer not yet fired): cancel if mouse moves beyond jitter tolerance. 2327 + // This allows text selection to proceed normally. 2328 + if (holdTimer && !holdReady && !isDragging) { 2329 + const dx = Math.abs(e.screenX - holdOriginX); 2330 + const dy = Math.abs(e.screenY - holdOriginY); 2331 + if (dx > JITTER_TOLERANCE || dy > JITTER_TOLERANCE) { 2332 + cancelHold(); 2333 + positionPromise = null; 2334 + console.log('[DRAG] hold cancelled — mouse moved during hold period'); 2335 + return; 2336 + } 2337 + } 2338 + 2339 + // Hold timer fired (cursor is 'grab') — engage drag once movement exceeds threshold 2317 2340 if (holdReady && !isDragging) { 2318 2341 const dx = e.screenX - dragOriginX; 2319 2342 const dy = e.screenY - dragOriginY; ··· 2388 2411 console.error('[DRAG] failed to get window ID:', err); 2389 2412 } 2390 2413 2414 + // Read drag hold delay from prefs (in seconds, convert to ms) 2415 + try { 2416 + const prefs = await ipcRenderer.invoke('get-app-prefs'); 2417 + if (prefs && typeof prefs.dragHoldDelay === 'number') { 2418 + HOLD_DELAY = Math.max(0, prefs.dragHoldDelay * 1000); 2419 + } 2420 + } catch (err) { 2421 + console.warn('[DRAG] failed to read prefs, using default hold delay:', err); 2422 + } 2423 + 2391 2424 // Capture-phase on document for mousedown (need e.target for shouldBlockDrag). 2392 2425 // Capture-phase on window for mousemove/mouseup (survive fast cursor escape). 2393 2426 document.addEventListener('mousedown', onMouseDown, true); ··· 2396 2429 window.addEventListener('blur', onBlur); 2397 2430 document.addEventListener('click', onClickCapture, true); 2398 2431 2399 - console.log('[DRAG] initialized, windowId:', cachedWindowId); 2432 + console.log('[DRAG] initialized, windowId:', cachedWindowId, 'holdDelay:', HOLD_DELAY + 'ms'); 2400 2433 }; 2401 2434 2402 2435 if (document.readyState === 'loading') {