1/** 2 * Notes Module â Google Keep-style notes and todos. 3 * Renders as a sidebar panel (like document editor), not a modal. 4 */ 5 6import uiModule from './ui.js'; 7import { spawnConfetti } from './compare/vote.js'; 8import * as Modals from './modalManager.js'; 9import { attachColorPicker } from './colorPicker.js'; 10import { makeWindowDraggable } from './windowDrag.js'; 11import { snapModalToZone } from './tileManager.js'; 12import { applyEdgeDock, clearDockSide } from './modalSnap.js'; 13 14const API_BASE = window.location.origin; 15let _open = false; 16let _notes = []; 17let _editingId = null; 18let _selectedIds = new Set(); 19let _activeLabel = null; 20let _activeFilter = null; // null | 'default' | 'reminders' | 'no-reminders' 21// Cycle order for the Reminders chip: each click on it advances reminders â 22// null â no-reminders â null â reminders â ... This var tracks which non-null 23// state the next click should land on after passing through null. 24let _reminderChipNext = 'reminders'; 25let _searchQuery = ''; 26let _viewMode = (typeof localStorage !== 'undefined' && localStorage.getItem('odysseus-notes-view')) || 'list'; // 'list' or 'grid' 27let _showingArchived = false; 28let _selectMode = false; 29let _reminderTimer = null; 30// Tracks the global keydown listener so closePanel can remove it 31// (previously leaked one per openPanel; on multi-open sessions this 32// stacked dozens of identical handlers). 33let _notesKeydownHandler = null; 34// Capture-phase "Esc cancels select mode" listener on document â tracked so it 35// is removed on close instead of leaking +1 per panel open/close cycle. 36let _notesSelectEscHandler = null; 37const REMINDER_FIRED_KEY = 'odysseus-notes-reminder-fired'; 38// Note IDs already shown with the entry-glow once. Re-set when the user 39// reschedules the reminder so the new firing glows again on next open. 40const REMINDER_GLOWED_KEY = 'odysseus-notes-reminder-glowed'; 41// IDs of notes whose reminders fired while the notes panel was closed. On the 42// next open of the panel we briefly glow those cards so the user can spot them. 43const REMINDER_PENDING_HIGHLIGHT_KEY = 'odysseus-notes-reminder-pending-highlight'; 44const REMINDER_ACTIVE_HIGHLIGHT_KEY = 'odysseus-notes-reminder-active-highlight'; 45// Timestamp of the last time the user opened the notes panel â used to gate 46// the rail "fired" badge so old reminders don't re-fire on every page reload. 47const REMINDER_DISMISSED_AT_KEY = 'odysseus-notes-reminder-dismissed-at'; 48const NOTES_FIRST_OPEN_HINT_KEY = 'odysseus-notes-first-open-hint-v1'; 49 50function _forceCloseNotesPanel() { 51 _open = false; 52 _editingId = null; 53 try { _commitOpenInPlaceEditor(); } catch {} 54 try { _closeMobileFullscreenEdit({ save: true }); } catch {} 55 try { _clearViewedReminderGlows(); } catch {} 56 if (_notesKeydownHandler) { 57 document.removeEventListener('keydown', _notesKeydownHandler); 58 _notesKeydownHandler = null; 59 } 60 if (_notesSelectEscHandler) { 61 document.removeEventListener('keydown', _notesSelectEscHandler, true); 62 _notesSelectEscHandler = null; 63 } 64 if (_reminderTimer) { 65 clearInterval(_reminderTimer); 66 _reminderTimer = null; 67 } 68 document.body.classList.remove('notes-view', 'notes-mobile-mode', 'notes-drag-mode'); 69 document.getElementById('tool-notes-btn')?.classList.remove('active'); 70 try { Modals.unregister('notes-panel'); } catch {} 71 try { document.getElementById('notes-pane')?.remove(); } catch {} 72 try { document.getElementById('notes-pane-backdrop')?.remove(); } catch {} 73 try { window._restoreSidebarIfRouteCollapsed?.(); } catch {} 74} 75 76function _showNotesFirstOpenHint(pane) { 77 if (!pane || typeof localStorage === 'undefined') return; 78 try { 79 if (localStorage.getItem(NOTES_FIRST_OPEN_HINT_KEY)) return; 80 localStorage.setItem(NOTES_FIRST_OPEN_HINT_KEY, '1'); 81 } catch { 82 return; 83 } 84 85 document.getElementById('notes-first-open-hint')?.remove(); 86 const hint = document.createElement('div'); 87 hint.id = 'notes-first-open-hint'; 88 hint.className = 'tour-hint'; 89 hint.innerHTML = ` 90 <div class="tour-hint-text"><b>Notes</b> is your basic todo list, and also where reminders are managed.</div> 91 <button type="button" class="tour-hint-dismiss">OK</button> 92 `; 93 document.body.appendChild(hint); 94 95 const place = () => { 96 const r = pane.getBoundingClientRect(); 97 const hw = hint.offsetWidth || 260; 98 hint.style.top = Math.max(12, r.top + 58) + 'px'; 99 hint.style.left = Math.min(window.innerWidth - hw - 12, Math.max(12, r.left + 18)) + 'px'; 100 }; 101 const close = () => { 102 window.removeEventListener('resize', place); 103 hint.classList.add('tour-hint-out'); 104 setTimeout(() => hint.remove(), 180); 105 }; 106 107 requestAnimationFrame(() => { 108 place(); 109 hint.classList.add('tour-hint-in'); 110 }); 111 window.addEventListener('resize', place); 112 hint.querySelector('.tour-hint-dismiss')?.addEventListener('click', close); 113 setTimeout(close, 6500); 114} 115 116function _notesFullscreenSafeRect() { 117 const vw = window.innerWidth || document.documentElement.clientWidth || 0; 118 const vh = window.innerHeight || document.documentElement.clientHeight || 0; 119 let left = 0; 120 let right = vw; 121 122 const sidebar = document.getElementById('sidebar'); 123 const rail = document.getElementById('icon-rail'); 124 const hamburgerRight = document.body.classList.contains('hamburger-right') 125 || sidebar?.classList.contains('right-side') 126 || rail?.classList.contains('right-side'); 127 128 const reserve = (el) => { 129 if (!el || getComputedStyle(el).display === 'none') return; 130 const rect = el.getBoundingClientRect(); 131 if (rect.width <= 0 || rect.height <= 0) return; 132 if (hamburgerRight) right = Math.min(right, rect.left); 133 else left = Math.max(left, rect.right); 134 }; 135 136 if (sidebar && !sidebar.classList.contains('hidden')) reserve(sidebar); 137 reserve(rail); 138 139 // The fixed hamburger can remain visible even when the rail/sidebar is 140 // collapsed. Reserve its strip too so fullscreen Notes does not sit beneath it. 141 const hamburger = document.getElementById('hamburger-btn'); 142 if (hamburger && getComputedStyle(hamburger).display !== 'none') { 143 const rect = hamburger.getBoundingClientRect(); 144 const pad = 8; 145 if (hamburgerRight) right = Math.min(right, rect.left - pad); 146 else left = Math.max(left, rect.right + pad); 147 } 148 149 left = Math.max(0, Math.min(left, vw - 80)); 150 right = Math.max(left + 80, Math.min(right, vw)); 151 return { left, top: 0, width: right - left, height: vh }; 152} 153 154function _wireNotesWindow(pane) { 155 if (!pane || pane.dataset.windowDragWired === '1') return; 156 const header = pane.querySelector('.notes-pane-header'); 157 if (!header) return; 158 pane.dataset.windowDragWired = '1'; 159 makeWindowDraggable(pane, { 160 content: pane, 161 header, 162 fsClass: 'notes-window-fullscreen', 163 skipSelector: 'button, input, select, textarea, label, .notes-mobile-grabber', 164 enableDock: true, 165 enableLeftDock: true, 166 onEnterFullscreen: () => { 167 pane.classList.add('notes-window-fullscreen'); 168 snapModalToZone(pane, { 169 name: 'fullscreen', 170 rect: _notesFullscreenSafeRect(), 171 }); 172 }, 173 onExitFullscreen: () => { 174 _restoreNotesSidebarDock(pane); 175 }, 176 }); 177} 178 179function _clearNotesSnapStyles(pane) { 180 if (!pane) return; 181 const hadLeft = pane.classList.contains('modal-left-docked'); 182 const hadRight = pane.classList.contains('modal-right-docked'); 183 pane.classList.remove('notes-window-fullscreen', 'modal-left-docked', 'modal-right-docked'); 184 if (hadLeft) clearDockSide('left', pane); 185 if (hadRight) clearDockSide('right', pane); 186 ['position', 'left', 'top', 'right', 'bottom', 'width', 'max-width', 'height', 187 'max-height', 'margin', 'transform', 'border-radius'] 188 .forEach((prop) => pane.style.removeProperty(prop)); 189 delete pane.dataset._tilePreSnap; 190 delete pane.dataset._tileZone; 191 delete pane._preDockSnapshot; 192 delete pane._dockSide; 193 delete pane._dockSuspended; 194} 195 196function _restoreNotesSidebarDock(pane) { 197 if (!pane || window.innerWidth <= 768) return; 198 _clearNotesSnapStyles(pane); 199 if (!pane.isConnected) return; 200 applyEdgeDock(pane, 'right'); 201} 202 203function _loadPendingHighlights() { 204 try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); }
205 catch { return new Set(); } 206} 207function _loadGlowedReminders() { 208 try { return new Set(JSON.parse(localStorage.getItem(REMINDER_GLOWED_KEY) || '[]')); } 209 catch { return new Set(); } 210} 211function _saveGlowedReminders(set) { 212 try { localStorage.setItem(REMINDER_GLOWED_KEY, JSON.stringify([...set])); } catch {} 213} 214function _loadActiveHighlights() { 215 try { return new Set(JSON.parse(localStorage.getItem(REMINDER_ACTIVE_HIGHLIGHT_KEY) || '[]')); } 216 catch { return new Set(); } 217} 218function _saveActiveHighlights(set) { 219 try { localStorage.setItem(REMINDER_ACTIVE_HIGHLIGHT_KEY, JSON.stringify([...set])); } catch {} 220} 221function _clearViewedReminderGlows() { 222 const active = _loadActiveHighlights(); 223 if (!active.size) return; 224 _saveActiveHighlights(new Set()); 225 document.querySelectorAll('.note-card-reminder-fired-sticky').forEach(card => { 226 card.classList.remove('note-card-reminder-fired-sticky'); 227 }); 228} 229function _setReminderCardGlow(noteId, on = true) { 230 if (!noteId) return; 231 const active = _loadActiveHighlights(); 232 if (on) active.add(noteId); 233 else active.delete(noteId); 234 _saveActiveHighlights(active); 235 document.querySelectorAll(`.note-card[data-note-id="${noteId}"]`).forEach(card => { 236 card.classList.toggle('note-card-reminder-fired-sticky', on); 237 }); 238} 239// A note has an active reminder when its due time has passed and the user 240// hasn't archived or fully completed it. Used for both sorting (bumped above 241// the rest of the unpinned section) and the entry-glow flush. 242function _hasActiveReminder(n) { 243 if (!n || n.archived || _isNoteFullyDone(n)) return false; 244 if (!n.due_date) return false; 245 const t = new Date(n.due_date).getTime(); 246 return !isNaN(t) && t <= Date.now(); 247} 248function _savePendingHighlights(set) { 249 try { localStorage.setItem(REMINDER_PENDING_HIGHLIGHT_KEY, JSON.stringify([...set])); } 250 catch {} 251} 252function _queuePendingHighlight(noteId) { 253 const set = _loadPendingHighlights(); 254 set.add(noteId); 255 _savePendingHighlights(set); 256} 257function _flushPendingHighlights() { 258 // Fresh firings (queued by the background loop while the panel was closed) 259 // glow unconditionally â a notification just told the user something 260 // happened, so we always point at the note even if it was glowed before. 261 const queued = _loadPendingHighlights(); 262 const glowed = _loadGlowedReminders(); 263 const toGlow = new Set(queued); 264 // For notes that are merely overdue at open time (no fresh firing event), 265 // only glow the ones we haven't already shown â otherwise reopening the 266 // panel keeps lighting up old reminders forever. 267 for (const n of _notes) { 268 if (!_hasActiveReminder(n) || !_hasTimeComponent(n.due_date)) continue; 269 if (queued.has(n.id) || !glowed.has(n.id)) toGlow.add(n.id); 270 } 271 // Always consume the queue. 272 _savePendingHighlights(new Set()); 273 if (!toGlow.size) return; 274 let firstCard = null; 275 for (const id of toGlow) { 276 const card = document.querySelector(`.note-card[data-note-id="${id}"]`); 277 if (!card) continue; 278 _setReminderCardGlow(id, true); 279 if (!firstCard) firstCard = card; 280 glowed.add(id); 281 } 282 _saveGlowedReminders(glowed); 283 // Bring the first one into view so it can't get buried below the fold. 284 if (firstCard) { 285 requestAnimationFrame(() => { 286 try { firstCard.scrollIntoView({ block: 'center', behavior: 'smooth' }); } 287 catch { firstCard.scrollIntoView(); } 288 }); 289 } 290} 291 292const COLORS = [ 293 { name: 'none', value: '' }, 294 { name: 'red', value: 'red' }, 295 { name: 'orange', value: 'orange' }, 296 { name: 'yellow', value: 'yellow' }, 297 { name: 'green', value: 'green' }, 298 { name: 'blue', value: 'blue' }, 299 { name: 'purple', value: 'purple' }, 300 { name: 'custom', value: 'custom' }, // sentinel â clicking opens native color picker 301]; 302 303const _CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)'; 304 305// A note's color is one of: '' (none), a preset name (red/orange/â¦), or a 306// sentinel "bg:<image-url>" for a custom background image uploaded by the user. 307function _isBgImage(c) { return typeof c === 'string' && c.startsWith('bg:'); } 308function _bgImageUrl(c) { return _isBgImage(c) ? c.slice(3) : ''; } 309 310function _dotBg(value, noteColor) { 311 if (value === 'custom') { 312 const url = _bgImageUrl(noteColor); 313 return url ? `center/cover no-repeat url('${url}')` : _CUSTOM_GRADIENT; 314 } 315 return COLOR_HEX[value]; 316} 317 318function _dotIsActive(value, noteColor) { 319 if (value === 'custom') return _isBgImage(noteColor); 320 return value === (noteColor || ''); 321} 322 323// Inline style for a note card/form when color is a custom bg image. 324function _customColorStyle(c) { 325 if (!_isBgImage(c)) return ''; 326 const url = _bgImageUrl(c); 327 return `background-image: linear-gradient(color-mix(in srgb, var(--panel) 60%, transparent), color-mix(in srgb, var(--panel) 60%, transparent)), url('${url}'); background-size: cover; background-position: center; border-color: color-mix(in srgb, var(--fg) 25%, var(--border));`; 328} 329 330// Open a file picker, upload the chosen image, and resolve with the URL. 331function _pickCustomBgImage() { 332 return new Promise(resolve => { 333 const input = document.createElement('input'); 334 input.type = 'file'; 335 input.accept = 'image/*'; 336 input.style.cssText = 'position:fixed; left:-9999px; top:-9999px;'; 337 document.body.appendChild(input); 338 let done = false;
339 const finish = (v) => { if (done) return; done = true; input.remove(); resolve(v); }; 340 input.addEventListener('change', async () => { 341 const file = input.files?.[0]; 342 if (!file) return finish(null); 343 const fd = new FormData(); 344 fd.append('files', file); 345 try { 346 const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' }); 347 const data = await res.json(); 348 const fileId = data.files?.[0]?.id; 349 if (!fileId) throw new Error('Upload failed'); 350 finish(`${API_BASE}/api/upload/${fileId}`); 351 } catch { finish(null); } 352 }); 353 // Best-effort cleanup if user dismisses the dialog. 354 setTimeout(() => { if (!done && !input.files?.length) finish(null); }, 30000); 355 input.click(); 356 }); 357} 358 359const COLOR_HEX = { 360 '': 'var(--border)', 361 // Pale/pastel palette â matches the calendar event color picker. 362 red: '#f0b5ba', 363 orange: '#e8ccb2', 364 yellow: '#f2dfbd', 365 green: '#cce0bc', 366 blue: '#b0d7f7', 367 purple: '#e2bcee', 368}; 369 370// ---- API ---- 371 372let _loading = false; 373// Undo stack â most recent action is at the end. We cap it small because the 374// only entries that survive a panel reload are in-memory anyway. 375const _undoStack = []; 376function _pushUndo(entry) { 377 _undoStack.push(entry); 378 if (_undoStack.length > 20) _undoStack.shift(); 379} 380function _popAndRunUndo() { 381 const entry = _undoStack.pop(); 382 if (entry) entry.run(); 383 return !!entry; 384} 385 386function _undoArchive(note, prevIdx) { 387 // Re-insert at original position and clear archived flag on the server. 388 const safeIdx = Math.min(Math.max(prevIdx, 0), _notes.length); 389 _notes.splice(safeIdx, 0, { ...note, archived: false }); 390 _renderNotes(); 391 _patchNote(note.id, { archived: false }).catch(() => { 392 // Roll back local insertion if the server refuses 393 const i = _notes.findIndex(n => n.id === note.id); 394 if (i >= 0) _notes.splice(i, 1); 395 _renderNotes(); 396 uiModule.showError('Undo failed'); 397 }); 398} 399 400async function _fetchNotes() { 401 _loading = true; 402 try { 403 const url = `${API_BASE}/api/notes${_showingArchived ? '?archived=true' : ''}`; 404 const res = await fetch(url, { credentials: 'same-origin' }); 405 if (!res.ok) { _notes = []; return; } 406 const data = await res.json(); 407 _notes = data.notes || data || []; 408 } catch (e) { 409 console.error('Failed to fetch notes:', e); 410 _notes = []; 411 } finally { 412 _loading = false; 413 } 414} 415 416async function _saveNote(note) { 417 const method = note.id ? 'PUT' : 'POST'; 418 const url = note.id ? `${API_BASE}/api/notes/${note.id}` : `${API_BASE}/api/notes`; 419 const res = await fetch(url, { 420 method, credentials: 'same-origin', 421 headers: { 'Content-Type': 'application/json' }, 422 body: JSON.stringify(note), 423 }); 424 if (!res.ok) throw new Error('Failed to save note'); 425 return await res.json(); 426} 427 428async function _deleteNoteApi(id) { 429 // v2 review â used to swallow 4xx/5xx silently. Throw so callers can 430 // distinguish success vs failure and toast accordingly. 431 const r = await fetch(`${API_BASE}/api/notes/${id}`, { method: 'DELETE', credentials: 'same-origin' }); 432 if (!r.ok) throw new Error('HTTP ' + r.status); 433} 434 435async function _patchNote(id, patch) { 436 const res = await fetch(`${API_BASE}/api/notes/${id}`, { 437 method: 'PUT', credentials: 'same-origin', 438 headers: { 'Content-Type': 'application/json' }, 439 body: JSON.stringify(patch), 440 }); 441 if (!res.ok) throw new Error('Failed to update note'); 442 return await res.json(); 443} 444 445// ---- Helpers ---- 446 447function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '<').replace(/>/g, '>'); } 448function _attrEsc(s) { 449 return String(s || '') 450 .replace(/"/g, '"') 451 .replace(/'/g, ''') 452 .replace(/</g, '<') 453 .replace(/>/g, '>') 454 .replace(/`/g, '`'); 455} 456// Image src guard â reject anything that isn't a relative path, http(s), or 457// raster data URL so an AI-saved note can't slip script-capable media into the 458// rendered <img>. 459function _safeImgSrc(s) { 460 const v = (s || '').trim(); 461 if (!v) return ''; 462 if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v; 463 if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v; 464 return ''; 465} 466 467// Escape then turn http(s)://... URLs into clickable anchors. XSS-safe. 468// Allow balanced `(...)` inside the URL (Wikipedia, MD links) by accepting 469// `(` in the body, then trim a trailing unmatched `)` afterwards. 470function _linkify(s) { 471 const escaped = _esc(s); 472 const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?\]])/g; 473 return escaped.replace(urlRe, (m) => { 474 let url = m; 475 // Trim a trailing ')' that doesn't have a matching '(' inside the URL 476 if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) { 477 url = url.slice(0, -1); 478 } 479 const href = url.startsWith('www.') ? `https://${url}` : url; 480 return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : ''); 481 }); 482} 483function _uid() { return Math.random().toString(36).slice(2, 10); } 484 485// Mobile swipe-to-dismiss for the notes sheet. Mirrors the document panel 486// gesture (finger-following, velocity-based dismiss, rubber-band, snap-back) 487// so both sheets feel identical; dismisses via the notes closePanel('down').
488function _wireNotesSwipeDismiss(el, pane) { 489 if (!el || !pane) return; 490 const DISMISS_THRESHOLD = 50, VELOCITY_THRESHOLD = 0.3, RUBBER = 0.35; 491 let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0; 492 let dragging = false, cancelled = false; 493 494 el.addEventListener('touchstart', (e) => { 495 if (window.innerWidth > 768 || e.touches.length !== 1) return; 496 if (e.target.closest('button, input, select, label, textarea')) return; 497 const t = e.touches[0]; 498 startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp; 499 velocity = 0; dragging = false; cancelled = false; 500 }, { passive: true }); 501 502 el.addEventListener('touchmove', (e) => { 503 if (cancelled || window.innerWidth > 768) return; 504 const t = e.touches[0]; 505 const dx = Math.abs(t.clientX - startX); 506 const dy = t.clientY - startY; 507 if (!dragging) { 508 if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } 509 if (Math.abs(dy) > 8) { 510 dragging = true; 511 pane.style.animation = 'none'; 512 pane.style.transition = 'none'; 513 pane.style.willChange = 'transform'; 514 } else return; 515 } 516 const dt = e.timeStamp - lastT; 517 if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4; 518 lastY = t.clientY; lastT = e.timeStamp; 519 e.preventDefault(); 520 pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER}px)`; 521 }, { passive: false }); 522 523 const endSwipe = () => { 524 if (!dragging) return; 525 dragging = false; 526 pane.style.willChange = ''; 527 const dy = lastY - startY; 528 if (dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD)) { 529 // Slide fully off-screen, then minimise. Keep it translated down (don't 530 // reset) so it doesn't flash back before closePanel removes it. 531 pane.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.4, 1)'; 532 pane.style.transform = 'translateY(100%)'; 533 setTimeout(() => closePanel('down'), 200); 534 } else { 535 pane.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; 536 pane.style.transform = ''; 537 setTimeout(() => { pane.style.transition = ''; }, 260); 538 } 539 }; 540 el.addEventListener('touchend', endSwipe, { passive: true }); 541 el.addEventListener('touchcancel', endSwipe, { passive: true }); 542} 543 544function _hasTimeComponent(dateStr) { 545 return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr); 546} 547 548function _formatDueDate(dateStr) { 549 if (!dateStr) return ''; 550 const d = new Date(dateStr); 551 if (isNaN(d)) return ''; 552 const now = new Date(); 553 const hasTime = _hasTimeComponent(dateStr); 554 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); 555 const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); 556 const diffDays = Math.round((due - today) / 86400000); 557 const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; 558 if (hasTime && d < now) return 'overdue'; 559 if (!hasTime && diffDays < 0) return 'overdue'; 560 if (diffDays === 0) return hasTime ? timeStr : 'today'; 561 if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow'; 562 const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); 563 return hasTime ? `${dateLabel} ${timeStr}` : dateLabel; 564} 565 566function _isDueOverdue(dateStr) { 567 if (!dateStr) return false; 568 const d = new Date(dateStr); 569 if (isNaN(d)) return false; 570 if (_hasTimeComponent(dateStr)) return d < new Date(); 571 return d < new Date(new Date().toDateString()); 572} 573 574function _isDueTodayOrOverdue(dateStr) { 575 if (!dateStr) return false; 576 const d = new Date(dateStr); 577 const now = new Date(); 578 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); 579 const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); 580 return due <= today; 581} 582 583function _isNoteFullyDone(note) { 584 if (_hasItems(note) && Array.isArray(note.items) && note.items.length > 0) { 585 return note.items.every(it => it.done); 586 } 587 return false; 588} 589 590// A "checklist note" â todo or goal â has structured items[] that the cards 591// render as checkboxes and that "fully done" / progress logic reads from. 592function _hasItems(note) { 593 return note && (note.note_type === 'todo' || note.note_type === 'goal'); 594} 595 596// Compact " N/M" progress string for a goal's checklist. Empty when the goal 597// has no steps yet (e.g. AI breakdown is still in flight or was cancelled). 598function _goalProgress(note) { 599 if (!Array.isArray(note?.items) || note.items.length === 0) return ''; 600 const done = note.items.filter(it => it.done).length; 601 return ` ${done}/${note.items.length}`; 602} 603 604// The next unchecked step in a goal, or null if all done / no items. 605function _nextGoalStep(note) { 606 if (!Array.isArray(note?.items)) return null; 607 for (let i = 0; i < note.items.length; i++) { 608 if (!note.items[i].done) return { idx: i, item: note.items[i] }; 609 } 610 return null; 611} 612 613// ---- Reminder presets ---- 614
615function _laterTodayDate() { 616 const now = new Date(); 617 const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today 618 // If less than 1 hour before 6pm, push to "in 3 hours" instead 619 if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000); 620 return eight; 621} 622function _tomorrowDate() { 623 const t = new Date(); 624 t.setDate(t.getDate() + 1); 625 t.setHours(8, 0, 0, 0); 626 return t; 627} 628function _nextWeekDate() { 629 const t = new Date(); 630 const daysUntilMon = (8 - t.getDay()) % 7 || 7; 631 t.setDate(t.getDate() + daysUntilMon); 632 t.setHours(8, 0, 0, 0); 633 return t; 634} 635function _toLocalDatetimeStr(d) { 636 // Format as YYYY-MM-DDTHH:MM (local, no TZ) 637 const pad = n => String(n).padStart(2, '0'); 638 return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; 639} 640function _formatReminderTag(dateStr) { 641 if (!dateStr) return ''; 642 const d = new Date(dateStr); 643 if (isNaN(d)) return ''; 644 const now = new Date(); 645 const sameDay = d.toDateString() === now.toDateString(); 646 const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); 647 const isTomorrow = d.toDateString() === tomorrow.toDateString(); 648 const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); 649 if (sameDay) return `Today, ${time}`; 650 if (isTomorrow) return `Tomorrow, ${time}`; 651 const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); 652 return `${dateLabel}, ${time}`; 653} 654// Build a human label for a date's nth-weekday-of-month, e.g. "2nd Tuesday" 655const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th']; 656const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 657function _nthWeekdayLabel(d) { 658 const n = Math.ceil(d.getDate() / 7); // 1..5 659 return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`; 660} 661function _isLastWeekdayOfMonth(d) { 662 const test = new Date(d); 663 test.setDate(d.getDate() + 7); 664 return test.getMonth() !== d.getMonth(); 665} 666// Find the Nth occurrence of `weekday` in a given year/month. n=1..5. 667// If n=5 and there's no 5th occurrence, returns the 4th (so "5th Monday" still works). 668function _nthWeekdayOfMonth(year, month, weekday, n) { 669 const first = new Date(year, month, 1); 670 const offset = (weekday - first.getDay() + 7) % 7; 671 let day = 1 + offset + (n - 1) * 7; 672 // Last day of month 673 const lastDay = new Date(year, month + 1, 0).getDate(); 674 if (day > lastDay) day -= 7; 675 return new Date(year, month, day, 0, 0, 0); 676} 677function _lastWeekdayOfMonth(year, month, weekday) { 678 const lastDay = new Date(year, month + 1, 0); 679 const back = (lastDay.getDay() - weekday + 7) % 7; 680 return new Date(year, month, lastDay.getDate() - back, 0, 0, 0); 681} 682 683// Snap a chosen datetime forward to the next slot matching a normalized 684// recurrence pattern (preserving time-of-day, strictly in the future). 685// Anchors to the user's chosen date when it's in the future (so picking a 686// recurrence on a far-future date doesn't drag it back to today); otherwise 687// anchors to "now". Returns null for daily/yearly/none. 688function _snapToRepeat(currentDate, normRepeat) { 689 const hh = currentDate.getHours(); 690 const mm = currentDate.getMinutes(); 691 const now = Date.now(); 692 const anchor = currentDate.getTime() > now ? currentDate : new Date(); 693 const parts = normRepeat.split(':'); 694 const kind = parts[0]; 695 if (kind === 'weekly') { 696 const targetWd = parseInt(parts[1], 10); 697 if (isNaN(targetWd)) return null; 698 const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0); 699 const delta = (targetWd - d.getDay() + 7) % 7; 700 d.setDate(d.getDate() + delta); 701 if (d.getTime() <= now) d.setDate(d.getDate() + 7); 702 return d; 703 } 704 if (kind === 'monthly') { 705 const sub = parts[1]; 706 let y = anchor.getFullYear(); 707 let m = anchor.getMonth(); 708 // Walk forward up to 14 months to find the next matching slot. 709 for (let tries = 0; tries < 14; tries++) { 710 let target; 711 if (sub === 'day') { 712 const wantDay = parseInt(parts[2], 10); 713 if (isNaN(wantDay)) return null; 714 const lastDay = new Date(y, m + 1, 0).getDate(); 715 target = new Date(y, m, Math.min(wantDay, lastDay)); 716 } else if (sub === 'nth') { 717 const n = parseInt(parts[2], 10); 718 const wd = parseInt(parts[3], 10); 719 if (isNaN(n) || isNaN(wd)) return null; 720 target = _nthWeekdayOfMonth(y, m, wd, n); 721 } else if (sub === 'last') { 722 const wd = parseInt(parts[2], 10); 723 if (isNaN(wd)) return null;
724 target = _lastWeekdayOfMonth(y, m, wd); 725 } else { 726 return null; 727 } 728 target.setHours(hh, mm, 0, 0); 729 if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target; 730 m++; 731 if (m > 11) { m = 0; y++; } 732 } 733 return null; 734 } 735 return null; 736} 737 738// Render a repeat value as a human-readable label. 739// originalDate is required only to interpret legacy bare values ("weekly", "monthly", ...). 740// All call sites pass it; missing it would silently misinterpret legacy values. 741function _formatRepeatLabel(repeat, originalDate) { 742 if (!repeat || repeat === 'none') return ''; 743 const norm = _normalizeRepeat(repeat, originalDate); 744 if (norm === 'daily') return 'Daily'; 745 if (norm === 'yearly') return 'Yearly'; 746 const parts = norm.split(':'); 747 if (parts[0] === 'weekly') { 748 const wd = parseInt(parts[1], 10); 749 if (isNaN(wd)) return 'Weekly'; 750 return `Weekly on ${_DAYS[wd]}s`; 751 } 752 if (parts[0] === 'monthly') { 753 if (parts[1] === 'day') return `Monthly on day ${parts[2]}`; 754 if (parts[1] === 'nth') { 755 const n = parseInt(parts[2], 10); 756 const wd = parseInt(parts[3], 10); 757 return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`; 758 } 759 if (parts[1] === 'last') { 760 const wd = parseInt(parts[2], 10); 761 return `Monthly on last ${_DAYS[wd]}`; 762 } 763 } 764 return norm; 765} 766 767// ---- Reminders ---- 768 769function _loadFiredReminders() { 770 try { return new Set(JSON.parse(localStorage.getItem(REMINDER_FIRED_KEY) || '[]')); } 771 catch { return new Set(); } 772} 773 774function _saveFiredReminders(set) { 775 try { localStorage.setItem(REMINDER_FIRED_KEY, JSON.stringify([...set])); } 776 catch {} 777} 778 779async function _ensureNotificationPermission() { 780 if (!('Notification' in window)) return false; 781 if (Notification.permission === 'granted') return true; 782 if (Notification.permission === 'denied') return false; 783 try { const p = await Notification.requestPermission(); return p === 'granted'; } 784 catch { return false; } 785} 786 787// Repeat format: 788// none 789// daily 790// weekly:W W = 0-6 (Sun..Sat) 791// monthly:day:D D = 1-31 (calendar day) 792// monthly:nth:N:W N = 1-4 (1st..4th), W = 0-6 (weekday) 793// monthly:last:W W = 0-6 (last weekday of month) 794// yearly 795// Legacy "weekly", "monthly", "monthly_nth_weekday", "monthly_last_weekday" 796// are normalized using the original due_date's weekday/Nth. 797function _normalizeRepeat(repeat, originalDate) { 798 if (!repeat || repeat === 'none') return 'none'; 799 if (repeat === 'daily' || repeat === 'yearly') return repeat; 800 if (/^(weekly|monthly):/.test(repeat)) return repeat; 801 // Legacy bare values â derive params from the original date 802 const wd = originalDate.getDay(); 803 const n = Math.ceil(originalDate.getDate() / 7); 804 if (repeat === 'weekly') return `weekly:${wd}`; 805 if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`; 806 if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`; 807 if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`; 808 return repeat; 809} 810 811function _advanceRecurring(dateStr, repeat) { 812 const orig = new Date(dateStr); 813 const hh = orig.getHours(); 814 const mm = orig.getMinutes(); 815 let d = new Date(orig); 816 const norm = _normalizeRepeat(repeat, orig); 817 if (norm === 'none') return null; 818 819 function step() { 820 if (norm === 'daily') { 821 d.setDate(d.getDate() + 1); 822 return; 823 } 824 if (norm === 'yearly') { 825 d.setFullYear(d.getFullYear() + 1); 826 return; 827 } 828 const parts = norm.split(':'); 829 const kind = parts[0]; 830 if (kind === 'weekly') { 831 // Snap to the requested weekday in the next 1-7 days 832 const targetWd = parseInt(parts[1], 10); 833 let delta = (targetWd - d.getDay() + 7) % 7; 834 if (delta === 0) delta = 7; 835 d.setDate(d.getDate() + delta);
836 d.setHours(hh, mm, 0, 0); 837 return; 838 } 839 if (kind === 'monthly') { 840 const sub = parts[1]; 841 const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0); 842 const nm = (d.getMonth() + 1) % 12; 843 let target; 844 if (sub === 'day') { 845 const wantDay = parseInt(parts[2], 10); 846 const lastDay = new Date(ny, nm + 1, 0).getDate(); 847 target = new Date(ny, nm, Math.min(wantDay, lastDay)); 848 } else if (sub === 'nth') { 849 const n = parseInt(parts[2], 10); 850 const wd = parseInt(parts[3], 10); 851 target = _nthWeekdayOfMonth(ny, nm, wd, n); 852 } else if (sub === 'last') { 853 const wd = parseInt(parts[2], 10); 854 target = _lastWeekdayOfMonth(ny, nm, wd); 855 } else { 856 d = null; return; 857 } 858 target.setHours(hh, mm, 0, 0); 859 d = target; 860 return; 861 } 862 d = null; 863 } 864 865 step(); 866 if (d === null) return null; 867 const now = Date.now(); 868 // Cap catch-up to avoid runaway on a malformed/very-old date. 869 let guard = 5000; 870 while (d.getTime() <= now) { 871 if (--guard <= 0) return null; 872 step(); 873 if (d === null) return null; 874 } 875 return _toLocalDatetimeStr(d); 876} 877 878function _checkReminders() { 879 if (!_notes.length) return; 880 const now = Date.now(); 881 const fired = _loadFiredReminders(); 882 let changed = false; 883 for (const note of _notes) { 884 if (!note.due_date || note.archived) continue; 885 if (!_hasTimeComponent(note.due_date)) continue; 886 if (fired.has(note.id)) continue; 887 const due = new Date(note.due_date).getTime(); 888 if (isNaN(due)) continue; 889 if (due <= now && due > now - 60000) { 890 _fireReminder(note); 891 // Recurring? advance the due_date instead of marking as fired 892 if (note.repeat && note.repeat !== 'none') { 893 const next = _advanceRecurring(note.due_date, note.repeat); 894 if (next) { 895 note.due_date = next; 896 _patchNote(note.id, { due_date: next }).catch(() => {}); 897 // Don't add to fired â new due_date is in the future 898 continue; 899 } 900 } 901 fired.add(note.id); 902 changed = true; 903 } else if (due <= now - 60000) { 904 // Past, never seen â silently advance recurring or mark fired 905 if (note.repeat && note.repeat !== 'none') { 906 const next = _advanceRecurring(note.due_date, note.repeat); 907 if (next) { 908 note.due_date = next; 909 _patchNote(note.id, { due_date: next }).catch(() => {}); 910 continue; 911 } 912 } 913 fired.add(note.id); 914 changed = true; 915 } 916 } 917 if (changed) _saveFiredReminders(fired); 918 // Always refresh badge â fired state may have changed visually without note mutation 919 _updateRailBadge(); 920} 921 922function _fireReminder(note) { 923 const title = note.title || 'Note reminder'; 924 // Include the verbatim note content so the email/notification actually 925 // shows what to do, not just a count. Cap the per-item lines (8 max) and 926 // total length so the body stays inbox-friendly. 927 let rawBody; 928 if (_hasItems(note)) { 929 const pending = (note.items || []) 930 .filter(i => !i.done && !i.checked) 931 .map(i => (i.text || '').trim()) 932 .filter(Boolean); 933 if (pending.length) { 934 const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n'); 935 const extra = pending.length > 8 ? `\nâ¦and ${pending.length - 8} more` : ''; 936 rawBody = `Pending (${pending.length}):\n${shown}${extra}`; 937 } else { 938 rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`; 939 } 940 } else { 941 rawBody = (note.content || '').slice(0, 400); 942 } 943 944 // Ask the server to dispatch according to user settings. The server may 945 // return an LLM-written synthesis line and/or send an email. We still show 946 // a local browser notification so the user gets immediate feedback even if 947 // the server path is disabled or slow. 948 const showLocal = (body) => { 949 if ('Notification' in window && Notification.permission === 'granted') { 950 try { 951 const n = new Notification(title, { body, tag: 'note-' + note.id, icon: '/static/favicon.ico' }); 952 n.onclick = () => { window.focus();
952 openPanel(); n.close(); }; 953 } catch {} 954 } 955 if (uiModule?.showToast) uiModule.showToast(title); 956 }; 957 958 // Fire-and-forget server dispatch. If synthesis comes back quickly enough, 959 // use it as the notification body; otherwise the local notification has 960 // already shown with the raw body. 961 let shown = false; 962 const timer = setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500); 963 964 fetch('/api/notes/fire-reminder', { 965 method: 'POST', 966 credentials: 'same-origin', 967 headers: { 'Content-Type': 'application/json' }, 968 body: JSON.stringify({ note_id: note.id, title, body: rawBody }), 969 }) 970 .then(r => r.ok ? r.json() : null) 971 .then(data => { 972 clearTimeout(timer); 973 if (shown) return; 974 shown = true; 975 const body = (data && data.synthesis) ? data.synthesis : rawBody; 976 showLocal(body); 977 }) 978 .catch(() => { 979 clearTimeout(timer); 980 if (!shown) { shown = true; showLocal(rawBody); } 981 }); 982 983 // Pulse the card if visible; otherwise queue it so the next time the user 984 // opens the notes panel the card gets a brief glow. 985 _setReminderCardGlow(note.id, true); 986 const card = document.querySelector(`.note-card[data-note-id="${note.id}"]`); 987 if (card) { 988 card.classList.add('note-card-reminder-fired'); 989 setTimeout(() => card.classList.remove('note-card-reminder-fired'), 3000); 990 } else { 991 _queuePendingHighlight(note.id); 992 } 993} 994 995function _startReminderLoop() { 996 if (_reminderTimer) return; 997 _reminderTimer = setInterval(_checkReminders, 30000); 998 _checkReminders(); // run once immediately 999} 1000 1001function _countDueReminders() { 1002 return _notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date) && !_isNoteFullyDone(n)).length; 1003} 1004 1005let _firedDotDismissedAt = (() => { 1006 try { 1007 const v = parseInt(localStorage.getItem(REMINDER_DISMISSED_AT_KEY) || '0', 10); 1008 return Number.isFinite(v) && v > 0 ? v : 0; 1009 } catch { return 0; } 1010})(); 1011 1012function _countFiredReminders() { 1013 // Reminders whose time has actually passed (not just date-today), 1014 // and which fired after the last user dismissal. 1015 const now = Date.now(); 1016 return _notes.filter(n => { 1017 if (n.archived || _isNoteFullyDone(n)) return false; 1018 if (!n.due_date || !_hasTimeComponent(n.due_date)) return false; 1019 const t = new Date(n.due_date).getTime(); 1020 if (isNaN(t) || t > now) return false; 1021 return t > _firedDotDismissedAt; 1022 }).length; 1023} 1024 1025export function dismissFiredReminderDot() { 1026 _firedDotDismissedAt = Date.now(); 1027 try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {} 1028 _updateRailBadge(); 1029} 1030 1031function _updateRailBadge() { 1032 const fired = _countFiredReminders(); 1033 // Rail (mini sidebar) â only show the count when reminders have ACTUALLY 1034 // fired since the last dismissal (i.e. you haven't opened notes yet). 1035 // Showing every overdue note forever made the badge feel permanent. 1036 const railBtn = document.getElementById('rail-notes'); 1037 if (railBtn) { 1038 let badge = railBtn.querySelector('.rail-notes-badge'); 1039 if (fired > 0) { 1040 if (!badge) { 1041 badge = document.createElement('span'); 1042 badge.className = 'rail-notes-badge'; 1043 railBtn.appendChild(badge); 1044 } 1045 badge.textContent = fired > 99 ? '99+' : String(fired); 1046 badge.classList.add('fired'); 1047 } else if (badge) { 1048 badge.remove(); 1049 } 1050 } 1051 // Main sidebar button 1052 const sidebarBtn = document.getElementById('tool-notes-btn'); 1053 if (sidebarBtn) { 1054 let dot = sidebarBtn.querySelector('.tool-notes-dot'); 1055 if (fired > 0) { 1056 if (!dot) { 1057 dot = document.createElement('span'); 1058 dot.className = 'tool-notes-dot'; 1059 sidebarBtn.appendChild(dot); 1060 } 1061 } else if (dot) { 1062 dot.remove(); 1063 } 1064 } 1065 // Individual note cards â pulse ones with fired reminders 1066 document.querySelectorAll('.note-card').forEach(card => { 1067 const id = card.dataset.noteId; 1068 const note = _notes.find(n => n.id === id); 1069 if (!note || note.archived || _isNoteFullyDone(note)) { 1070 card.classList.remove('note-card-reminder-due'); 1071 return; 1072 } 1073 if (note.due_date && _hasTimeComponent(note.due_date)) { 1074 const t = new Date(note.due_date).getTime(); 1075 card.classList.toggle('note-card-reminder-due', !isNaN(t) && t <= Date.now()); 1076 } else { 1077 card.classList.remove('note-card-reminder-due'); 1078 } 1079 }); 1080} 1081 1082export async function refreshDueBadge(opts = {}) { 1083 // Usually lightweight, but callers that just created a note reminder can 1084 // force a refresh so the background reminder loop sees it immediately. 1085 if (opts.force || _notes.length === 0) { 1086 try { 1087 const wasArchived = _showingArchived; 1088 _showingArchived = false;
1089 await _fetchNotes(); 1090 _showingArchived = wasArchived; 1091 } catch {} 1092 } 1093 _updateRailBadge(); 1094} 1095 1096// ---- Panel ---- 1097 1098export function openPanel() { 1099 if (_open) return; 1100 _open = true; 1101 _editingId = null; 1102 _clearViewedReminderGlows(); 1103 _firedDotDismissedAt = Date.now(); 1104 try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {} 1105 1106 const container = document.getElementById('chat-container'); 1107 if (!container) return; 1108 1109 document.body.classList.add('notes-view'); 1110 1111 // On mobile the notes panel takes the whole screen â auto-close the 1112 // sidebar so the panel isn't cropped underneath it. 1113 if (window.innerWidth <= 768) { 1114 const sb = document.getElementById('sidebar'); 1115 if (sb) sb.classList.add('hidden'); 1116 document.body.classList.add('sidebar-collapsed'); 1117 } 1118 // Mobile mode: tiles become read-only previews (no inline checkbox / 1119 // edit / archive / etc.), tap opens a fullscreen edit overlay, 1120 // long-press enters drag-to-reorder mode. See _bindCardEvents + 1121 // .notes-mobile-mode CSS rules. 1122 if (_isNotesMobileMode()) document.body.classList.add('notes-mobile-mode'); 1123 1124 // Toggle button state 1125 const btn = document.getElementById('tool-notes-btn'); 1126 if (btn) btn.classList.add('active'); 1127 1128 // Create panel 1129 const pane = document.createElement('div'); 1130 pane.id = 'notes-pane'; 1131 pane.className = 'notes-pane'; 1132 pane.innerHTML = ` 1133 <div class="notes-mobile-grabber" id="notes-mobile-grabber" aria-hidden="true"></div> 1134 <div class="notes-pane-header"> 1135 <h4 class="notes-pane-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2.5px;margin-right:6px"><path d="M5 3h10l4 4v14H5z"/><path d="M15 3v5h5"/><path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/></svg>Notes</h4> 1136 <span style="flex:1"></span> 1137 <button id="notes-archive-toggle" class="doc-action-icon-btn notes-header-text-btn" title="View archive" style="opacity:0.8;gap:5px;"> 1138 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg> 1139 <span class="notes-header-btn-label">Archive</span> 1140 </button> 1141 <button id="notes-view-toggle" class="doc-action-icon-btn notes-header-text-btn" title="Toggle view" style="opacity:0.8;gap:5px;"> 1142 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg> 1143 <span class="notes-header-btn-label">Toggle</span> 1144 </button> 1145 <button id="notes-minimize-btn" class="modal-minimize-btn" title="Minimize" aria-label="Minimize notes" style="position:relative;left:2px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="18" x2="18" y2="18"/></svg></button> 1146 </div> 1147 <div class="notes-search-bar"> 1148 <input type="text" id="notes-search" class="memory-search-input" placeholder="Search notesâ¦" autocomplete="off" /> 1149 <button id="notes-select-btn" class="notes-select-trigger" type="button">Select</button> 1150 </div> 1151 <div id="notes-bulk-bar" class="memory-bulk-bar hidden"> 1152 <label class="memory-bulk-check-all"><input type="checkbox" id="notes-select-all" /> All</label> 1153 <span id="notes-selected-count">0 Selected</span> 1154 <span style="flex:1"></span> 1155 <button id="notes-bulk-archive" class="memory-toolbar-btn" disabled> 1156 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg>Archive 1157 </button> 1158 <button id="notes-bulk-delete" class="memory-toolbar-btn danger" disabled> 1159 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
1159<line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>Delete 1160 </button> 1161 </div> 1162 <div class="notes-pane-body"></div> 1163 `; 1164 1165 // On mobile open as a full-screen bottom sheet (slide up), not the desktop 1166 // side panel. Set inline so it wins over the base .notes-pane rule regardless 1167 // of cascade specifics (the CSS @media override wasn't reliably applying, 1168 // which left it as a side panel squeezing the chat). 1169 if (window.innerWidth <= 768) { 1170 pane.style.position = 'fixed'; 1171 pane.style.inset = '0'; 1172 pane.style.width = '100%'; 1173 pane.style.maxWidth = '100%'; 1174 pane.style.zIndex = '170'; 1175 pane.style.borderRadius = '14px 14px 0 0'; 1176 pane.style.animation = 'sheet-enter 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both'; 1177 pane.style.transformOrigin = 'bottom center'; 1178 } 1179 1180 // Mount on body so Notes can behave like the other draggable windows. On 1181 // desktop it is immediately docked to the right by _restoreNotesSidebarDock. 1182 const backdrop = document.createElement('div'); 1183 backdrop.className = 'notes-pane-backdrop'; 1184 backdrop.id = 'notes-pane-backdrop'; 1185 backdrop.addEventListener('click', (ev) => { 1186 if (ev.target === backdrop) closePanel('down'); 1187 }); 1188 backdrop.appendChild(pane); 1189 document.body.appendChild(backdrop); 1190 _wireNotesWindow(pane); 1191 _restoreNotesSidebarDock(pane); 1192 1193 // Events 1194 // (Close chevron removed â swipe down on mobile, tool-rail toggle on de
1194sktop.) 1195 1196 // Mobile: swipe the grab handle / header down to dismiss (minimise to chip). 1197 // Mirrors the document sheet gesture â finger-following, velocity-based 1198 // dismiss, rubber-band on up-drag, spring snap-back. 1199 _wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane); 1200 _wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane); 1201 1202 const minBtn = document.getElementById('notes-minimize-btn'); 1203 if (minBtn) minBtn.addEventListener('click', (e) => { 1204 e.preventDefault(); 1205 e.stopPropagation(); 1206 closePanel('down'); 1207 }); 1208 // Search 1209 const searchEl = document.getElementById('notes-search'); 1210 if (searchEl) { 1211 searchEl.addEventListener('input', () => { 1212 _searchQuery = searchEl.value.trim().toLowerCase(); 1213 _renderNotes(); 1214 }); 1215 } 1216 1217 // View toggle 1218 const archiveBtn = document.getElementById('notes-archive-toggle'); 1219 if (archiveBtn) { 1220 const ARCHIVE_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg><span class="notes-header-btn-label">Archive</span>'; 1221 const CLOSE_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg><span class="notes-header-btn-label">Archive</span>'; 1222 const syncArchiveBtn = () => { 1223 archiveBtn.classList.toggle('active', _showingArchived); 1224 archiveBtn.title = _showingArchived ? 'Exit archive' : 'View archive'; 1225 archiveBtn.style.opacity = _showingArchived ? '1' : '0.8'; 1226 // Swap to an X while in archive view so it doubles as a close-back- 1227 // to-active-notes toggle. 1228 archiveBtn.innerHTML = _showingArchived ? CLOSE_ICON : ARCHIVE_ICON; 1229 // Tint the whole pane so it's obvious you're not in the active list. 1230 pane.classList.toggle('notes-pane-archive', _showingArchived); 1231 }; 1232 syncArchiveBtn(); 1233 archiveBtn.addEventListener('click', async () => { 1234 _showingArchived = !_showingArchived; 1235 _selectedIds.clear(); 1236 syncArchiveBtn(); 1237 // Brief fade so the body content swap doesn't snap â the bg-tint 1238 // change is already eased by CSS transitions on .notes-pane*. 1239 const _bodyEl = document.querySelector('#notes-pane .notes-pane-body'); 1240 if (_bodyEl) { 1241 _bodyEl.style.transition = 'opacity 0.18s ease'; 1242 _bodyEl.style.opacity = '0.25'; 1243 } 1244 await _fetchNotes(); 1245 _renderNotes(); 1246 if (_bodyEl) { 1247 requestAnimationFrame(() => { 1248 _bodyEl.style.opacity = ''; 1249 _bodyEl.addEventListener('transitionend', () => { _bodyEl.style.transition = ''; }, { once: true }); 1250 }); 1251 } 1252 }); 1253 } 1254 const viewBtn = document.getElementById('notes-view-toggle'); 1255 if (viewBtn) { 1256 pane.classList.toggle('notes-view-grid', _viewMode === 'grid'); 1257 // Label shows what you'll switch TO â "Grid" while in list, "List" while in grid. 1258 const _setViewLabel = () => { 1259 const lbl = viewBtn.querySelector('.notes-header-btn-label'); 1260 if (lbl) lbl.textContent = _viewMode === 'grid' ? 'List' : 'Grid'; 1261 }; 1262 _setViewLabel(); 1263 requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); 1264 viewBtn.addEventListener('click', () => { 1265 _viewMode = _viewMode === 'grid' ? 'list' : 'grid'; 1266 try { localStorage.setItem('odysseus-notes-view', _viewMode); } catch {} 1267 pane.classList.toggle('notes-view-grid', _viewMode === 'grid'); 1268 _setViewLabel(); 1269 requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); 1270 }); 1271 } 1272 // Select mode 1273 document.getElementById('notes-select-btn').addEventListener('click', () => { 1274 if (_selectMode) _exitSelectMode(); else _enterSelectMode(); 1275 }); 1276 // Esc cancels select mode. Notes uses a toggle "Select" button rather 1277 // than a *-bulk-cancel button, so the global Esc-cancel handler in 1278 // keyboard-shortcuts.js can't reach it â handle it here. Capture phase 1279 // + stopPropagation so Esc cancels select instead of closing the panel. 1280 if (_notesSelectEscHandler) { 1281 document.removeEventListener('keydown', _notesSelectEscHandler, true); 1282 } 1283 _notesSelectEscHandler = (e) => { 1284 if (e.key === 'Escape' && _selectMode) { 1285 e.preventDefault(); 1286 e.stopPropagation(); 1287 _exitSelectMode(); 1288 } 1289 }; 1290 document.addEventListener('keydown', _notesSelectEscHandler, true); 1291 document.getElementById('notes-select-all').addEventListener('change', (e) => {
1292 if (e.target.checked) _notes.forEach(n => _selectedIds.add(n.id)); 1293 else _selectedIds.clear(); 1294 _renderNotes(); 1295 _updateBulkBar(); 1296 }); 1297 document.getElementById('notes-bulk-archive').addEventListener('click', async () => { 1298 const ids = [..._selectedIds]; 1299 if (!ids.length) return; 1300 await Promise.all(ids.map(id => _patchNote(id, { archived: true }).catch(() => {}))); 1301 _exitSelectMode(); 1302 await _fetchNotes(); 1303 _renderNotes(); 1304 uiModule.showToast(`Archived ${ids.length}`); 1305 }); 1306 document.getElementById('notes-bulk-delete').addEventListener('click', async () => { 1307 const ids = [..._selectedIds]; 1308 if (!ids.length) return; 1309 if (uiModule && uiModule.styledConfirm) { 1310 const ok = await uiModule.styledConfirm(`Delete ${ids.length} note${ids.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }); 1311 if (!ok) return; 1312 } 1313 await Promise.all(ids.map(id => _deleteNoteApi(id).catch(() => {}))); 1314 _exitSelectMode(); 1315 await _fetchNotes(); 1316 _renderNotes(); 1317 uiModule.showToast(`Deleted ${ids.length}`); 1318 }); 1319 // Escape: exit select mode first (if active), otherwise close the panel. 1320 // Skip when the user is editing a form field â those have their own 1321 // ESC-to-cancel handlers and we don't want to nuke the whole panel 1322 // mid-edit. 1323 // Idempotent: remove any previous handler from a prior openPanel so 1324 // re-opening doesn't stack multiple handlers. 1325 if (_notesKeydownHandler) { 1326 document.removeEventListener('keydown', _notesKeydownHandler); 1327 _notesKeydownHandler = null; 1328 } 1329 _notesKeydownHandler = (e) => { 1330 if (!_open) return; 1331 const t = e.target; 1332 const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable); 1333 // Ctrl/Cmd+Z anywhere in the panel â undo the last note action. Skip when 1334 // typing in a field so the browser's normal text-undo still works. 1335 if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) { 1336 if (inField) return; 1337 if (_undoStack.length === 0) return; 1338 e.preventDefault(); 1339 _popAndRunUndo(); 1340 return; 1341 } 1342 // Ctrl/Cmd+C while hovering a note card â copy that note. Skip when the 1343 // user is editing or has an active text selection (let the browser handle 1344 // a real text copy in that case). 1345 if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !e.shiftKey && !e.altKey) { 1346 if (inField) return; 1347 const sel = window.getSelection?.(); 1348 if (sel && sel.toString && sel.toString().length > 0) return; 1349 const hovered = document.querySelector('.note-card:hover'); 1350 if (!hovered) return; 1351 const id = hovered.dataset.noteId; 1352 if (!id) return; 1353 e.preventDefault(); 1354 // Flash the ⯠menu button (copy now lives in that menu). 1355 const btn = hovered.querySelector('.note-card-corner-menu'); 1356 _copyNote(id, btn); 1357 return; 1358 } 1359 if (e.key !== 'Escape') return; 1360 if (inField) return; 1361 if (_selectMode) { _exitSelectMode(); return; } 1362 if (_showingArchived) { 1363 // Mirror the archive toggle button: flip back to active notes. 1364 document.getElementById('notes-archive-toggle')?.click(); 1365 return; 1366 } 1367 _forceCloseNotesPanel(); 1368 }; 1369 document.addEventListener('keydown', _notesKeydownHandler); 1370 1371 // Load â show skeleton immediately, then fetch 1372 _renderLoadingSkeleton(); 1373 // Defer the highlight flush to the next frame so it runs *after* the cards 1374 // are committed to the DOM (and any FLIP animations have settled), giving 1375 // the querySelector lookups inside something to find. 1376 _fetchNotes().then(() => { 1377 _renderNotes(); 1378 requestAnimationFrame(() => _flushPendingHighlights()); 1379 _startReminderLoop(); 1380 _showNotesFirstOpenHint(pane); 1381 }); 1382} 1383 1384function _renderLoadingSkeleton() { 1385 const body = document.querySelector('#notes-pane .notes-pane-body'); 1386 if (!body) return; 1387 body.innerHTML = ''; 1388 _renderLabelsInto(body); 1389 _renderQuickAdd(body); 1390 const skel = document.createElement('div'); 1391 skel.className = 'notes-skeleton'; 1392 skel.innerHTML = ` 1393 <div class="notes-skeleton-card"></div> 1394 <div class="notes-skeleton-card"></div> 1395 <div class="notes-skeleton-card short"></div> 1396 <div class="notes-skeleton-card"></div> 1397 `; 1398 body.appendChild(skel); 1399} 1400 1401function _enterSelectMode() { 1402 _selectMode = true; 1403 _selectedIds.clear(); 1404 const bar = document.getElementById('notes-bulk-bar'); 1405 const btn = document.getElementById('notes-select-btn'); 1406 if (bar) bar.classList.remove('hidden'); 1407 if (btn) { btn.classList.add('active'); btn.textContent = 'Cancel'; } 1408 _renderNotes(); 1409 _updateBulkBar(); 1410} 1411 1412function _exitSelectMode() { 1413 _selectMode = false;
1414 _selectedIds.clear(); 1415 const bar = document.getElementById('notes-bulk-bar'); 1416 const btn = document.getElementById('notes-select-btn'); 1417 const all = document.getElementById('notes-select-all'); 1418 if (bar) bar.classList.add('hidden'); 1419 if (btn) { btn.classList.remove('active'); btn.textContent = 'Select'; } 1420 if (all) all.checked = false; 1421 _renderNotes(); 1422} 1423 1424function _updateBulkBar() { 1425 const count = _selectedIds.size; 1426 const countEl = document.getElementById('notes-selected-count'); 1427 const archiveBtn = document.getElementById('notes-bulk-archive'); 1428 const deleteBtn = document.getElementById('notes-bulk-delete'); 1429 const allEl = document.getElementById('notes-select-all'); 1430 if (countEl) countEl.textContent = `${count} Selected`; 1431 if (archiveBtn) archiveBtn.disabled = count === 0; 1432 if (deleteBtn) deleteBtn.disabled = count === 0; 1433 if (allEl) allEl.checked = _notes.length > 0 && _notes.every(n => _selectedIds.has(n.id)); 1434 // Toggle select-mode class so todo dots don't react to hover 1435 const pane = document.getElementById('notes-pane'); 1436 if (pane) pane.classList.toggle('notes-select-mode', count > 0); 1437} 1438 1439// A note's label field may hold multiple space-separated tags. Split + dedupe. 1440function _noteTags(n) { 1441 const tags = []; 1442 if (n?.label) tags.push(...n.label.trim().split(/\s+/).filter(Boolean)); 1443 if (n?.due_date && _hasTimeComponent(n.due_date)) tags.push('reminder'); 1444 return [...new Set(tags.map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; 1445} 1446 1447function _visibleNoteTags(n) { 1448 return _noteTags(n).filter(t => t !== 'reminder'); 1449} 1450 1451function _isPastReminder(n) { 1452 if (!n?.due_date || !_hasTimeComponent(n.due_date)) return false; 1453 const due = new Date(n.due_date).getTime(); 1454 return !isNaN(due) && due <= Date.now(); 1455} 1456 1457async function _clearPastReminders() { 1458 const targets = _notes.filter(n => !n.archived && _isPastReminder(n)); 1459 if (!targets.length) { 1460 uiModule.showToast?.('No past reminders to clear'); 1461 return; 1462 } 1463 const ok = uiModule?.styledConfirm 1464 ? await uiModule.styledConfirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }) 1465 : confirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`); 1466 if (!ok) return; 1467 await Promise.all(targets.map(n => _deleteNoteApi(n.id).catch(() => {}))); 1468 await _fetchNotes(); 1469 _renderNotes(); 1470 uiModule.showToast?.(`Cleared ${targets.length} past reminder${targets.length === 1 ? '' : 's'}`); 1471} 1472 1473function _renderLabels(root = document) { 1474 const bar = root.querySelector?.('.notes-labels-bar') || document.querySelector('.notes-labels-bar'); 1475 if (!bar) return; 1476 const labels = new Set(); 1477 for (const n of _notes) for (const t of _visibleNoteTags(n)) labels.add(t); 1478 const sortedLabels = [...labels].sort(); 1479 // Count active reminders (not archived, has datetime due_date) 1480 const reminderCount = _notes.filter(n => !n.archived && n.due_date && _hasTimeComponent(n.due_date)).length; 1481 const pastReminderCount = _notes.filter(n => !n.archived && _isPastReminder(n)).length; 1482 const defaultCount = _notes.filter(n => !n.archived && _visibleNoteTags(n).length === 0).length; 1483 // Active goals = non-archived goal notes. Today view lists pending steps 1484 // from each, so we surface the count next to the chip. 1485 const goalCount = _notes.filter(n => n.note_type === 'goal' && !n.archived).length; 1486 const todayCount = _notes.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)).length; 1487 bar.style.display = ''; 1488 const allActive = _activeLabel === null && _activeFilter === null; 1489 let html = `<button class="notes-label-chip${allActive ? ' active' : ''}" data-action="all">All</button>`; 1490 html += `<button class="notes-label-chip${_activeFilter === 'default' ? ' active' : ''}" data-action="default" title="Show notes without tags">Default <span class="notes-label-chip-count">${defaultCount}</span></button>`; 1491 if (todayCount > 0) { 1492 const isOn = _activeFilter === 'today'; 1493 const icon = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>'; 1494 html += `<button class="notes-label-chip notes-label-chip-today${isOn ? ' active' : ''}" data-action="today" title="Next step from every goal">${icon}Today <span class="notes-label-chip-count">${todayCount}</span></button>`; 1495 } 1496 if (goalCount > 0) { 1497 const isOn = _activeFilter === 'goals';
1498 const icon = '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>'; 1499 html += `<button class="notes-label-chip notes-label-chip-goals${isOn ? ' active' : ''}" data-action="goals" title="Show only goals">${icon}Goals <span class="notes-label-chip-count">${goalCount}</span></button>`; 1500 } 1501 const isReminderOn = _activeFilter === 'reminders'; 1502 const isReminderOff = _activeFilter === 'no-reminders'; 1503 const reminderCls = `notes-label-chip notes-label-chip-reminders${isReminderOn ? ' active' : ''}${isReminderOff ? ' active negated' : ''}`; 1504 const reminderIcon = isReminderOff 1505 // bell-off icon 1506 ? '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px"><path d="M13.73 21a2 2 0 0 1-3.46 0"/><path d="M18.63 13A17.89 17.89 0 0 1 18 8"/><path d="M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14"/><path d="M18 8a6 6 0 0 0-9.33-5"/><line x1="1" y1="1" x2="23" y2="23"/></svg>' 1507 : '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>'; 1508 html += `<button class="${reminderCls}" data-action="reminders" title="${isReminderOn ? 'Showing only reminders â click to show all' : isReminderOff ? 'Hiding reminders â click to show only reminders' : 'Click to filter reminders'}">${reminderIcon}Reminders <span class="notes-label-chip-count">${reminderCount}</span></button>`; 1509 const showingReminders = _activeFilter === 'reminders'; 1510 if (showingReminders && pastReminderCount > 0) { 1511 html += `<button class="notes-label-chip notes-label-clear-past" data-action="clear-past-reminders" title="Delete reminders whose time has passed"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
1511Clear past <span class="notes-label-chip-count">${pastReminderCount}</span></button>`; 1512 } 1513 for (const lbl of sortedLabels) { 1514 html += `<button class="notes-label-chip${_activeLabel === lbl ? ' active' : ''}" data-label="${_esc(lbl)}">#${_esc(lbl)}</button>`; 1515 } 1516 bar.innerHTML = html; 1517 bar.querySelectorAll('.notes-label-chip').forEach(chip => { 1518 chip.addEventListener('click', () => { 1519 if (chip.dataset.action === 'all') { 1520 _activeLabel = null; 1521 _activeFilter = null; 1522 } else if (chip.dataset.action === 'today') { 1523 _activeLabel = null; 1524 _activeFilter = (_activeFilter === 'today') ? null : 'today'; 1525 } else if (chip.dataset.action === 'goals') { 1526 _activeLabel = null; 1527 _activeFilter = (_activeFilter === 'goals') ? null : 'goals'; 1528 } else if (chip.dataset.action === 'default') { 1529 _activeLabel = null; 1530 _activeFilter = (_activeFilter === 'default') ? null : 'default'; 1531 } else if (chip.dataset.action === 'reminders') { 1532 _activeLabel = null; 1533 // Cycle: null â reminders â null â no-reminders â null â reminders â ... 1534 if (_activeFilter === null) { 1535 _activeFilter = _reminderChipNext; 1536 _reminderChipNext = (_reminderChipNext === 'reminders') ? 'no-reminders' : 'reminders'; 1537 } else { 1538 _activeFilter = null; 1539 } 1540 } else if (chip.dataset.action === 'clear-past-reminders') { 1541 _clearPastReminders(); 1542 return; 1543 } else { 1544 _activeFilter = null; 1545 _activeLabel = chip.dataset.label || null; 1546 } 1547 _renderNotes(); 1548 }); 1549 }); 1550} 1551 1552function _renderLabelsInto(_body) { 1553 if (!_body) return; 1554 let bar = _body.querySelector(':scope > .notes-labels-bar'); 1555 if (!bar) { 1556 bar = document.createElement('div'); 1557 bar.className = 'notes-labels-bar'; 1558 _body.appendChild(bar); 1559 } 1560 _renderLabels(_body); 1561} 1562 1563function _ensureNotesChipRegistered() { 1564 if (Modals.isRegistered('notes-panel')) return; 1565 Modals.register('notes-panel', { 1566 railBtnId: 'rail-notes', 1567 sidebarBtnId: 'tool-notes-btn', 1568 restoreFn: () => { openPanel(); }, 1569 closeFn: () => { _forceCloseNotesPanel(); }, 1570 }); 1571} 1572 1573// `direction === 'down'` (mobile swipe-down) MINIMIZES the panel to a 1574// dock chip instead of fully closing â tapping the chip reopens it. 1575// Any other call (close button, programmatic) is a full close. 1576export function closePanel(direction) { 1577 if (!_open) return; 1578 _open = false; 1579 _editingId = null; 1580 _clearViewedReminderGlows(); 1581 const _minimize = direction === 'down'; 1582 if (_minimize) { 1583 _ensureNotesChipRegistered(); 1584 } else if (Modals.isRegistered('notes-panel')) { 1585 Modals.unregister('notes-panel'); 1586 } 1587 1588 // Drop the document keydown listener and the 30s reminder interval â 1589 // both leaked across open/close cycles in the v2 review. 1590 if (_notesKeydownHandler) { 1591 document.removeEventListener('keydown', _notesKeydownHandler); 1592 _notesKeydownHandler = null; 1593 } 1594 if (_notesSelectEscHandler) { 1595 document.removeEventListener('keydown', _notesSelectEscHandler, true); 1596 _notesSelectEscHandler = null; 1597 } 1598 if (_reminderTimer) { 1599 clearInterval(_reminderTimer); 1600 _reminderTimer = null; 1601 } 1602 1603 document.body.classList.remove('notes-view'); 1604 document.body.classList.remove('notes-mobile-mode'); 1605 document.body.classList.remove('notes-drag-mode'); 1606 // Closing the panel should PRESERVE in-progress edits, not drop them. 1607 // Commit any open in-place editor, and close the mobile fullscreen 1608 // overlay with save=true so the note is persisted. 1609 try { _commitOpenInPlaceEditor(); } catch {} 1610 _closeMobileFullscreenEdit({ save: true }); 1611 // /notes route may have collapsed the wide sidebar to a rail; restore. 1612 try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} 1613 1614 const btn = document.getElementById('tool-notes-btn'); 1615 if (btn) btn.classList.remove('active'); 1616 1617 const pane = document.getElementById('notes-pane'); 1618 const backdrop = document.getElementById('notes-pane-backdrop'); 1619 if (pane) { 1620 // Scale-out + fade. Match the enter animation duration so close feels 1621 // like the same gesture played backwards. 1622 pane.classList.add('notes-pane-leaving'); 1623 const _cleanup = () => {
1624 try { pane.remove(); } catch {} 1625 try { backdrop?.remove(); } catch {} 1626 }; 1627 pane.addEventListener('animationend', _cleanup, { once: true }); 1628 // Belt-and-braces: if animation is skipped (reduced motion / detached 1629 // tab) the listener won't fire; remove after the expected duration. 1630 setTimeout(_cleanup, 220); 1631 } else if (backdrop) { 1632 backdrop.remove(); 1633 } 1634 // Show the dock chip for a swipe-down minimize (tap it to reopen). 1635 if (_minimize) { try { Modals.minimize('notes-panel'); } catch {} } 1636} 1637 1638export function togglePanel() { 1639 if (_open) closePanel(); 1640 else openPanel(); 1641} 1642 1643export function isPanelOpen() { return _open; } 1644 1645// ---- Render ---- 1646 1647// FLIP animation â capture positions before render, animate back after 1648function _captureCardPositions() { 1649 const body = document.querySelector('#notes-pane .notes-pane-body'); 1650 if (!body) return null; 1651 const positions = new Map(); 1652 body.querySelectorAll('.note-card').forEach(card => { 1653 const id = card.dataset.noteId; 1654 if (id) positions.set(id, card.getBoundingClientRect()); 1655 }); 1656 return positions; 1657} 1658 1659function _animateReflow(prevPositions) { 1660 if (!prevPositions || !prevPositions.size) return; 1661 const body = document.querySelector('#notes-pane .notes-pane-body'); 1662 if (!body) return; 1663 body.querySelectorAll('.note-card').forEach(card => { 1664 const id = card.dataset.noteId; 1665 const prev = prevPositions.get(id); 1666 if (!prev) return; 1667 const next = card.getBoundingClientRect(); 1668 const dx = prev.left - next.left; 1669 const dy = prev.top - next.top; 1670 if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; 1671 // Invert: jump back to old position 1672 card.style.transition = 'none'; 1673 card.style.transform = `translate(${dx}px, ${dy}px)`; 1674 // Play: animate to 0 1675 requestAnimationFrame(() => { 1676 card.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.2, 0.64, 1)'; 1677 card.style.transform = ''; 1678 card.addEventListener('transitionend', () => { 1679 card.style.transition = ''; 1680 }, { once: true }); 1681 }); 1682 }); 1683} 1684 1685function _renderNotes() { 1686 _updateRailBadge(); 1687 const body = document.querySelector('#notes-pane .notes-pane-body'); 1688 if (!body) return; 1689 const prevPositions = _captureCardPositions(); 1690 const activeReminderHighlights = _loadActiveHighlights(); 1691 1692 let filtered = _activeLabel ? _notes.filter(n => _noteTags(n).includes(_activeLabel)) : _notes; 1693 if (_activeFilter === 'reminders') { 1694 filtered = filtered.filter(n => n.due_date && _hasTimeComponent(n.due_date)); 1695 } else if (_activeFilter === 'no-reminders') { 1696 filtered = filtered.filter(n => !(n.due_date && _hasTimeComponent(n.due_date))); 1697 } else if (_activeFilter === 'default') { 1698 filtered = filtered.filter(n => _visibleNoteTags(n).length === 0); 1699 } else if (_activeFilter === 'goals') { 1700 filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived); 1701 } else if (_activeFilter === 'today') { 1702 // Today view: only goals that still have an unchecked step. 1703 filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)); 1704 } 1705 if (_searchQuery) { 1706 filtered = filtered.filter(n => { 1707 const q = _searchQuery; 1708 if ((n.title || '').toLowerCase().includes(q)) return true; 1709 if ((n.content || '').toLowerCase().includes(q)) return true; 1710 if ((n.label || '').toLowerCase().includes(q)) return true; 1711 if (Array.isArray(n.items) && n.items.some(it => (it.text || '').toLowerCase().includes(q))) return true; 1712 return false; 1713 }); 1714 } 1715 const sorted = [...filtered].sort((a, b) => { 1716 // In reminders view: sort by due date ascending (soonest first) 1717 if (_activeFilter === 'reminders') { 1718 const da = new Date(a.due_date || 0).getTime(); 1719 const db = new Date(b.due_date || 0).getTime(); 1720 return da - db; 1721 } 1722 // Archived view: newest archived first (ignore manual sort_order). 1723 if (_showingArchived) { 1724 return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); 1725 } 1726 if (a.pinned && !b.pinned) return -1; 1727 if (!a.pinned && b.pinned) return 1; 1728 // Active reminders (due date in the past, not done/archived) rank 1729 // immediately under the pinned block. 1730 const aActive = _hasActiveReminder(a); 1731 const bActive = _hasActiveReminder(b); 1732 if (aActive && !bActive) return -1; 1733 if (!aActive && bActive) return 1; 1734 const so = (a.sort_order || 0) - (b.sort_order || 0); 1735 if (so !== 0) return so;
1736 return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); 1737 }); 1738 1739 let html = ''; 1740 // Today view: render a compact card listing the next-unchecked step from 1741 // each active goal. Tapping a step toggles it done (same idx-based wiring 1742 // as regular checkboxes). Tapping the title opens the goal note for full 1743 // editing. 1744 if (_activeFilter === 'today') { 1745 body.innerHTML = ''; 1746 _renderLabelsInto(body); 1747 _renderQuickAdd(body); 1748 if (sorted.length === 0) { 1749 body.insertAdjacentHTML('beforeend', `<div class="notes-empty">All caught up â no pending goal steps right now.</div>`); 1750 } else { 1751 let todayHtml = `<div class="notes-today-wrap"> 1752 <div class="notes-today-header"> 1753 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> 1754 <span>Today · one step per goal</span> 1755 </div> 1756 <div class="notes-today-list">`; 1757 for (const note of sorted) { 1758 const next = _nextGoalStep(note); 1759 if (!next) continue; 1760 const progress = _goalProgress(note).trim(); 1761 todayHtml += `<div class="notes-today-row" data-note-id="${note.id}"> 1762 <span class="note-check-dot" data-note-id="${note.id}" data-idx="${next.idx}" title="Mark step done"></span> 1763 <div class="notes-today-text"> 1764 <div class="notes-today-title" data-action="edit" data-note-id="${note.id}">${_esc(note.title || '(untitled goal)')}</div> 1765 <div class="notes-today-step">${_linkify(next.item.text || '')}</div> 1766 </div> 1767 <span class="notes-today-progress">${_esc(progress)}</span> 1768 </div>`; 1769 } 1770 todayHtml += `</div></div>`; 1771 body.insertAdjacentHTML('beforeend', todayHtml); 1772 } 1773 _wireTodayView(body); 1774 return; 1775 } 1776 for (const note of sorted) { 1777 if (_editingId === note.id) continue; // skip â form is shown instead 1778 const borderColor = COLOR_HEX[note.color || ''] || 'var(--border)'; 1779 const dueFmt = _formatDueDate(note.due_date); 1780 const overdue = _isDueOverdue(note.due_date); 1781 1782 let contentHtml = ''; 1783 if (_hasItems(note) && Array.isArray(note.items)) { 1784 // Goal notes can carry a free-form description above the step list â 1785 // todos rarely do, but the same render works for both. 1786 if (note.note_type === 'goal' && (note.content || '').trim()) { 1787 const fullText = note.content || ''; 1788 const preview = fullText.length > 300 ? fullText.slice(0, 300) + 'â¦' : fullText; 1789 contentHtml += `<div class="note-goal-desc">${_esc(preview)}</div>`; 1790 } 1791 contentHtml += '<div class="note-checklist-preview">'; 1792 // Show ALL items â the preview container is scrollable (CSS caps 1793 // its max-height + overflow-y:auto), so there's no need to truncate. 1794 for (let i = 0; i < note.items.length; i++) { 1795 const item = note.items[i]; 1796 const doneClass = item.done ? ' done' : ''; 1797 const indent = Math.min(item.indent || 0, 3); 1798 contentHtml += `<div class="note-checkbox${doneClass}" data-note-id="${note.id}" data-idx="${i}" style="padding-left:${indent * 16}px"> 1799 <span class="note-check-dot" title="Mark done"></span> 1800 <span class="note-check-text">${_linkify(item.text)}</span> 1801 <button class="note-checkbox-rm" data-note-id="${note.id}" data-idx="${i}" title="Delete item"> 1802 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 1803 </button> 1804 </div>`; 1805 } 1806 contentHtml += '</div>'; 1807 } else { 1808 const fullText = note.content || ''; 1809 const preview = fullText.length > 600 ? fullText.slice(0, 600) + 'â¦' : fullText; 1810 // _linkify already calls _esc internally, so URLs become clickable 1811 // anchors (used by e.g. the "remind me to reply" email deep-link). 1812 contentHtml = preview ? `<div class="note-content-preview">${_linkify(preview)}</div>` : ''; 1813 } 1814 1815 const isBg = _isBgImage(note.color); 1816 const cc = (note.color && !isBg) ? ' note-color-' + note.color : ''; 1817 const cardStyle = isBg ? ` style="${_customColorStyle(note.color)}"` : ''; 1818 const sel = _selectedIds.has(note.id) ? ' note-card-selected' : ''; 1819 const reminderTagHtml = note.due_date && _hasTimeComponent(note.due_date) 1820 ? `<div class="note-card-reminder${overdue ? ' overdue' : ''}">
1821 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> 1822 <span>${_esc(_formatReminderTag(note.due_date))}${note.repeat && note.repeat !== 'none' ? ' · ' + _esc(_formatRepeatLabel(note.repeat, new Date(note.due_date))) : ''}</span> 1823 </div>` 1824 : ''; 1825 const noteTags = _visibleNoteTags(note); 1826 const dueBadge = dueFmt && !_hasTimeComponent(note.due_date) ? `<span class="note-due-inline${overdue ? ' note-due-overdue' : ''}">${dueFmt}</span>` : ''; 1827 const colorDots = COLORS.map(c => `<span class="note-card-color-dot${_dotIsActive(c.value, note.color) ? ' active' : ''}" data-color="${c.value}" style="background:${_dotBg(c.value, note.color)}" title="${c.name || 'default'}"></span>`).join(''); 1828 const goalClass = note.note_type === 'goal' ? ' note-card-goal' : ''; 1829 const reminderGlowClass = activeReminderHighlights.has(note.id) && _hasActiveReminder(note) ? ' note-card-reminder-fired-sticky' : ''; 1830 const goalPill = note.note_type === 'goal' 1831 ? `<span class="note-goal-pill" title="AI-broken-down goal"> 1832 <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> 1833 Goal${_goalProgress(note)} 1834 </span>` 1835 : ''; 1836 html += `<div class="note-card${note.pinned ? ' note-card-pinned' : ''}${cc}${sel}${goalClass}${reminderGlowClass}${_selectMode ? ' note-card-selectmode' : ''}" draggable="${(_selectMode || _isNotesMobileMode()) ? 'false' : 'true'}" data-note-id="${note.id}"${cardStyle}> 1837 ${_selectMode ? `<input type="checkbox" class="memory-select-cb note-card-cb" data-note-id="${note.id}" ${_selectedIds.has(note.id) ? 'checked' : ''} />` : ''} 1838 ${goalPill} 1839 <button class="note-card-pin${note.pinned ? ' active' : ''}" data-note-id="${note.id}" title="${note.pinned ? 'Unpin' : 'Pin'}"> 1840 <svg width="16" height="16" viewBox="0 0 24 28" fill="${note.pinned ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${note.pinned ? ' style="color:var(--accent,var(--red));"' : ''}><g transform="rotate(${note.pinned ? 0 : 45} 12 14)" style="transition:transform 0.2s ease;"><line x1="12" y1="17" x2="12" y2="27"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"/></g></svg> 1841 </button> 1842 ${_showingArchived 1843 ? `<button class="note-card-corner-trash" data-note-id="${note.id}" title="Delete forever" aria-label="Delete forever"> 1844 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> 1845 </button> 1846 <button class="note-card-corner-unarchive" data-note-id="${note.id}" title="Unarchive" aria-label="Unarchive note"> 1847 <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14l-5-5 5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg> 1848 </button>` 1849 : `<button class="note-card-done" data-note-id="${note.id}" title="Mark done" aria-label="Mark done"> 1850 <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> 1851 </button> 1852 ${_hasItems(note) ? `<button class="note-card-copy note-card-copy-corner" data-note-id="${note.id}" title="Copy all items" aria-label="Copy all items"> 1853 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> 1854 </button>` : ''}`} 1855 <div class="note-card-header"> 1856 <div class="note-card-title${note.title ? '' : ' empty'}" data-action="edit">${_esc(note.title || '')}</div> 1857 ${dueBadge} 1858 </div> 1859 ${_safeImgSrc(note.image_url) ? `<img class="note-card-image" src="${_esc(_safeImgSrc(note.image_url))}" alt="" draggable="false" />` : ''} 1860 ${contentHtml} 1861 ${_hasItems(note) ? `<div class="note-cl-quickadd"><input type="text" class="note-cl-quickadd-input" placeholder="+ Add item" data-note-id="${note.id}" /></div>` : ''} 1862 ${reminderTagHtml} 1863 ${noteTags.length ? `<div class="note-card-label">${noteTags.map(t => `<button type="button" class="note-card-label-chip" data-note-label-filter="${_esc(t)}" title="Filter #${_esc(t)}">#${_esc(t)}</button>`).join(' ')}</div>` : ''} 1864 ${note.agent_session_id ? `<button class="note-agent-tag" data-note-id="${note.id}
1864" data-session-id="${_esc(note.agent_session_id)}" title="Open the agent's chat for this note"> 1865 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg> 1866 <span>Agent</span> 1867 </button>` : ''} 1868 <div class="note-card-actions"> 1869 <div class="note-card-colors">${colorDots}</div> 1870 <span style="flex:1"></span> 1871 ${_showingArchived ? ` 1872 <button class="note-card-action note-card-delete" data-note-id="${note.id}" title="Delete permanently"> 1873 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> 1874 </button> 1875 <button class="note-card-action note-card-unarchive" data-note-id="${note.id}" title="Unarchive"> 1876 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 4 3 10 9 10"/></svg> 1877 </button>` : ` 1878 ${_hasItems(note) ? ` 1879 <button class="note-card-action note-card-copy" data-note-id="${note.id}" title="Copy all items"> 1880 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> 1881 </button>` : ''} 1882 <button class="note-card-action note-card-archive" data-note-id="${note.id}" title="Save (archive)"> 1883 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> 1884 </button> 1885 <button class="note-card-action note-card-delete" data-note-id="${note.id}" title="Delete"> 1886 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 1887 </button> 1888 <button class="note-card-action note-card-corner-menu" data-note-id="${note.id}" title="More" aria-label="More actions"> 1889 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/></svg> 1890 </button>`} 1891 </div> 1892 </div>`; 1893 } 1894 1895 // Always render quick-add at top (collapsed unless user is typing) 1896 const existingForm = body.querySelector('.note-form'); 1897 if (existingForm && _editingId === '__new__') { 1898 // Keep the expanded form, replace cards after it 1899 const next = [...body.children].filter(c => c !== existingForm); 1900 next.forEach(c => c.remove()); 1901 if (sorted.length === 0) { 1902 body.insertAdjacentHTML('beforeend', '<div class="notes-empty-msg">No notes <span style="vertical-align:-3px;margin-left:4px;">' + uiModule.emptyStateIcon('smiley') + '</span></div>'); 1903 } else { 1904 existingForm.insertAdjacentHTML('afterend', html); 1905 } 1906 } else { 1907 body.innerHTML = ''; 1908 _renderLabelsInto(body); 1909 _renderQuickAdd(body); 1910 if (sorted.length === 0) { 1911 body.insertAdjacentHTML('beforeend', '<div class="notes-empty-msg">No notes yet <span style="vertical-align:-3px;margin-left:4px;">' + uiModule.emptyStateIcon('smiley') + '</span></div>'); 1912 } else { 1913 body.insertAdjacentHTML('beforeend', html); 1914 } 1915 } 1916 1917 _bindCardEvents(body); 1918 _animateReflow(prevPositions); 1919 _applyMasonry(body); 1920} 1921 1922// In grid view, lay out the cards as masonry by 1923// computing each card's `grid-row-end: span N` from its measured height 1924// (rows are 4px tall + 8px row gap simulated via card margin-bottom). The 1925// grid's `grid-auto-flow: dense` packs columns independently so left/right 1926// lanes no longer share row heights. 1927// 1928// Re-runs on layout-affecting changes via ResizeObserver bound per-card. 1929let _masonryObserver = null; 1930function _applyMasonry(body) { 1931 if (!body) return; 1932 const pane = body.closest('.notes-pane'); 1933 const isGrid = pane?.classList.contains('notes-view-grid'); 1934 const isMobileGrid = isGrid && window.matchMedia('(max-width: 768px)').matches; 1935 // Tear down any prior observer (defensive â _renderNotes wipes body.innerHTML). 1936 if (_masonryObserver) { try { _masonryObserver.disconnect(); } catch {} _masonryObserver = null; } 1937 if (!isGrid) { 1938 // Clear any leftover inline spans so list view lays out normally. 1939 body.querySelectorAll('.note-card, .notes-labels-bar, .notes-quick-add, .note-form').forEach(c => { c.style.gridRowEnd = ''; }); 1940 return; 1941 } 1942 const ROW_PX = 4; 1943 const spanForHeight = (h) => Math.max(1, Math.ceil(h / ROW_PX)); 1944 const recomputeFullRows = () => { 1945 const quickAdd = body.querySelector('.notes-quick-add'); 1946 const labelsBar = body.querySelector('.notes-labels-bar'); 1947 if (labelsBar && getComputedStyle(labelsBar).display !== 'none') { 1948 const shave = isMobileGrid ? 4 : 0; 1949 labelsBar.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsBar.scrollHeight) - shave)}`; 1950 } 1951 if (quickAdd) { 1952 const shave = isMobileGrid ? 4 : 0; 1953 quickAdd.style.gridRowEnd = `span ${Math.max(1, spanForHeight(quickAdd.scrollHeight + 10) - shave)}`; 1954 } 1955 body.querySelectorAll('.note-form').forEach(form => { 1956 form.style.gridColumn = '1 / -1'; 1957 const isDrawForm = !!form.querySelector('.note-form-type-seg.is-draw');
1958 const minSpan = isMobileGrid ? (isDrawForm ? 104 : 64) : 1; 1959 const renderedHeight = form.getBoundingClientRect?.().height || 0; 1960 const drawReserve = isDrawForm && isMobileGrid ? 12 : 12; 1961 const measuredHeight = Math.max(form.scrollHeight, renderedHeight) + drawReserve; 1962 form.style.gridRowEnd = `span ${Math.max(minSpan, spanForHeight(measuredHeight))}`; 1963 }); 1964 }; 1965 const recompute = (card) => { 1966 // scrollHeight returns the natural content height â card.getBoundingClientRect() 1967 // would return the grid cell height (collapsed to 4px until the span is set, 1968 // which is the value we're trying to compute). 1969 const h = card.scrollHeight + (isMobileGrid ? 6 : 8); 1970 if (h <= 0) return; 1971 card.style.gridRowEnd = `span ${spanForHeight(h)}`; 1972 }; 1973 recomputeFullRows(); 1974 body.querySelectorAll('.note-card').forEach(recompute); 1975 // Watch masonry participants â content can grow (image load, todo edits, 1976 // quick-add/form expansion), and stale spans are what cause visual merging. 1977 if ('ResizeObserver' in window) { 1978 _masonryObserver = new ResizeObserver(entries => { 1979 let fullRowsChanged = false; 1980 for (const e of entries) { 1981 if (e.target.classList.contains('note-card')) recompute(e.target); 1982 else fullRowsChanged = true; 1983 } 1984 if (fullRowsChanged) recomputeFullRows(); 1985 }); 1986 body.querySelectorAll('.note-card').forEach(c => _masonryObserver.observe(c)); 1987 body.querySelectorAll('.notes-labels-bar, .notes-quick-add, .note-form').forEach(c => _masonryObserver.observe(c)); 1988 } 1989} 1990 1991// Wire the Today aggregated view: tap a step's dot toggles it done; tap 1992// the goal title opens the full note for editing. Done steps fade and the 1993// next pending step rotates in on the next render. 1994function _wireTodayView(body) { 1995 body.querySelectorAll('.notes-today-row .note-check-dot').forEach(dot => { 1996 dot.addEventListener('click', async (e) => { 1997 e.stopPropagation(); 1998 const id = dot.dataset.noteId; 1999 const idx = parseInt(dot.dataset.idx); 2000 const note = _notes.find(n => n.id === id); 2001 if (!note || !Array.isArray(note.items) || !note.items[idx]) return; 2002 note.items[idx].done = !note.items[idx].done; 2003 const row = dot.closest('.notes-today-row'); 2004 if (row) row.classList.add('done'); 2005 try { 2006 await _patchNote(id, { items: note.items }); 2007 // Re-render so the next pending step bubbles up (or the row drops 2008 // out entirely if the goal is fully done now). 2009 _renderNotes(); 2010 // Confetti when ALL items just turned done. 2011 if (note.items.every(it => it.done)) { 2012 const r = (row || dot).getBoundingClientRect(); 2013 spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); 2014 } 2015 } catch { 2016 note.items[idx].done = !note.items[idx].done; 2017 } 2018 }); 2019 }); 2020 body.querySelectorAll('.notes-today-title').forEach(el => { 2021 el.addEventListener('click', () => { 2022 const id = el.dataset.noteId; 2023 if (!id) return; 2024 // Drop the Today filter first so the regular card list is rendered; 2025 // _editNote needs to find a .note-card in the DOM to replace with 2026 // the editor form. 2027 _activeFilter = null; 2028 _renderNotes(); 2029 _editNote(id); 2030 }); 2031 }); 2032} 2033 2034function _renderQuickAdd(body) { 2035 const wrap = document.createElement('div'); 2036 wrap.className = 'notes-quick-add'; 2037 // 2-pill Note/Todo toggle mirrors the full form's type-seg (minus Draw â 2038 // drawing happens in the expanded form). The pill that's active steers 2039 // both the placeholder and the type the form opens in. 2040 wrap.innerHTML = ` 2041 <div class="notes-quick-type-seg is-todo" role="group" aria-label="New item type"> 2042 <button type="button" class="notes-quick-type-pill" data-type="note" aria-label="Note" aria-pressed="false" title="Note"> 2043 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> 2044 </button> 2045 <button type="button" class="notes-quick-type-pill active" data-type="todo" aria-label="To-do" aria-pressed="true" title="To-do"> 2046 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg> 2047 </button> 2048 </div> 2049 <input type="text" class="notes-quick-input" placeholder="Add a to-doâ¦" /> 2050 <button class="notes-quick-icon" data-action="photo" title="Attach photo"> 2051 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 2052 </button> 2053 `; 2054 body.appendChild(wrap); 2055 2056 const input = wrap.querySelector('.notes-quick-input'); 2057 const seg = wrap.querySelector('.notes-quick-type-seg'); 2058 let currentType = 'todo'; 2059 const setType = (t) => { 2060 if (t !== 'note' && t !== 'todo') return; 2061 currentType = t; 2062 seg.classList.toggle('is-todo', t === 'todo'); 2063 seg.classList.toggle('is-note', t === 'note'); 2064 seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { 2065 const on = p.dataset.type === t; 2066 p.classList.toggle('active', on); 2067 p.setAttribute('aria-pressed', on ? 'true' : 'false'); 2068 }); 2069 input.placeholder = t === 'note' ? 'Add a noteâ¦' : 'Add a to-doâ¦'; 2070 }; 2071 seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { 2072 p.addEventListener('click', (e) => { 2073 e.stopPropagation(); 2074 setType(p.dataset.type); 2075 }); 2076 }); 2077 // Click input or type â expand to full form 2078 const expandToForm = (initialType = 'note', initialText = '') => { 2079 _editingId = '__new__'; 2080 const form = _buildForm({ note_type: initialType }); 2081 form.classList.add('note-form-new'); 2082 if (initialText) { 2083 const titleEl = form.querySelector('.note-form-title'); 2084 if (titleEl) titleEl.value = initialText; 2085 } 2086 const mobileGrid = body.closest('.notes-pane')?.classList.contains('notes-view-grid') 2087 && window.matchMedia('(max-width: 768px)').matches; 2088 if (mobileGrid) { 2089 form.style.gridColumn = '1 / -1'; 2090 form.style.gridRowEnd = 'span 64'; 2091 } 2092 wrap.replaceWith(form); 2093 _applyMasonry(body); 2094 requestAnimationFrame(() => _applyMasonry(body)); 2095 const titleEl = form.querySelector('.note-form-title'); 2096 if (titleEl) {
2097 titleEl.focus(); 2098 // Move caret to end 2099 titleEl.setSelectionRange(titleEl.value.length, titleEl.value.length); 2100 } 2101 }; 2102 // Expand only on real intent: a click directly on the input, or actual 2103 // typing. Focus alone â including focus stolen from a missed nearby 2104 // click â no longer creates an empty form. 2105 input.addEventListener('click', () => expandToForm(currentType, input.value)); 2106 input.addEventListener('input', () => expandToForm(currentType, input.value)); 2107 wrap.querySelector('[data-action="photo"]').addEventListener('click', (e) => { 2108 e.stopPropagation(); 2109 expandToForm(currentType); 2110 // Trigger photo input on the new form 2111 setTimeout(() => document.querySelector('.note-form-photo-btn')?.click(), 50); 2112 }); 2113} 2114 2115function _bindCardEvents(body) { 2116 const tapToEditOrSelect = (cardEl) => { 2117 const id = cardEl.dataset.noteId; 2118 if (_selectMode) { 2119 const cb = cardEl.querySelector('.note-card-cb'); 2120 if (cb) { 2121 cb.checked = !cb.checked; 2122 cb.dispatchEvent(new Event('change')); 2123 } 2124 } else if (_isNotesMobileMode()) { 2125 // Mobile: open the per-note fullscreen edit overlay instead of the 2126 // in-place form. Tiles on mobile are read-only previews. 2127 _openMobileFullscreenEdit(id, cardEl); 2128 } else { 2129 _editNote(id); 2130 } 2131 }; 2132 // Mobile: long-press anywhere on a note card â enter drag-to-reorder mode. 2133 // Cancelled by movement (so it doesn't interfere with vertical scrolling) 2134 // or by lifting the finger before the timer fires. 2135 if (_isNotesMobileMode()) { 2136 body.querySelectorAll('.note-card').forEach(card => _bindLongPressDrag(card)); 2137 } 2138 body.querySelectorAll('.note-card.note-card-reminder-fired-sticky').forEach(card => { 2139 card.addEventListener('click', () => _setReminderCardGlow(card.dataset.noteId, false), true); 2140 }); 2141 // Click title â edit, or toggle select in select mode 2142 body.querySelectorAll('.note-card-title[data-action="edit"]').forEach(el => { 2143 el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); }); 2144 }); 2145 // Click content â edit, or toggle select in select mode 2146 body.querySelectorAll('.note-content-preview').forEach(el => { 2147 el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); }); 2148 }); 2149 // Click empty area of checklist preview (not on checkbox/X) â edit 2150 body.querySelectorAll('.note-checklist-preview').forEach(el => { 2151 el.addEventListener('click', (e) => { 2152 if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return; 2153 e.stopPropagation(); 2154 tapToEditOrSelect(el.closest('.note-card')); 2155 }); 2156 }); 2157 // Clicking todo item text now toggles its checkbox â let the click bubble 2158 // up to the parent .note-checkbox row handler. To open the editor, the 2159 // user clicks the pencil corner. 2160 // (No-op block kept as a marker â removing the listener entirely means 2161 // clicks naturally bubble to the row toggle below.) 2162 // In select mode, clicking anywhere on the card toggles selection 2163 if (_selectMode) { 2164 body.querySelectorAll('.note-card').forEach(card => { 2165 card.addEventListener('click', (e) => { 2166 if (e.target.closest('.note-card-cb')) return; // checkbox handles itself 2167 e.stopPropagation(); 2168 tapToEditOrSelect(card); 2169 }); 2170 }); 2171 } 2172 // Mobile, non-select: tapping anywhere on the card body (not on an 2173 // interactive child â buttons, pin, checkbox, color dot, reminder pill, 2174 // agent tag, links) opens the fullscreen editor. Previously only the 2175 // title / content preview triggered edit, so padding + empty gutters were 2176 // dead zones that felt broken on mobile. 2177 if (_isNotesMobileMode() && !_selectMode) { 2178 const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb'; 2179 body.querySelectorAll('.note-card').forEach(card => { 2180 card.addEventListener('click', (e) => { 2181 if (e.target.closest(_INTERACTIVE)) return; 2182 e.stopPropagation(); 2183 tapToEditOrSelect(card); 2184 }); 2185 }); 2186 } 2187 // Multi-select checkbox (only in select mode) 2188 body.querySelectorAll('.note-card-cb').forEach(cb => { 2189 cb.addEventListener('click', (e) => e.stopPropagation()); 2190 cb.addEventListener('change', () => { 2191 const id = cb.dataset.noteId; 2192 if (cb.checked) _selectedIds.add(id); 2193 else _selectedIds.delete(id); 2194 cb.closest('.note-card').classList.toggle('note-card-selected', cb.checked); 2195 _updateBulkBar(); 2196 }); 2197 }); 2198 // Pin toggle (optimistic) 2199 body.querySelectorAll('.note-card-pin').forEach(btn => { 2200 btn.addEventListener('click', (e) => { 2201 e.stopPropagation(); 2202 const id = btn.dataset.noteId; 2203 const note = _notes.find(n => n.id === id); 2204 if (!note) return; 2205 const prevPinned = note.pinned; 2206 const prevSortOrder = note.sort_order; 2207 note.pinned = !prevPinned; 2208 const patch = { pinned: note.pinned }; 2209 if (note.pinned) { 2210 const minPinned = _notes 2211 .filter(n => n.pinned && n.id !== id) 2212 .reduce((m, n) => Math.min(m, n.sort_order || 0), 0); 2213 note.sort_order = minPinned - 1; 2214 patch.sort_order = note.sort_order; 2215 } 2216 _renderNotes(); 2217 _patchNote(id, patch).catch(() => { 2218 note.pinned = prevPinned; 2219 note.sort_order = prevSortOrder; 2220 _renderNotes(); 2221 uiModule.showError('Failed to pin'); 2222 }); 2223 }); 2224 }); 2225 // Color picker 2226 const _applyCardColor = async (card, id, newColor) => { 2227 const isBg = _isBgImage(newColor); 2228 COLORS.forEach(c => { if (c.value && c.value !== 'custom') card.classList.remove('note-color-' + c.value); }); 2229 if (newColor && !isBg) card.classList.add('note-color-' + newColor); 2230 if (isBg) card.setAttribute('style', _customColorStyle(newColor)); 2231 else card.removeAttribute('style'); 2232 card.querySelectorAll('.note-card-color-dot').forEach(d => { 2233 d.classList.toggle('active', _dotIsActive(d.dataset.color, newColor)); 2234 d.style.background = _dotBg(d.dataset.color, newColor); 2235 }); 2236 try { await _patchNote(id, { color: newColor || null }); const note = _notes.find(n => n.id === id); if (note) note.color = newColor; }
2237 catch { uiModule.showError('Failed to update color'); } 2238 }; 2239 body.querySelectorAll('.note-card-color-dot').forEach(dot => { 2240 dot.addEventListener('click', (e) => { 2241 e.stopPropagation(); 2242 const card = dot.closest('.note-card'); 2243 const id = card.dataset.noteId; 2244 if (dot.dataset.color === 'custom') { 2245 _pickCustomBgImage().then(url => { if (url) _applyCardColor(card, id, 'bg:' + url); }); 2246 return; 2247 } 2248 _applyCardColor(card, id, dot.dataset.color); 2249 }); 2250 }); 2251 // Plain pencil corner â open editor. The unarchive corner shares the 2252 // .note-card-edit-corner class for styling, so :not() keeps the edit 2253 // handler off it. 2254 body.querySelectorAll('.note-card-edit-corner:not(.note-card-unarchive-corner)').forEach(btn => { 2255 btn.addEventListener('click', (e) => { 2256 e.stopPropagation(); 2257 const id = btn.dataset.noteId; 2258 if (id) _editNote(id); 2259 }); 2260 }); 2261 // Copy corner â bottom-right, just left of the Done check. Shared with 2262 // the Ctrl/Cmd+C shortcut wired further down so both code paths run the 2263 // same serializer + feedback flash. 2264 // ⯠corner menu â Copy + Agent (solve-this-todo). 2265 body.querySelectorAll('.note-card-corner-menu').forEach(btn => { 2266 btn.addEventListener('click', (e) => { 2267 e.preventDefault(); 2268 e.stopPropagation(); 2269 _openNoteCornerMenu(btn); 2270 }); 2271 }); 2272 // Agent tag â opens the chat session the agent ran for this note. 2273 body.querySelectorAll('.note-agent-tag').forEach(tag => { 2274 tag.addEventListener('click', (e) => { 2275 e.preventDefault(); 2276 e.stopPropagation(); 2277 const sid = tag.dataset.sessionId; 2278 const _sm = window.sessionModule; 2279 if (sid && _sm && _sm.selectSession) { closePanel(); _sm.selectSession(sid); } 2280 }); 2281 }); 2282 body.querySelectorAll('.note-card-label-chip').forEach(chip => { 2283 chip.addEventListener('click', (e) => { 2284 e.preventDefault(); 2285 e.stopPropagation(); 2286 const label = chip.dataset.noteLabelFilter; 2287 if (!label) return; 2288 if (_activeLabel === label && _activeFilter === null) { 2289 _activeLabel = null; 2290 } else { 2291 _activeFilter = null; 2292 _activeLabel = label; 2293 } 2294 _renderNotes(); 2295 }); 2296 }); 2297 // Done (â) at bottom-right â only visible on hover for active notes. 2298 body.querySelectorAll('.note-card-done').forEach(btn => { 2299 btn.addEventListener('click', (e) => { 2300 e.stopPropagation(); 2301 const id = btn.dataset.noteId; 2302 const card = btn.closest('.note-card'); 2303 const idx = _notes.findIndex(n => n.id === id); 2304 if (idx < 0) return; 2305 // Celebrate completion â same confetti shower the bulk-archive uses. 2306 if (card) { 2307 const r = card.getBoundingClientRect(); 2308 spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); 2309 } 2310 const removed = _notes.splice(idx, 1)[0]; 2311 const undo = () => _undoArchive(removed, idx); 2312 _pushUndo({ label: 'archive', run: undo }); 2313 const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>'; 2314 const finish = () => { 2315 _renderNotes(); 2316 _patchNote(id, { archived: true }).then(() => { 2317 uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' }); 2318 }).catch(() => { 2319 _notes.splice(idx, 0, removed); 2320 _renderNotes(); 2321 uiModule.showError('Failed to archive'); 2322 }); 2323 }; 2324 if (card) { 2325 card.classList.add('note-card-sliding-out'); 2326 let done = false; 2327 const once = () => { if (done) return; done = true; finish(); }; 2328 card.addEventListener('transitionend', once, { once: true }); 2329 setTimeout(once, 400); 2330 } else { 2331 finish(); 2332 } 2333 }); 2334 }); 2335 // Unarchive corner â only visible in archive view. 2336 body.querySelectorAll('.note-card-corner-unarchive').forEach(btn => { 2337 btn.addEventListener('click', (e) => { 2338 e.stopPropagation(); 2339 const id = btn.dataset.noteId; 2340 const idx = _notes.findIndex(n => n.id === id); 2341 if (idx < 0) return; 2342 const removed = _notes.splice(idx, 1)[0]; 2343 _renderNotes(); 2344 _patchNote(id, { archived: false }).then(() => uiModule.showToast('Unarchived')).catch(() => { 2345 _notes.splice(idx, 0, removed); 2346 _renderNotes(); 2347 uiModule.showError('Failed to unarchive'); 2348 }); 2349 }); 2350 }); 2351 // Trash corner â archive view only. Permanent delete, no confirmation. 2352 body.querySelectorAll('.note-card-corner-trash').forEach(btn => { 2353 btn.addEventListener('click', (e) => { 2354 e.stopPropagation(); 2355 const id = btn.dataset.noteId; 2356 const idx = _notes.findIndex(n => n.id === id); 2357 if (idx < 0) return; 2358 const removed = _notes.splice(idx, 1)[0]; 2359 _renderNotes(); 2360 _deleteNoteApi(id).then(() => uiModule.showToast('Deleted')).catch(() => { 2361 _notes.splice(idx, 0, removed); 2362 _renderNotes(); 2363 uiModule.showError('Failed to delete'); 2364 }); 2365 }); 2366 }); 2367 2368 body.querySelectorAll('.note-card-archive').forEach(btn => { 2369 btn.addEventListener('click', (e) => { 2370 e.stopPropagation(); 2371 const id = btn.dataset.noteId; 2372 if (!id) return; 2373 const note = _notes.find(n => n.id === id); 2374 const card = btn.closest('.note-card'); 2375 // Confetti when archiving a fully-completed checklist (todo or goal). 2376 if (note && _hasItems(note) && card) { 2377 const undone = (note.items || []).filter(i => !i.done); 2378 if (undone.length === 0) { 2379 const r = card.getBoundingClientRect(); 2380 spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); 2381 } 2382 } 2383 let done = false;
2384 const finishRemove = () => { 2385 if (done) return; 2386 done = true; 2387 const curIdx = _notes.findIndex(n => n.id === id); 2388 if (curIdx < 0) return; 2389 const removed = _notes.splice(curIdx, 1)[0]; 2390 _renderNotes(); 2391 const undo = () => _undoArchive(removed, curIdx); 2392 _pushUndo({ label: 'archive', run: undo }); 2393 const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>'; 2394 _patchNote(id, { archived: true }).then(() => { 2395 uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' }); 2396 }).catch(() => { 2397 _notes.splice(curIdx, 0, removed); 2398 _renderNotes(); 2399 uiModule.showError('Failed to archive'); 2400 }); 2401 }; 2402 if (card) { 2403 card.classList.add('note-card-sliding-out'); 2404 card.addEventListener('transitionend', finishRemove, { once: true }); 2405 setTimeout(finishRemove, 400); 2406 } else { 2407 finishRemove(); 2408 } 2409 }); 2410 }); 2411 // Unarchive (optimistic) â only present in archive view 2412 body.querySelectorAll('.note-card-unarchive').forEach(btn => { 2413 btn.addEventListener('click', (e) => { 2414 e.stopPropagation(); 2415 const id = btn.dataset.noteId; 2416 const idx = _notes.findIndex(n => n.id === id); 2417 if (idx < 0) return; 2418 const removed = _notes.splice(idx, 1)[0]; 2419 _renderNotes(); 2420 _patchNote(id, { archived: false }).then(() => uiModule.showToast('Unarchived')).catch(() => { 2421 _notes.splice(idx, 0, removed); 2422 _renderNotes(); 2423 uiModule.showError('Failed to unarchive'); 2424 }); 2425 }); 2426 }); 2427 // Delete (optimistic) 2428 body.querySelectorAll('.note-card-delete, .note-card-x').forEach(btn => { 2429 btn.addEventListener('click', (e) => { 2430 e.stopPropagation(); 2431 const id = btn.dataset.noteId; 2432 const idx = _notes.findIndex(n => n.id === id); 2433 if (idx < 0) return; 2434 const removed = _notes.splice(idx, 1)[0]; 2435 _renderNotes(); 2436 _deleteNoteApi(id).catch(() => { 2437 _notes.splice(idx, 0, removed); 2438 _renderNotes(); 2439 uiModule.showError('Failed to delete'); 2440 }); 2441 }); 2442 }); 2443 // Copy entire checklist (title + items, markdown-style) 2444 body.querySelectorAll('.note-card-copy').forEach(btn => { 2445 btn.addEventListener('click', async (e) => { 2446 e.preventDefault(); 2447 e.stopPropagation(); 2448 e.stopImmediatePropagation(); 2449 const id = btn.dataset.noteId; 2450 const note = _notes.find(n => n.id === id); 2451 if (!note) return; 2452 const lines = []; 2453 if (note.title) lines.push(note.title); 2454 if (note.content) lines.push(note.content); 2455 if (lines.length) lines.push(''); 2456 for (const it of (note.items || [])) { 2457 if (!it || !(it.text || '').trim()) continue; 2458 lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); 2459 } 2460 const text = lines.join('\n').trim(); 2461 try { 2462 await navigator.clipboard.writeText(text); 2463 uiModule.showToast?.(`Copied ${(note.items || []).filter(i => (i?.text || '').trim()).length} items`); 2464 } catch { 2465 // Fallback for browsers blocking the async API 2466 const ta = document.createElement('textarea'); 2467 ta.value = text; 2468 ta.style.position = 'fixed'; ta.style.left = '-9999px'; 2469 document.body.appendChild(ta); 2470 ta.select(); 2471 try { document.execCommand('copy'); uiModule.showToast?.('Copied'); } 2472 catch { uiModule.showError?.('Copy failed'); } 2473 ta.remove(); 2474 } 2475 }); 2476 }); 2477 2478 // Remove a single checklist item (hover X) 2479 body.querySelectorAll('.note-checkbox-rm').forEach(btn => { 2480 btn.addEventListener('click', (e) => { 2481 e.stopPropagation(); 2482 if (_selectMode) return; 2483 const noteId = btn.dataset.noteId; 2484 const idx = parseInt(btn.dataset.idx); 2485 const note = _notes.find(n => n.id === noteId); 2486 if (!note || !Array.isArray(note.items) || !note.items[idx]) return; 2487 const removed = note.items[idx]; 2488 note.items = note.items.filter((_, i) => i !== idx); 2489 _renderNotes(); 2490 _patchNote(noteId, { items: note.items }).catch(() => { 2491 note.items.splice(idx, 0, removed); 2492 _renderNotes(); 2493 uiModule.showError('Failed to remove item'); 2494 }); 2495 }); 2496 }); 2497 2498 // Quick-add new checklist item (hover input at bottom of todo cards) 2499 body.querySelectorAll('.note-cl-quickadd-input').forEach(input => { 2500 input.addEventListener('click', (e) => e.stopPropagation()); 2501 input.addEventListener('keydown', async (e) => { 2502 e.stopPropagation(); 2503 if (e.key !== 'Enter') return; 2504 e.preventDefault(); 2505 const text = input.value.trim(); 2506 if (!text) return; 2507 const noteId = input.dataset.noteId; 2508 const note = _notes.find(n => n.id === noteId); 2509 if (!note) return; 2510 const items = Array.isArray(note.items) ? [...note.items] : []; 2511 items.push({ id: _uid(), text, done: false }); 2512 note.items = items; 2513 input.value = ''; 2514 _renderNotes(); 2515 // Refocus the input on the same card 2516 setTimeout(() => { 2517 const next = document.querySelector(`.note-cl-quickadd-input[data-note-id="${noteId}"]`); 2518 if (next) next.focus(); 2519 }, 0); 2520 _patchNote(noteId, { items }).catch(() => { 2521 note.items = items.slice(0, -1); 2522 _renderNotes(); 2523 uiModule.showError('Failed to add item'); 2524 }); 2525 }); 2526 }); 2527 2528 // Checkboxes (dot toggle, optimistic) â disabled in select mode 2529 body.querySelectorAll('.note-checkbox').forEach(el => { 2530 el.addEventListener('click', (e) => { 2531 if (_selectMode) return; // let card-level handler take over 2532 e.stopPropagation(); 2533 const noteId = el.dataset.noteId; 2534 const idx = parseInt(el.dataset.idx); 2535 const note = _notes.find(n => n.id === noteId); 2536 if (!note || !note.items || !note.items[idx]) return; 2537 const wasAllDone = note.items.length > 0 && note.items.every(it => it.done); 2538 note.items[idx].done = !note.items[idx].done; 2539 el.classList.toggle('done', note.items[idx].done); 2540 const isAllDone = note.items.length > 0 && note.items.every(it => it.done); 2541 if (!wasAllDone && isAllDone) { 2542 const card = el.closest('.note-card'); 2543 if (card) { 2544 const r = card.getBoundingClientRect(); 2545 spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); 2546 } 2547 } 2548 _patchNote(noteId, { items: note.items }).catch(() => { 2549 note.items[idx].done = !note.items[idx].done; 2550 el.classList.toggle('done', note.items[idx].done); 2551 }); 2552 }); 2553 }); 2554 2555 // Drag-reorder notes on pointer/mouse devices. Mobile uses the custom 2556 // placeholder sorter below `_bindLongPressDrag`; native HTML5 dragging is 2557 // unreliable on touch browsers and can compete with the long-press flow. 2558 if (!_isNotesMobileMode()) { 2559 body.querySelectorAll('.note-card').forEach(card => { 2560 card.addEventListener('dragstart', (e) => { 2561 if (e.target.closest('.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .
2561note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip')) { 2562 e.preventDefault(); 2563 return; 2564 } 2565 card.classList.add('dragging'); 2566 body.classList.add('drag-active'); 2567 e.dataTransfer.effectAllowed = 'move'; 2568 try { e.dataTransfer.setData('text/plain', card.dataset.noteId); } catch {} 2569 }); 2570 card.addEventListener('dragend', async () => { 2571 card.classList.remove('dragging'); 2572 body.classList.remove('drag-active'); 2573 body.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); 2574 const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); 2575 try { await fetch(`${API_BASE}/api/notes/reorder`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }); } 2576 catch {} 2577 }); 2578 }); 2579 } 2580 // Track which card we last swapped with so a single hover-over triggers 2581 // one swap, not a jitter as the pointer keeps moving inside the same card. 2582 let _lastSwapId = null; 2583 function _maybeSwap(dragging, clientX, clientY) { 2584 const target = document.elementFromPoint(clientX, clientY)?.closest('.note-card'); 2585 if (!target || target === dragging || !body.contains(target)) return; 2586 const id = target.dataset.noteId; 2587 if (id === _lastSwapId) return; 2588 // FLIP across ALL siblings. In list view only `target` moves visually, but 2589 // in grid view (2-col) and when the pinned-section grid-column-start rule 2590 // shifts, several cards reflow at once. Capture every card's pre-swap rect, 2591 // do the DOM swap, then animate any that actually moved. The dragging card 2592 // is excluded â it's already finger-tracked via translate3d. 2593 const cards = [...body.querySelectorAll('.note-card')].filter(c => c !== dragging); 2594 const prevRects = new Map(cards.map(c => [c, c.getBoundingClientRect()])); 2595 const draggingNext = dragging.nextSibling === target ? dragging : dragging.nextSibling; 2596 body.insertBefore(dragging, target); 2597 body.insertBefore(target, draggingNext); 2598 for (const c of cards) { 2599 const prev = prevRects.get(c); 2600 const next = c.getBoundingClientRect(); 2601 const dx = prev.left - next.left; 2602 const dy = prev.top - next.top; 2603 if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue; 2604 c.style.transition = 'none'; 2605 c.style.transform = `translate(${dx}px, ${dy}px)`; 2606 requestAnimationFrame(() => { 2607 c.style.transition = 'transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1)'; 2608 c.style.transform = ''; 2609 c.addEventListener('transitionend', () => { c.style.transition = ''; }, { once: true }); 2610 }); 2611 } 2612 _lastSwapId = id; 2613 } 2614 body.addEventListener('dragover', (e) => { 2615 e.preventDefault(); 2616 const dragging = body.querySelector('.note-card.dragging'); 2617 if (!dragging) return; 2618 _maybeSwap(dragging, e.clientX, e.clientY); 2619 }); 2620 body.addEventListener('dragend', () => { _lastSwapId = null; }); 2621 2622 // Legacy touch drag for larger touch devices only. Phone-sized Notes uses 2623 // the placeholder sorter wired by `_bindLongPressDrag`; running both flows 2624 // makes one press start two independent drag sessions. 2625 if (!_isNotesMobileMode() && 'ontouchstart' in window && !body.dataset.touchDragBound) { 2626 body.dataset.touchDragBound = '1'; 2627 let dragCard = null; 2628 let isDragging = false; 2629 let longPressTimer = null; 2630 let startX = 0, startY = 0; 2631 const LONG_PRESS_MS = 350; 2632 const MOVE_THRESHOLD_PX = 8; 2633 const _selectorSkip = '.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip, input, textarea, button, a'; 2634 2635 // Anchor for the finger-follow transform. Recomputed after every swap so 2636 // the card stays under the finger across reorderings. 2637 let anchorX = 0, anchorY = 0;
2638 const _follow = (clientX, clientY) => { 2639 if (!dragCard) return; 2640 const dx = clientX - anchorX; 2641 const dy = clientY - anchorY; 2642 // Compose with the CSS .dragging transform (scale + rotate). 2643 dragCard.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(1.03) rotate(-0.6deg)`; 2644 }; 2645 const _reanchor = (clientX, clientY) => { 2646 if (!dragCard) return; 2647 // Clear any prior translate so the card resettles in its natural slot, 2648 // then anchor at the finger's current position. Subsequent _follow calls 2649 // translate by (finger - anchor), keeping the finger at the same relative 2650 // spot on the card it had when we re-anchored. 2651 dragCard.style.transform = ''; 2652 anchorX = clientX; 2653 anchorY = clientY; 2654 }; 2655 2656 const _endDrag = (committed) => { 2657 clearTimeout(longPressTimer); 2658 longPressTimer = null; 2659 if (dragCard) { 2660 dragCard.classList.remove('dragging'); 2661 dragCard.style.transform = ''; 2662 } 2663 body.classList.remove('drag-active'); 2664 _lastSwapId = null; 2665 if (isDragging) { 2666 document.documentElement.style.touchAction = ''; 2667 if (committed) { 2668 const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); 2669 fetch(`${API_BASE}/api/notes/reorder`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }).catch(() => {}); 2670 } 2671 } 2672 dragCard = null; 2673 isDragging = false; 2674 }; 2675 2676 body.addEventListener('touchstart', (e) => { 2677 if (_selectMode) return; 2678 const card = e.target.closest('.note-card'); 2679 if (!card) return; 2680 if (e.target.closest(_selectorSkip)) return; 2681 const t = e.touches[0]; 2682 startX = t.clientX; startY = t.clientY; 2683 dragCard = card; 2684 longPressTimer = setTimeout(() => { 2685 if (!dragCard) return; 2686 isDragging = true; 2687 dragCard.classList.add('dragging'); 2688 body.classList.add('drag-active'); 2689 document.documentElement.style.touchAction = 'none'; 2690 _reanchor(startX, startY); 2691 try { if (navigator.vibrate) navigator.vibrate(15); } catch {} 2692 }, LONG_PRESS_MS); 2693 }, { passive: true }); 2694 2695 body.addEventListener('touchmove', (e) => { 2696 if (!dragCard) return; 2697 const t = e.touches[0]; 2698 if (!isDragging) { 2699 // Movement before long-press fires = user is scrolling; cancel pickup. 2700 if (Math.abs(t.clientX - startX) > MOVE_THRESHOLD_PX || Math.abs(t.clientY - startY) > MOVE_THRESHOLD_PX) { 2701 clearTimeout(longPressTimer); 2702 longPressTimer = null; 2703 dragCard = null; 2704 } 2705 return; 2706 } 2707 e.preventDefault(); 2708 // Live-follow the finger first, then check for a swap. After a swap the 2709 // card's natural slot moves, so we re-anchor and re-apply the offset. 2710 _follow(t.clientX, t.clientY); 2711 const before = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); 2712 _maybeSwap(dragCard, t.clientX, t.clientY); 2713 const after = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); 2714 if (before !== after) { 2715 _reanchor(t.clientX, t.clientY); 2716 _follow(t.clientX, t.clientY); 2717 } 2718 }, { passive: false }); 2719 2720 body.addEventListener('touchend', () => _endDrag(true)); 2721 body.addEventListener('touchcancel', () => _endDrag(false)); 2722 } 2723} 2724 2725// ââ Draft autosave ââââââââââââââââââââââââââââââââââââââââââââââââââ 2726// While a note is open in the editor, its form is snapshotted to 2727// localStorage on every change (debounced). If the connection drops, the 2728// tab closes, or the page reloads before Save is hit, reopening that note 2729// restores the unsaved text. Drafts are cleared on an explicit Save or 2730// Cancel. Survives offline because it never touches the network. 2731const _DRAFT_PREFIX = 'odysseus-note-draft-'; 2732function _draftKey(id) { return _DRAFT_PREFIX + (id || '__new__'); } 2733function _loadDraft(id) { 2734 try { return JSON.parse(localStorage.getItem(_draftKey(id)) || 'null'); } catch { return null; } 2735} 2736function _clearDraft(id) { try { localStorage.removeItem(_draftKey(id)); } catch {} } 2737function _collectFormDraft(form) { 2738 if (!form) return null; 2739 const type = form.querySelector('.note-form-type-pill.active')?.dataset.type || 'note';
2740 const d = { 2741 _ts: Date.now(), 2742 note_type: type, 2743 title: form.querySelector('.note-form-title')?.value || '', 2744 label: form.querySelector('.note-form-label')?.value || '', 2745 due_date: form.querySelector('.note-form-due')?.value || null, 2746 repeat: form.querySelector('.note-form-repeat')?.value || 'none', 2747 }; 2748 if (type === 'note') d.content = form.querySelector('.note-form-content')?.value || ''; 2749 else if (type === 'goal') { d.content = form.querySelector('.note-form-goal-desc')?.value || ''; d.items = _collectItems(form); } 2750 else d.items = _collectItems(form); 2751 return d; 2752} 2753function _isDraftEmpty(d) { 2754 if (!d) return true; 2755 if ((d.title || '').trim()) return false; 2756 if ((d.content || '').trim()) return false; 2757 if (Array.isArray(d.items) && d.items.some(it => (it.text || '').trim())) return false; 2758 return true; 2759} 2760function _wireDraftAutosave(form, id) { 2761 let t = null; 2762 const save = () => { 2763 const d = _collectFormDraft(form); 2764 if (_isDraftEmpty(d)) { _clearDraft(id); return; } 2765 try { localStorage.setItem(_draftKey(id), JSON.stringify(d)); } catch {} 2766 }; 2767 form._flushDraft = () => { clearTimeout(t); save(); }; 2768 const sched = () => { clearTimeout(t); t = setTimeout(save, 600); }; 2769 form.addEventListener('input', sched); 2770 form.addEventListener('change', sched); 2771} 2772 2773// Commit whatever in-place editor is open (called when the panel closes 2774// or another note is opened) so edits aren't lost when the user navigates 2775// away without clicking Save. Empty notes are discarded instead of saved. 2776function _commitOpenInPlaceEditor() { 2777 const form = document.querySelector('#notes-pane .note-form'); 2778 if (!form) return; 2779 const d = _collectFormDraft(form); 2780 if (_isDraftEmpty(d)) { form.querySelector('.note-form-cancel')?.click(); return; } 2781 form.querySelector('.note-form-save')?.click(); 2782} 2783// Merge a stored draft over a note so _buildForm renders the unsaved edits. 2784function _applyDraftToNote(note, id) { 2785 const d = _loadDraft(id); 2786 if (_isDraftEmpty(d)) return { note, restored: false }; 2787 const merged = { ...(note || {}) }; 2788 ['note_type', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => { 2789 if (d[k] !== undefined) merged[k] = d[k]; 2790 }); 2791 return { note: merged, restored: true }; 2792} 2793 2794// ---- Create / Edit Form ---- 2795 2796function _buildForm(note = null) { 2797 const isEdit = note && note.id; 2798 const type = note?.note_type || 'note'; 2799 const color = note?.color || ''; 2800 const items = note?.items || [{ id: _uid(), text: '', done: false }]; 2801 2802 const form = document.createElement('div'); 2803 form.className = 'note-form'; 2804 if (color && !_isBgImage(color)) form.classList.add('note-color-' + color); 2805 if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color)); 2806 let currentImageUrl = _safeImgSrc(note?.image_url || ''); 2807 form.innerHTML = ` 2808 <div class="note-form-header"> 2809 <input type="text" class="note-form-title" placeholder="Title" value="${_esc(note?.title || '')}" /> 2810 <button type="button" class="note-form-icon-btn note-form-remind-btn${note?.due_date ? ' has-date' : ''}" title="Remind me"> 2811 <svg width="31" height="31" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> 2812 </button> 2813 <input type="hidden" class="note-form-due" value="${note?.due_date || ''}" /> 2814 <input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" /> 2815 </div> 2816 ${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button></div>` : ''} 2817 <div class="note-form-body"> 2818 ${type === 'note' 2819 ? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>` 2820 : type === 'draw' 2821 ? _buildDrawHtml() 2822 : type === 'goal' 2823 ? _buildGoalHtml(note, items) 2824 : _buildChecklistHtml(items)} 2825 </div> 2826 <div class="note-form-reminder-tags"></div> 2827 <div class="note-form-meta"> 2828 <div class="note-form-type-seg${type === 'todo' ? ' is-todo' : type === 'draw' ? ' is-draw' : ''}" role="group"> 2829 <button type="button" class="note-form-type-pill${type === 'note' ? ' active' : ''}" data-type="note"> 2830 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
2831 <span>Note</span> 2832 </button> 2833 <button type="button" class="note-form-type-pill${type === 'todo' ? ' active' : ''}" data-type="todo"> 2834 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg> 2835 <span>Todo</span> 2836 </button> 2837 <button type="button" class="note-form-type-pill${type === 'draw' ? ' active' : ''}" data-type="draw"> 2838 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg> 2839 <span>Draw</span> 2840 </button> 2841 </div> 2842 <button class="note-form-photo-btn" title="Attach photo"> 2843 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 2844 </button> 2845 <input type="file" class="note-form-photo-input" accept="image/*" capture="environment" style="display:none" /> 2846 <div class="note-color-picker"> 2847 ${COLORS.map(c => `<span class="note-color-dot${_dotIsActive(c.value, color) ? ' active' : ''}" data-color="${c.value}" style="background:${_dotBg(c.value, color)}" title="${c.name || 'default'}"></span>`).join('')} 2848 </div> 2849 <input type="text" class="note-form-label" value="${_esc(note?.label || '')}" placeholder="#tag1 #tag2" title="Tag(s) â space-separated" /> 2850 <div class="note-form-actions-group"> 2851 ${isEdit ? ` 2852 <button type="button" class="note-form-text-btn note-form-archive-btn note-form-collapsible" title="Archive"> 2853 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg><span class="nft-label">Archive</span> 2854 </button> 2855 <button type="button" class="note-form-text-btn note-form-delete-btn note-form-collapsible danger" title="Delete"> 2856 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg><span class="nft-label">Delete</span> 2857 </button> 2858 ` : ''} 2859 <span class="note-form-actions-spacer"></span> 2860 <button class="note-form-cancel note-form-text-btn note-form-collapsible" title="Cancel"> 2861 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg><span class="nft-label">Cancel</span> 2862 </button> 2863 <button class="note-form-save note-form-text-btn" title="${isEdit ? 'Update' : 'Save'}"> 2864 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><span class="nft-label">${isEdit ? 'Update' : 'Save'}</span> 2865 </button> 2866 </div> 2867 </div> 2868 `; 2869 2870 let currentType = type; 2871 let currentColor = color; 2872 // Stash original-form values so round-trips (NoteâTodoâNote) restore the 2873 // user's hand-formatted text instead of a join of generated items. Same the 2874 // other way: if you started in todo, switch to note, switch back, items 2875 // come back unchanged. 2876 let _stashedNoteText = (type === 'note') ? (note?.content || '') : null; 2877 let _stashedTodoItems = (type === 'todo' && Array.isArray(note?.items)) ? note.items.slice() : null; 2878 // Goal mode kept its own pair of stashes (description + steps) so a 2879 // TodoâGoalâTodo round-trip wouldn't lose either side. The Goal pill in 2880 // the type picker was later removed, so the only entry point now is 2881 // *editing* an existing goal-typed note â but the switch handler still 2882 // accepts GoalâTodo/Note transitions (downgrading legacy goals), so 2883 // these stashes still earn their keep.
2884 let _stashedGoalDesc = (type === 'goal') ? (note?.content || '') : null; 2885 let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null; 2886 2887 // Drawing also stashes the saved image URL so it survives NoteâDraw flips. 2888 let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null; 2889 const _refreshFormLayout = () => { 2890 const body = form.closest('.notes-pane-body'); 2891 if (!body) return; 2892 _applyMasonry(body); 2893 requestAnimationFrame(() => { 2894 _applyMasonry(body); 2895 requestAnimationFrame(() => _applyMasonry(body)); 2896 }); 2897 }; 2898 2899 // Type segmented control â Note | Todo | Draw 2900 form.querySelectorAll('.note-form-type-pill').forEach(pill => { 2901 pill.addEventListener('click', () => { 2902 const newType = pill.dataset.type; 2903 if (newType === currentType) return; 2904 const bodyEl = form.querySelector('.note-form-body'); 2905 // Stash whatever the user has in the current mode before swapping it 2906 // out, so a subsequent flip back restores their work. 2907 if (currentType === 'note') { 2908 _stashedNoteText = form.querySelector('.note-form-content')?.value || ''; 2909 } else if (currentType === 'todo') { 2910 _stashedTodoItems = _collectItems(form); 2911 } else if (currentType === 'goal') { 2912 _stashedGoalDesc = form.querySelector('.note-form-goal-desc')?.value || ''; 2913 _stashedGoalItems = _collectItems(form); 2914 } else if (currentType === 'draw') { 2915 const c = form.querySelector('.note-form-canvas'); 2916 if (c) { try { _stashedDrawUrl = c.toDataURL('image/png'); } catch {} } 2917 } 2918 // Render the new mode's body and re-wire its inputs. 2919 if (newType === 'todo') { 2920 let nextItems; 2921 if (_stashedTodoItems && _stashedTodoItems.length) { 2922 nextItems = _stashedTodoItems; 2923 } else if (_stashedGoalItems && _stashedGoalItems.length) { 2924 // Going GoalâTodo keeps the AI-generated steps as a plain checklist. 2925 nextItems = _stashedGoalItems; 2926 } else if (_stashedNoteText) { 2927 const lines = _stashedNoteText.split('\n').map(s => s.trim()).filter(Boolean); 2928 nextItems = lines.length ? lines.map(t => ({ id: _uid(), text: t, done: false })) : [{ id: _uid(), text: '', done: false }]; 2929 } else { 2930 nextItems = [{ id: _uid(), text: '', done: false }]; 2931 } 2932 bodyEl.innerHTML = _buildChecklistHtml(nextItems); 2933 _wireChecklist(bodyEl); 2934 } else if (newType === 'draw') { 2935 bodyEl.innerHTML = _buildDrawHtml(); 2936 // If the user just attached a photo (via the photo button) and then 2937 // toggled to Draw, paint that photo onto the canvas so they can draw 2938 // on top of it. _stashedDrawUrl wins if they were drawing earlier in 2939 // the same edit session. 2940 _wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null); 2941 } else { 2942 const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '') 2943 ? _stashedNoteText 2944 : (_stashedGoalDesc && _stashedGoalDesc) 2945 || (_stashedTodoItems || _stashedGoalItems || []).map(i => i.text).join('\n'); 2946 bodyEl.innerHTML = `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(text)}</textarea>`; 2947 _wireHashtag(bodyEl.querySelector('.note-form-content')); 2948 } 2949 const focusEl = newType === 'note' 2950 ? bodyEl.querySelector('.note-form-content') 2951 : newType === 'todo' 2952 ? bodyEl.querySelector('.note-cl-text') 2953 : null; 2954 if (focusEl) { 2955 requestAnimationFrame(() => { 2956 focusEl.focus({ preventScroll: true }); 2957 try { 2958 const end = focusEl.value.length; 2959 focusEl.setSelectionRange(end, end); 2960 } catch {} 2961 }); 2962 } 2963 currentType = newType; 2964 const seg = form.querySelector('.note-form-type-seg'); 2965 seg?.classList.toggle('is-todo', newType === 'todo'); 2966 seg?.classList.toggle('is-draw', newType === 'draw'); 2967 form.querySelectorAll('.note-form-type-pill').forEach(p => p.classList.toggle('active', p.dataset.type === newType)); 2968 // The standalone image preview (form-image-wrap) and the canvas would
2969 // otherwise both show the same image_url when editing a drawn note. 2970 // Hide it in draw mode, restore it when leaving draw mode. 2971 const imgWrap = form.querySelector('.note-form-image-wrap'); 2972 if (imgWrap) imgWrap.style.display = (newType === 'draw') ? 'none' : ''; 2973 // The background-color dots set the note card's bg â they make no sense 2974 // for a drawn note (the canvas image IS the card content), so hide them. 2975 const bgPicker = form.querySelector('.note-color-picker'); 2976 if (bgPicker) bgPicker.style.display = (newType === 'draw') ? 'none' : ''; 2977 if (form.closest('.notes-pane.notes-view-grid') && window.matchMedia('(max-width: 768px)').matches) { 2978 form.style.gridColumn = '1 / -1'; 2979 form.style.gridRowEnd = newType === 'draw' ? 'span 152' : 'span 64'; 2980 } 2981 _refreshFormLayout(); 2982 }); 2983 }); 2984 2985 // Slide a finger across the Note/Todo/Draw control to switch modes (mobile). 2986 // On touchmove we find the pill under the finger and click it â reusing the 2987 // pill click handler above, so the body re-renders + content stashing all 2988 // work. Only fires when crossing into a *different* pill. 2989 const _typeSeg = form.querySelector('.note-form-type-seg'); 2990 if (_typeSeg) { 2991 let _sliding = false; 2992 const _activateAt = (x, y) => { 2993 const pill = document.elementFromPoint(x, y)?.closest?.('.note-form-type-pill'); 2994 if (pill && !pill.classList.contains('active')) pill.click(); 2995 }; 2996 _typeSeg.addEventListener('touchstart', () => { _sliding = true; }, { passive: true }); 2997 _typeSeg.addEventListener('touchmove', (e) => { 2998 if (_sliding && e.touches[0]) _activateAt(e.touches[0].clientX, e.touches[0].clientY); 2999 }, { passive: true }); 3000 _typeSeg.addEventListener('touchend', () => { _sliding = false; }); 3001 _typeSeg.addEventListener('touchcancel', () => { _sliding = false; }); 3002 } 3003 3004 // Color dots â apply to entire form immediately 3005 const _applyFormColor = (newColor) => { 3006 currentColor = newColor || ''; 3007 const isBg = _isBgImage(currentColor); 3008 COLORS.forEach(c => { if (c.value && c.value !== 'custom') form.classList.remove('note-color-' + c.value); }); 3009 if (currentColor && !isBg) form.classList.add('note-color-' + currentColor); 3010 if (isBg) form.setAttribute('style', _customColorStyle(currentColor)); 3011 else form.removeAttribute('style'); 3012 form.querySelectorAll('.note-color-dot').forEach(d => { 3013 d.classList.toggle('active', _dotIsActive(d.dataset.color, currentColor)); 3014 d.style.background = _dotBg(d.dataset.color, currentColor); 3015 }); 3016 }; 3017 form.querySelectorAll('.note-color-dot').forEach(dot => { 3018 dot.addEventListener('click', () => { 3019 if (dot.dataset.color === 'custom') { 3020 _pickCustomBgImage().then(url => { if (url) _applyFormColor('bg:' + url); }); 3021 return; 3022 } 3023 _applyFormColor(dot.dataset.color); 3024 }); 3025 }); 3026 3027 if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body')); 3028 if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body')); 3029 if (currentType === 'draw') { 3030 _wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null); 3031 // Same hides we apply on type-switch â keep them consistent on initial open. 3032 const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none'; 3033 const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none'; 3034 } 3035 3036 // Auto-grow the plain-note textarea so editing longer notes is 3037 // comfortable â it expands with the content (up to a cap) instead of 3038 // staying a cramped 4-row box. The user can still drag-resize too. 3039 const _contentTa = form.querySelector('.note-form-content'); 3040 if (_contentTa) { 3041 const _grow = () => { 3042 _contentTa.style.height = 'auto'; 3043 // Inline form: cap at ~50vh so a huge note doesn't push the action 3044 // buttons off-screen. Fullscreen mobile overlay: the body scrolls and 3045 // there are no inline buttons crowding, so allow nearly the full height 3046 // â capping at 50vh there clipped longer notes ("part disappears"). 3047 const inFullscreen = !!_contentTa.closest('.note-fullscreen-overlay'); 3048 const max = Math.round(window.innerHeight * (inFullscreen ? 0.9 : 0.5)); 3049 _contentTa.style.height = Math.min(_contentTa.scrollHeight, max) + 'px'; 3050 }; 3051 _contentTa.addEventListener('input', _grow); 3052 // Grow on open so existing content is fully visible. Run again after the 3053 // fullscreen overlay's open animation settles â measuring mid-animation 3054 // (the overlay starts scaled/transitioning) can under-size the box. 3055 setTimeout(_grow, 0); 3056 setTimeout(_grow, 360); 3057 } 3058 3059 // Reminder bell â opens dropdown menu 3060 const remindBtn = form.querySelector('.note-form-remind-btn'); 3061 const dueInput = form.querySelector('.note-form-due'); 3062 const repeatInput = form.querySelector('.note-form-repeat'); 3063 const tagsEl = form.querySelector('.note-form-reminder-tags'); 3064 3065 function _renderReminderTag() { 3066 if (!tagsEl) return; 3067 const v = dueInput.value; 3068 const rep = repeatInput.value || 'none'; 3069 if (!v) { tagsEl.innerHTML = ''; return; } 3070 const label = _formatReminderTag(v); 3071 const repLabel = rep !== 'none' ? ` · ${_formatRepeatLabel(rep, new Date(v))}` : ''; 3072 tagsEl.innerHTML = `<button class="note-reminder-tag" type="button" title="Edit reminder">
3073 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> 3074 <span>${_esc(label)}${_esc(repLabel)}</span> 3075 <span class="note-reminder-tag-x" title="Remove">Ã</span> 3076 </button>`; 3077 tagsEl.querySelector('.note-reminder-tag').addEventListener('click', (e) => { 3078 if (e.target.classList.contains('note-reminder-tag-x')) { 3079 dueInput.value = ''; 3080 repeatInput.value = 'none'; 3081 _renderReminderTag(); 3082 return; 3083 } 3084 _openReminderMenu(remindBtn || tagsEl, true); 3085 }); 3086 } 3087 3088 function _openReminderMenu(anchor, isEdit = false) { 3089 // Close any existing menu 3090 document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove()); 3091 const menu = document.createElement('div'); 3092 menu.className = 'note-reminder-menu'; 3093 document.body.appendChild(menu); 3094 3095 const presetItems = [ 3096 { label: 'Later today', sub: _laterTodayDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_laterTodayDate())) }, 3097 { label: 'Tomorrow', sub: _tomorrowDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_tomorrowDate())) }, 3098 { label: 'Next week', sub: _nextWeekDate().toLocaleDateString([], { weekday: 'short' }) + ' ' + _nextWeekDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_nextWeekDate())) }, 3099 { label: 'Select date and time', sub: '', action: () => _pickCustomDate() }, 3100 ]; 3101 3102 // Sub-page state for the repeat picker. null = top page. 3103 // 'weekly' | 'monthly' | 'monthly_nth' 3104 let subMode = null; 3105 // Temporary state for monthly_nth so user can click N then weekday (or vice versa) 3106 // before committing. 3107 let nthDraft = { n: 0, w: -1 }; 3108 3109 const DAY_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; 3110 3111 function getNorm() { 3112 if (!dueInput.value) return 'none'; 3113 return _normalizeRepeat(repeatInput.value || 'none', new Date(dueInput.value)); 3114 } 3115 3116 function commit(val) { 3117 repeatInput.value = val; 3118 _renderReminderTag(); 3119 menu.remove(); 3120 } 3121 3122 // Like commit, but first snaps dueInput.value forward to the next matching 3123 // slot for the chosen recurrence. Use for weekly/monthly variants where the 3124 // current due date may not match the chosen pattern (e.g. user picks 3125 // "weekly on Mondays" while the date is a Wednesday). 3126 function snapAndCommit(val) { 3127 if (dueInput.value) { 3128 const cur = new Date(dueInput.value); 3129 const norm = _normalizeRepeat(val, cur); 3130 const snapped = _snapToRepeat(cur, norm); 3131 if (snapped) { 3132 dueInput.value = _toLocalDatetimeStr(snapped); 3133 if (remindBtn) remindBtn.classList.add('has-date'); 3134 } 3135 } 3136 commit(val); 3137 } 3138 3139 function reposition() { 3140 const rect = anchor.getBoundingClientRect(); 3141 const vw = window.innerWidth; 3142 const vh = window.innerHeight; 3143 const mw = menu.offsetWidth || 220; 3144 const mh = menu.offsetHeight || 280; 3145 let top = rect.bottom + 4; 3146 let left = rect.left; 3147 if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); 3148 if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); 3149 if (left < 8) left = 8; 3150 menu.style.top = top + 'px'; 3151 menu.style.left = left + 'px'; 3152 } 3153 3154 function render() { 3155 let html = ''; 3156 3157 if (subMode === null) { 3158 html += '<div class="note-reminder-menu-title">Remind me later</div>'; 3159 for (let i = 0; i < presetItems.length; i++) { 3160 const it = presetItems[i]; 3161 html += `<button class="note-reminder-menu-item" data-action="preset" data-i="${i}"><span>${it.label}</span><span class="note-reminder-menu-sub">${it.sub}</span></button>`; 3162 } 3163 if (isEdit && dueInput.value) { 3164 const norm = getNorm(); 3165 html += '<div class="note-reminder-menu-divider"></div>'; 3166 html += '<div class="note-reminder-menu-title">Repeat</div>'; 3167 // None 3168 html += `<button class="note-reminder-menu-item${norm === 'none' ? ' active' : ''}" data-action="set" data-val="none"><span>Doesn't repeat</span>${norm === 'none' ? '<span class="note-reminder-menu-check">â</span>' : ''}</button>`; 3169 // Daily 3170 html += `<button class="note-reminder-menu-item${norm === 'daily' ? ' active' : ''}" data-action="set" data-val="daily"><span>Daily</span>${norm === 'daily' ? '<span class="note-reminder-menu-check">â</span>' : ''}</button>`; 3171 // Weekly â 3172 { 3173 const isW = norm.startsWith('weekly:'); 3174 const wd = isW ? parseInt(norm.split(':')[1], 10) : null;
3175 const sub = isW && !isNaN(wd) ? `<span class="note-reminder-menu-sub">${_DAYS[wd]}</span>` : ''; 3176 html += `<button class="note-reminder-menu-item${isW ? ' active' : ''}" data-action="sub" data-sub="weekly"><span>Weekly</span>${sub}<span class="note-reminder-menu-arrow">âº</span></button>`; 3177 } 3178 // Monthly â 3179 { 3180 const isM = norm.startsWith('monthly:'); 3181 const sub = isM ? `<span class="note-reminder-menu-sub">${_monthlyShortDescriptor(norm)}</span>` : ''; 3182 html += `<button class="note-reminder-menu-item${isM ? ' active' : ''}" data-action="sub" data-sub="monthly"><span>Monthly</span>${sub}<span class="note-reminder-menu-arrow">âº</span></button>`; 3183 } 3184 // Yearly 3185 html += `<button class="note-reminder-menu-item${norm === 'yearly' ? ' active' : ''}" data-action="set" data-val="yearly"><span>Yearly</span>${norm === 'yearly' ? '<span class="note-reminder-menu-check">â</span>' : ''}</button>`; 3186 } 3187 } else if (subMode === 'weekly') { 3188 const norm = getNorm(); 3189 const curWd = norm.startsWith('weekly:') ? parseInt(norm.split(':')[1], 10) : -1; 3190 html += `<button class="note-reminder-menu-back" data-action="back"><span class="note-reminder-menu-arrow-back">â¹</span> Repeat</button>`; 3191 html += '<div class="note-reminder-menu-title">Weekly onâ¦</div>'; 3192 html += '<div class="note-reminder-weekday-row">'; 3193 for (let i = 0; i < 7; i++) { 3194 html += `<button class="note-reminder-day-chip${curWd === i ? ' active' : ''}" data-action="weekly-pick" data-wd="${i}" title="${_DAYS[i]}">${DAY_SHORT[i]}</button>`; 3195 } 3196 html += '</div>'; 3197 } else if (subMode === 'monthly') { 3198 const norm = getNorm(); 3199 const dueDate = new Date(dueInput.value); 3200 const dayN = dueDate.getDate(); 3201 html += `<button class="note-reminder-menu-back" data-action="back"><span class="note-reminder-menu-arrow-back">â¹</span> Repeat</button>`; 3202 html += '<div class="note-reminder-menu-title">Monthly onâ¦</div>'; 3203 // Day N â uses the chosen date's day. Always offered. 3204 const dayVal = `monthly:day:${dayN}`; 3205 html += `<button class="note-reminder-menu-item${norm === dayVal ? ' active' : ''}" data-action="set" data-val="${dayVal}"><span>Day ${dayN} every month</span>${norm === dayVal ? '<span class="note-reminder-menu-check">â</span>' : ''}</button>`; 3206 // Nth weekday â 3207 { 3208 const isNth = norm.startsWith('monthly:nth:'); 3209 const sub = isNth ? `<span class="note-reminder-menu-sub">${_monthlyShortDescriptor(norm)}</span>` : ''; 3210 html += `<button class="note-reminder-menu-item${isNth ? ' active' : ''}" data-action="sub" data-sub="monthly_nth"><span>Nth weekday</span>${sub}<span class="note-reminder-menu-arrow">âº</span></button>`; 3211 } 3212 } else if (subMode === 'monthly_nth') { 3213 // Pick ordinal (1..4) and weekday (0..6); commit when both chosen. 3214 html += `<button class="note-reminder-menu-back" data-action="back-monthly"><span class="note-reminder-menu-arrow-back">â¹</span> Monthly</button>`; 3215 html += '<div class="note-reminder-menu-title">Nth weekday of month</div>'; 3216 html += '<div class="note-reminder-menu-sublabel">Which one</div>'; 3217 html += '<div class="note-reminder-weekday-row">'; 3218 for (let i = 1; i <= 4; i++) { 3219 html += `<button class="note-reminder-day-chip wide${nthDraft.n === i ? ' active' : ''}" data-action="nth-n" data-n="${i}">${_ORDINALS[i - 1]}</button>`; 3220 } 3221 html += '</div>'; 3222 html += '<div class="note-reminder-menu-sublabel">Weekday</div>'; 3223 html += '<div class="note-reminder-weekday-row">'; 3224 for (let i = 0; i < 7; i++) { 3225 html += `<button class="note-reminder-day-chip${nthDraft.w === i ? ' active' : ''}" data-action="nth-w" data-wd="${i}" title="${_DAYS[i]}">${DAY_SHORT[i]}</button>`; 3226 } 3227 html += '</div>'; 3228 html += '<div class="note-reminder-menu-divider"></div>'; 3229 const ready = nthDraft.n > 0 && nthDraft.w >= 0; 3230 const lbl = ready ? `Save: ${_ORDINALS[nthDraft.n - 1]} ${_DAYS[nthDraft.w]}` : 'Pick week and weekday'; 3231 html += `<button class="note-reminder-menu-item note-reminder-menu-confirm${ready ? '' : ' disabled'}" data-action="nth-save" ${ready ? '' : 'disabled'}><span>${lbl}</span></button>`; 3232 } 3233 3234 menu.innerHTML = html; 3235 reposition(); 3236 wire(); 3237 } 3238 3239 function wire() { 3240 menu.querySelectorAll('[data-action]').forEach(el => { 3241 el.addEventListener('click', (e) => { 3242 e.stopPropagation(); 3243 const a = el.dataset.action; 3244 if (a === 'preset') { 3245 const it = presetItems[parseInt(el.dataset.i, 10)]; 3246 it.action(); 3247 menu.remove(); 3248 } else if (a === 'set') { 3249 snapAndCommit(el.dataset.val); 3250 } else if (a === 'sub') { 3251 subMode = el.dataset.sub; 3252 // Seed nth draft from saved value only on first entry â preserve 3253 // in-progress picks across back-trips (Nth â back â Nth again). 3254 if (subMode === 'monthly_nth' && nthDraft.n === 0 && nthDraft.w === -1) { 3255 const norm = getNorm(); 3256 const m = norm.match(/^monthly:nth:(\d):(\d)$/); 3257 if (m) nthDraft = { n: parseInt(m[1], 10), w: parseInt(m[2], 10) }; 3258 } 3259 render(); 3260 } else if (a === 'back') { 3261 subMode = null; 3262 render(); 3263 } else if (a === 'back-monthly') { 3264 subMode = 'monthly'; 3265 render(); 3266 } else if (a === 'weekly-pick') { 3267 snapAndCommit(`weekly:${el.dataset.wd}`); 3268 } else if (a === 'nth-n') { 3269 nthDraft.n = parseInt(el.dataset.n, 10); 3270 render(); 3271 } else if (a === 'nth-w') { 3272 nthDraft.w = parseInt(el.dataset.wd, 10); 3273 render(); 3274 } else if (a === 'nth-save') { 3275 if (nthDraft.n > 0 && nthDraft.w >= 0) { 3276 snapAndCommit(`monthly:nth:${nthDraft.n}:${nthDraft.w}`); 3277 } 3278 } 3279 }); 3280 }); 3281 } 3282 3283 render(); 3284 // Click outside to close (single global handler attached after first paint) 3285 setTimeout(() => { 3286 const close = (e) => { 3287 if (!menu.isConnected) { document.removeEventListener('click', close); return; } 3288 if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } 3289 }; 3290 document.addEventListener('click', close); 3291 }, 0); 3292 } 3293 3294 function _monthlyShortDescriptor(norm) { 3295 const parts = norm.split(':'); 3296 if (parts[1] === 'day') return `Day ${parts[2]}`; 3297 if (parts[1] === 'nth') { 3298 const n = parseInt(parts[2], 10); 3299 const wd = parseInt(parts[3], 10); 3300 return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd].slice(0, 3)}`; 3301 } 3302 if (parts[1] === 'last') { 3303 const wd = parseInt(parts[2], 10); 3304 return `Last ${_DAYS[wd].slice(0, 3)}`; 3305 } 3306 return ''; 3307 } 3308 3309 function _setReminder(datetimeLocalStr) { 3310 dueInput.value = datetimeLocalStr; 3311 if (remindBtn) { 3312 remindBtn.classList.add('has-date'); 3313 // Jingle the bell. CSS handles the animation; remove + reflow + re-add 3314 // so it replays every time the user sets/changes a reminder. 3315 const _bell = remindBtn.querySelector('svg'); 3316 if (_bell) { 3317 _bell.classList.remove('jingling'); 3318 void _bell.offsetWidth; 3319 _bell.classList.add('jingling'); 3320 setTimeout(() => _bell.classList.remove('jingling'), 700); 3321 } 3322 } 3323 _renderReminderTag(); 3324 _ensureNotificationPermission(); 3325 } 3326 3327 function _pickCustomDate() { 3328 // Replace the dropdown menu with a small inline picker 3329 document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove()); 3330 const menu = document.createElement('div'); 3331 menu.className = 'note-reminder-menu'; 3332 const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate()); 3333 menu.innerHTML = ` 3334 <div class="note-reminder-menu-title">Pick date and time</div> 3335 <div class="note-reminder-menu-picker"> 3336 <input type="datetime-local" class="note-reminder-date-input" value="${initial}" /> 3337 </div> 3338 <div class="note-reminder-menu-divider"></div> 3339 <button class="note-reminder-menu-item note-reminder-menu-confirm">
3340 <span>Save</span> 3341 </button> 3342 `; 3343 document.body.appendChild(menu); 3344 // Position next to the bell button 3345 const anchor = remindBtn || form.querySelector('.note-form-reminder-tags'); 3346 const rect = anchor.getBoundingClientRect(); 3347 const vw = window.innerWidth; 3348 const vh = window.innerHeight; 3349 const mw = menu.offsetWidth || 240; 3350 const mh = menu.offsetHeight || 200; 3351 let top = rect.bottom + 4; 3352 let left = rect.left; 3353 if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); 3354 if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); 3355 if (left < 8) left = 8; 3356 menu.style.top = top + 'px'; 3357 menu.style.left = left + 'px'; 3358 const dInput = menu.querySelector('.note-reminder-date-input'); 3359 dInput.focus(); 3360 if (typeof dInput.showPicker === 'function') { 3361 try { dInput.showPicker(); } catch {} 3362 } 3363 menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => { 3364 if (dInput.value) _setReminder(dInput.value); 3365 menu.remove(); 3366 }); 3367 setTimeout(() => { 3368 const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } }; 3369 document.addEventListener('click', close); 3370 }, 0); 3371 } 3372 3373 if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); }); 3374 _renderReminderTag(); 3375 3376 // Photo upload 3377 const photoBtn = form.querySelector('.note-form-photo-btn'); 3378 const photoInput = form.querySelector('.note-form-photo-input'); 3379 if (photoBtn && photoInput) { 3380 photoBtn.addEventListener('click', () => photoInput.click()); 3381 photoInput.addEventListener('change', async () => { 3382 const file = photoInput.files?.[0]; 3383 if (!file) return; 3384 const fd = new FormData(); 3385 fd.append('files', file);
3386 try { 3387 const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' }); 3388 const data = await res.json(); 3389 const fileId = data.files?.[0]?.id; 3390 if (!fileId) throw new Error('Upload failed'); 3391 currentImageUrl = `${API_BASE}/api/upload/${fileId}`; 3392 // Only ever keep the latest attached photo â drop any existing wrap 3393 // before inserting a fresh one. Picking a second photo replaces the 3394 // first instead of stacking. 3395 form.querySelector('.note-form-image-wrap')?.remove(); 3396 const wrap = document.createElement('div'); 3397 wrap.className = 'note-form-image-wrap'; 3398 wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button>`; 3399 // Insert AFTER the whole header (a flex-row), not after the 3400 // title input itself â otherwise the image lands as a sibling 3401 // of the title inside the header and flex puts them side-by-side. 3402 form.querySelector('.note-form-header').after(wrap); 3403 wrap.querySelector('.note-form-image-rm').addEventListener('click', () => { wrap.remove(); currentImageUrl = ''; }); 3404 wrap.querySelector('img').src = currentImageUrl; 3405 } catch (err) { uiModule.showError('Image upload failed'); } 3406 photoInput.value = ''; 3407 }); 3408 } 3409 // Existing image remove 3410 form.querySelector('.note-form-image-rm')?.addEventListener('click', () => { 3411 form.querySelector('.note-form-image-wrap')?.remove(); 3412 currentImageUrl = ''; 3413 }); 3414 3415 // Title Enter -> focus body (textarea or first checklist item) 3416 form.querySelector('.note-form-title').addEventListener('keydown', (e) => { 3417 if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { 3418 e.preventDefault(); 3419 const ta = form.querySelector('.note-form-content'); 3420 if (ta) { ta.focus(); return; } 3421 const firstItem = form.querySelector('.note-cl-text'); 3422 if (firstItem) firstItem.focus(); 3423 } 3424 }); 3425 3426 // Hashtag â label: typing "#foo " in title/content appends "foo" to the 3427 // space-separated tag list. Repeats are deduplicated, so #foo #foo only 3428 // keeps one. Tags already present in the label field are left alone. 3429 const labelInput = form.querySelector('.note-form-label'); 3430 const _hashtagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; 3431 function _wireHashtag(el) { 3432 if (!el || !labelInput) return; 3433 el.addEventListener('input', () => { 3434 const m = _hashtagRe.exec(el.value); 3435 if (!m) return; 3436 const tag = m[2]; 3437 // Dedup against the stripped form â labelInput may already hold `#tag` 3438 // (after Enter normalised), so includes(tag) on the raw split would 3439 // miss the duplicate and append a bare `tag` next to `#tag`. 3440 const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); 3441 const stripped = existing.map(t => t.replace(/^#+/, '')); 3442 if (!stripped.includes(tag)) { 3443 existing.push('#' + tag); 3444 labelInput.value = existing.join(' '); 3445 labelInput.classList.add('flash-once'); 3446 setTimeout(() => labelInput.classList.remove('flash-once'), 600); 3447 } 3448 const cut = el.value.length - m[0].length + m[1].length; 3449 el.value = el.value.slice(0, cut); 3450 }); 3451 } 3452 _wireHashtag(form.querySelector('.note-form-title')); 3453 _wireHashtag(form.querySelector('.note-form-content')); 3454 // Pressing Enter in the tag field commits the current word as its own tag 3455 // and parks the cursor after a trailing space, so the next word becomes a 3456 // separate tag rather than overwriting the previous one. 3457 labelInput?.addEventListener('keydown', (e) => { 3458 if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey) return; 3459 e.preventDefault(); 3460 e.stopPropagation(); 3461 // Strip any leading #s the user typed, dedupe, then re-prepend exactly 3462 // one #. So typing "foo" or "#foo" both end up as "#foo " in the input; 3463 // the save handler keeps stripping #s before storing so DB stays clean. 3464 const tags = [...new Set(labelInput.value.split(/\s+/).map(t =>
3464 t.replace(/^#+/, '').trim()).filter(Boolean))]; 3465 if (!tags.length) return; 3466 labelInput.value = tags.map(t => '#' + t).join(' ') + ' '; 3467 labelInput.setSelectionRange(labelInput.value.length, labelInput.value.length); 3468 labelInput.classList.add('flash-once'); 3469 setTimeout(() => labelInput.classList.remove('flash-once'), 600); 3470 }); 3471 3472 // Shift+Enter (or Cmd/Ctrl+Enter) anywhere in the form -> save 3473 // Escape -> cancel edit 3474 form.addEventListener('keydown', (e) => { 3475 if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey)) { 3476 e.preventDefault(); 3477 form.querySelector('.note-form-save')?.click(); 3478 } else if (e.key === 'Escape') { 3479 e.preventDefault(); 3480 e.stopPropagation(); 3481 form.querySelector('.note-form-cancel')?.click(); 3482 } 3483 }); 3484 3485 // Save. Prevent the button from stealing focus on press: on mobile, the 3486 // first tap would otherwise just blur the focused textarea/input (closing 3487 // the keyboard and shifting layout), so the tap never reached the button and 3488 // you had to tap "Done" twice. mousedown preventDefault keeps focus put while 3489 // still letting the click fire. 3490 const _saveBtnEl0 = form.querySelector('.note-form-save'); 3491 _saveBtnEl0.addEventListener('mousedown', (e) => e.preventDefault()); 3492 _saveBtnEl0.addEventListener('click', async () => { 3493 // Guard against spam-clicks: the drawing save AWAITS a canvas upload before 3494 // the optimistic re-render removes the form, so without this a slow upload 3495 // let repeated clicks create duplicate notes. 3496 const _saveBtn = form.querySelector('.note-form-save'); 3497 if (_saveBtn._saving) return; 3498 // Mobile: when an existing note is opened and closed without edits, the 3499 // Update (â) button morphs into Archive (set up below). Route the click 3500 // to the hidden archive button so the existing archive flow + undo toast 3501 // run unchanged. 3502 if (_saveBtn.classList.contains('archive-mode')) { 3503 form.querySelector('.note-form-archive-btn')?.click(); 3504 return; 3505 } 3506 _saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5'; 3507 try { 3508 const title = form.querySelector('.note-form-title').value.trim(); 3509 // Normalize tag input: split on whitespace, strip leading #s, dedupe,
3510 // re-join with single spaces. Empty â null. 3511 const _rawLabel = form.querySelector('.note-form-label')?.value || ''; 3512 const _tags = [...new Set(_rawLabel.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; 3513 if (form.querySelector('.note-form-due').value && !_tags.includes('reminder')) _tags.push('reminder'); 3514 const labelVal = _tags.length ? _tags.join(' ') : null; 3515 const payload = { 3516 title, 3517 note_type: currentType, 3518 color: currentColor, 3519 label: labelVal, 3520 due_date: form.querySelector('.note-form-due').value || null, 3521 repeat: form.querySelector('.note-form-repeat')?.value || 'none', 3522 image_url: currentImageUrl || null, 3523 }; 3524 if (currentType === 'note') { 3525 payload.content = form.querySelector('.note-form-content')?.value || ''; 3526 } else if (currentType === 'draw') { 3527 // Upload the canvas PNG before saving so image_url points to a 3528 // persistent file. We block the save until upload completes â drawings 3529 // can't be re-rendered later without the URL. 3530 const canvas = form.querySelector('.note-form-canvas'); 3531 const url = await _uploadCanvasAsPng(canvas); 3532 if (!url) { uiModule.showError('Failed to save drawing'); return; } 3533 payload.image_url = url; 3534 } else if (currentType === 'goal') { 3535 // Legacy: existing goal-type notes still edit through this branch. 3536 // No AI involvement â save as a normal note with description + items. 3537 payload.content = form.querySelector('.note-form-goal-desc')?.value || ''; 3538 payload.items = _collectItems(form); 3539 } else { 3540 payload.items = _collectItems(form); 3541 } 3542 if (isEdit) payload.id = note.id; 3543 // Reset fired reminder if due_date changed (so re-arm works), and also 3544 // clear the entry-glow seen flag so the new firing glows again on the 3545 // next time the user opens the panel. 3546 if (isEdit && note.due_date !== payload.due_date) { 3547 const fired = _loadFiredReminders(); 3548 fired.delete(note.id); 3549 _saveFiredReminders(fired); 3550 const glowed = _loadGlowedReminders(); 3551 glowed.delete(note.id); 3552 _saveGlowedReminders(glowed); 3553 _setReminderCardGlow(note.id, false); 3554 } 3555 // Edited notes move to the top of their section (under pinned). Compute 3556 // sort_order = (min unpinned sort_order) - 1 so the saved note sorts above 3557 // siblings; the pin block keeps its own ordering above this. 3558 if (!payload.pinned) { 3559 // Both edits AND newly-created notes anchor above the rest of the 3560 // unpinned section. Without this, freshly created notes sit at the 3561 // bottom because manually-reordered siblings already carry negative 3562 // sort_order values. 3563 const minUnpinned = _notes 3564 .filter(n => !n.pinned && (!isEdit || n.id !== note.id)) 3565 .reduce((m, n) => Math.min(m, n.sort_order || 0), 0); 3566 payload.sort_order = minUnpinned - 1; 3567 } 3568 // Optimistic update â update local state first, render, then save in background 3569 _editingId = null; 3570 _clearDraft(isEdit ? note.id : '__new__'); // saved â discard the draft 3571 if (isEdit) { 3572 const idx = _notes.findIndex(n => n.id === note.id); 3573 if (idx >= 0) _notes[idx] = { ..._notes[idx], ...payload }; 3574 } else { 3575 _notes.unshift({ ...payload, id: 'tmp_' + _uid(), created_at: new Date().toISOString(), updated_at: new Date().toISOString() }); 3576 } 3577 _renderNotes(); 3578 // Background save 3579 _saveNote(payload).then(saved => { 3580 if (!isEdit && saved && saved.id) { 3581 // Replace temp ID with real one from server. AND re-render â the 3582 // existing card's `data-note-id="tmp_xxx"` is stale after Object.assign 3583 // bumps the in-memory id, so all subsequent clicks (edit, done, copy, 3584 // archive, delete) silently fail to find the note in `_notes`. 3585 const tmp = _notes.find(n => n.id.startsWith('tmp_')); 3586 if (tmp) Object.assign(tmp, saved); 3587 _renderNotes(); 3588 } 3589 }).catch(err => { 3590 uiModule.showError('Save failed: ' + err.message); 3591 _fetchNotes().then(() => _renderNotes()); 3592 }); 3593 } finally { 3594 // Re-enable on early returns / errors. On success the form is removed by 3595 // the optimistic re-render, so re-enabling the detached button is a no-op. 3596 _saveBtn._saving = false; _saveBtn.disabled = false; _saveBtn.style.opacity = ''; 3597 } 3598 }); 3599 3600 // Mobile-only: when editing an existing note, the Update (â) button starts in 3601 // archive-mode (visually + behaviorally) and flips to Update on the first 3602 // edit. Lets the user tap a note to skim, then tap â to archive without ever 3603 // touching a separate Archive button. 3604 if (isEdit && window.innerWidth <= 768) { 3605 const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label'); 3606 const _enterArchive = () => { 3607 _saveBtnEl0.classList.add('archive-mode');
3608 if (_saveLabelEl) _saveLabelEl.textContent = 'Archive'; 3609 _saveBtnEl0.title = 'Archive'; 3610 }; 3611 const _enterUpdate = () => { 3612 if (!_saveBtnEl0.classList.contains('archive-mode')) return; 3613 _saveBtnEl0.classList.remove('archive-mode'); 3614 if (_saveLabelEl) _saveLabelEl.textContent = 'Update'; 3615 _saveBtnEl0.title = 'Update'; 3616 }; 3617 _enterArchive(); 3618 form.addEventListener('input', _enterUpdate, true); 3619 form.addEventListener('change', _enterUpdate, true); 3620 } 3621 3622 // Cancel 3623 form.querySelector('.note-form-cancel').addEventListener('click', () => { _clearDraft(isEdit ? note.id : '__new__'); _editingId = null; _renderNotes(); }); 3624 3625 // Archive / Delete â edit-mode-only buttons, mirror the (now-hidden) card actions. 3626 form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => { 3627 if (!isEdit) return; 3628 const id = note.id; 3629 const idx = _notes.findIndex(n => n.id === id); 3630 if (idx < 0) return; 3631 const removed = _notes.splice(idx, 1)[0]; 3632 _editingId = null; 3633 _renderNotes(); 3634 const undo = () => _undoArchive(removed, idx); 3635 _pushUndo({ label: 'archive', run: undo }); 3636 const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>'; 3637 _patchNote(id, { archived: true }).then(() => { 3638 uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' }); 3639 }).catch(() => { 3640 _notes.splice(idx, 0, removed); 3641 _renderNotes(); 3642 uiModule.showError('Failed to archive'); 3643 }); 3644 }); 3645 form.querySelector('.note-form-delete-btn')?.addEventListener('click', async () => { 3646 if (!isEdit) return; 3647 const id = note.id; 3648 if (uiModule.styledConfirm) { 3649 const ok = await uiModule.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }); 3650 if (!ok) return; 3651 } else if (!confirm('Delete this note?')) { 3652 return; 3653 } 3654 const idx = _notes.findIndex(n => n.id === id); 3655 if (idx >= 0) _notes.splice(idx, 1); 3656 _editingId = null; 3657 _renderNotes(); 3658 _deleteNoteApi(id).then(() => uiModule.showToast('Deleted')).catch(() => { 3659 uiModule.showError('Failed to delete'); 3660 _fetchNotes().then(() => _renderNotes()); 3661 }); 3662 }); 3663 3664 // Autosave a draft to localStorage on every change so unsaved edits 3665 // survive connection loss / reload / accidental close. 3666 _wireDraftAutosave(form, isEdit ? note.id : '__new__'); 3667 3668 return form; 3669} 3670 3671// Legacy goal-typed notes still render through this branch so existing 3672// data isn't lost. The "Goal" type is no longer exposed in the form picker 3673// or quick-add â these notes show with a description + manual checklist 3674// editor, just like a regular todo with a body. 3675function _buildGoalHtml(note, items) { 3676 const desc = (note?.content || '').toString(); 3677 return ` 3678 <div class="note-form-goal"> 3679 <textarea class="note-form-goal-desc" placeholder="Description (optional)" rows="3">${_esc(desc)}</textarea> 3680 ${_buildChecklistHtml(items)} 3681 </div> 3682 `; 3683} 3684 3685function _wireGoalForm(form, container) { 3686 if (!container) return; 3687 // _wireHashtag is a closure inside _buildForm â out of scope here. Inline 3688 // the same behavior (type "#foo " in the description â tag added to the
3689 // form's label input) so editing a goal note doesn't ReferenceError. 3690 const desc = container.querySelector('.note-form-goal-desc'); 3691 const labelInput = form?.querySelector('.note-form-label'); 3692 if (desc && labelInput) { 3693 const tagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; 3694 desc.addEventListener('input', () => { 3695 const m = tagRe.exec(desc.value); 3696 if (!m) return; 3697 const tag = m[2]; 3698 // Same dedup-after-stripping fix as the plain note hashtag handler. 3699 const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); 3700 const stripped = existing.map(t => t.replace(/^#+/, '')); 3701 if (!stripped.includes(tag)) { 3702 existing.push('#' + tag); 3703 labelInput.value = existing.join(' '); 3704 labelInput.classList.add('flash-once'); 3705 setTimeout(() => labelInput.classList.remove('flash-once'), 600); 3706 } 3707 const cut = desc.value.length - m[0].length + m[1].length; 3708 desc.value = desc.value.slice(0, cut); 3709 }); 3710 } 3711 // Always wire the checklist. The previous gate on a `note-form-goal-fresh` 3712 // class was dead code â nothing ever set that class, so the editor never 3713 // hooked up add/drag/Tab. 3714 _wireChecklist(container); 3715} 3716 3717function _buildChecklistHtml(items) { 3718 let html = '<div class="note-checklist-inputs">'; 3719 for (const item of items) { 3720 const indent = Math.min(item.indent || 0, 3); 3721 html += `<div class="note-cl-row${item.done ? ' done' : ''}" draggable="true" data-item-id="${item.id || _uid()}" data-indent="${indent}" style="padding-left:${indent * 16}px"> 3722 <span class="note-cl-grip" title="Drag to reorder">â®â®</span> 3723 <span class="note-cl-dot"></span> 3724 <input type="text" class="note-cl-text" value="${_esc(item.text)}" placeholder="Item..." /> 3725 <button type="button" class="note-cl-rm">×</button> 3726 </div>`; 3727 } 3728 // `type="button"` matters on mobile â without it some browsers treat 3729 // bare <button> as form-submit and the click handler never fires inside 3730 // certain containers. Also bumped tap target so fingers don't miss. 3731 html += `<button type="button" class="note-cl-add">+ Add</button></div>`; 3732 return html; 3733} 3734 3735function _wireRow(row, container) { 3736 row.querySelector('.note-cl-rm')?.addEventListener('click', () => row.remove()); 3737 row.querySelector('.note-cl-dot')?.addEventListener('click', () => { 3738 const wasDone = row.classList.contains('done'); 3739 row.classList.toggle('done'); 3740 const becameDone = !wasDone; // we just flipped it 3741 const dot = row.querySelector('.note-cl-dot'); 3742 const dRect = (dot || row).getBoundingClientRect(); 3743 // Small confetti burst on each fresh check so the user gets a 3744 // "well done" beat per item, not just the grand-finale on all-done. 3745 if (becameDone) { 3746 spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 16); 3747 } 3748 // Bigger burst when the whole list is now done. 3749 const rows = [...container.querySelectorAll('.note-cl-row')]; 3750 const hasText = rows.some(r => (r.querySelector('.note-cl-text')?.value || '').trim().length > 0); 3751 if (hasText && rows.every(r => r.classList.contains('done') || !(r.querySelector('.note-cl-text')?.value || '').trim())) { 3752 spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 60); 3753 } 3754 }); 3755 const txt = row.querySelector('.note-cl-text'); 3756 txt?.addEventListener('keydown', (e) => { 3757 if (e.key === 'Enter') { e.preventDefault(); container.querySelector('.note-cl-add')?.click(); } 3758 else if (e.key === 'Tab') { 3759 e.preventDefault(); 3760 const cur = parseInt(row.dataset.indent || '0'); 3761 const next = e.shiftKey ? Math.max(0, cur - 1) : Math.min(3, cur + 1); 3762 row.dataset.indent = String(next); 3763 row.style.paddingLeft = (next * 16) + 'px'; 3764 } else if (e.key === 'Backspace' && txt.value === '') { 3765 e.preventDefault(); 3766 const prev = row.previousElementSibling; 3767 row.remove(); 3768 if (prev && prev.classList.contains('note-cl-row')) prev.querySelector('.note-cl-text')?.focus(); 3769 } 3770 }); 3771 // Drag handlers 3772 row.addEventListener('dragstart', (e) => { 3773 row.classList.add('dragging'); 3774 e.dataTransfer.effectAllowed = 'move'; 3775 try { e.dataTransfer.setData('text/plain', row.dataset.itemId); } catch {} 3776 }); 3777 row.addEventListener('dragend', () => { 3778 row.classList.remove('dragging'); 3779 container.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); 3780 }); 3781} 3782 3783function _wireChecklist(container) { 3784 if (!container) return; 3785 // Delegate the + Add click off the container so re-renders + mobile 3786 // touch quirks don't leave the button dead. The previous direct 3787 // `addEventListener` on the button silently broke on mobile when 3788 // _wireChecklist ran more than once (or before the button was in DOM). 3789 if (!container._addDelegated) { 3790 container._addDelegated = true; 3791 container.addEventListener('click', (ev) => { 3792 const addBtn = ev.target.closest('.note-cl-add'); 3793 if (!addBtn || !container.contains(addBtn)) return; 3794 ev.preventDefault(); 3795 ev.stopPropagation(); 3796 const inputs = container.querySelector('.note-checklist-inputs'); 3797 if (!inputs) return;
3798 const row = document.createElement('div'); 3799 row.className = 'note-cl-row'; 3800 row.draggable = true; 3801 row.dataset.itemId = _uid(); 3802 row.dataset.indent = '0'; 3803 row.innerHTML = `<span class="note-cl-grip" title="Drag">â®â®</span><span class="note-cl-dot"></span><input type="text" class="note-cl-text" placeholder="Item..." /><button type="button" class="note-cl-rm">×</button>`; 3804 inputs.insertBefore(row, addBtn); 3805 row.querySelector('.note-cl-text').focus(); 3806 _wireRow(row, container); 3807 }); 3808 } 3809 container.querySelectorAll('.note-cl-row').forEach(row => _wireRow(row, container)); 3810 3811 // Drag-over handler on the inputs container 3812 const inputs = container.querySelector('.note-checklist-inputs'); 3813 if (inputs) { 3814 inputs.addEventListener('dragover', (e) => { 3815 e.preventDefault(); 3816 const dragging = inputs.querySelector('.note-cl-row.dragging'); 3817 if (!dragging) return; 3818 inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); 3819 const rows = [...inputs.querySelectorAll('.note-cl-row:not(.dragging)')]; 3820 const after = rows.find(r => { 3821 const box = r.getBoundingClientRect(); 3822 return e.clientY < box.top + box.height / 2; 3823 }); 3824 if (after) { 3825 after.classList.add('drop-before'); 3826 inputs.insertBefore(dragging, after); 3827 } else if (rows.length) { 3828 rows[rows.length - 1].classList.add('drop-after'); 3829 inputs.insertBefore(dragging, container.querySelector('.note-cl-add')); 3830 } 3831 }); 3832 inputs.addEventListener('dragleave', (e) => { 3833 if (!inputs.contains(e.relatedTarget)) { 3834 inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); 3835 } 3836 }); 3837 } 3838} 3839 3840function _collectItems(form) { 3841 const items = []; 3842 form.querySelectorAll('.note-cl-row').forEach(row => { 3843 const text = row.querySelector('.note-cl-text')?.value?.trim(); 3844 if (text) items.push({ 3845 id: row.dataset.itemId || _uid(), 3846 text, 3847 done: row.classList.contains('done'), 3848 indent: parseInt(row.dataset.indent || '0'), 3849 }); 3850 }); 3851 return items; 3852} 3853 3854// ---- Draw mode (canvas) ---- 3855 3856function _buildDrawHtml() { 3857 return ` 3858 <div class="note-form-draw-wrap"> 3859 <canvas class="note-form-canvas" width="600" height="320"></canvas> 3860 <div class="note-form-draw-toolbar"> 3861 <input type="color" class="note-form-draw-color" title="Stroke color" value="#222222" /> 3862 <label class="note-form-draw-tool note-form-draw-size-wrap" title="Stroke size"> 3863 <input type="range" class="note-form-draw-size" min="1" max="24" value="3" /> 3864 </label> 3865 <div class="note-form-draw-be" role="group"> 3866 <button type="button" class="note-form-draw-be-btn note-form-draw-brush active" data-mode="pen" title="Brush"> 3867 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04 0-1.67-1.34-3.02-3-3.02z"/></svg> 3868 </button> 3869 <button type="button" class="note-form-draw-be-btn note-form-draw-eraser" data-mode="eraser" title="Eraser"> 3870 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg> 3871 </button> 3872 </div> 3873 <button type="button" class="note-form-draw-text" title="Add text â click to cycle size">T<span class="note-form-draw-text-badge"></span></button> 3874 <button type="button" class="note-form-draw-line" title="Line â click to cycle size"> 3875 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="20" x2="20" y2="4"/></svg>
3876 <span class="note-form-draw-shape-badge"></span> 3877 </button> 3878 <button type="button" class="note-form-draw-circle" title="Circle â click to cycle size"> 3879 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg> 3880 <span class="note-form-draw-shape-badge"></span> 3881 </button> 3882 <button type="button" class="note-form-draw-undo" title="Undo"> 3883 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg> 3884 </button> 3885 </div> 3886 </div> 3887 `; 3888} 3889 3890// Attach drawing handlers to the canvas inside `container`. Optionally loads 3891// `initialImageUrl` as a background, so editing an existing drawing keeps it. 3892function _wireCanvas(container, initialImageUrl) { 3893 const canvas = container.querySelector('.note-form-canvas'); 3894 if (!canvas) return; 3895 // Bump the backing-store resolution for retina displays so strokes stay 3896 // crisp. Set only style.width â leaving style.height to auto so the canvas 3897 // scales uniformly via its intrinsic aspect ratio. If both CSS dimensions 3898 // are pinned, max-width:100% shrinks the width only, leaving rasterized 3899 // glyphs visibly stretched relative to their on-screen input. 3900 const dpr = Math.min(window.devicePixelRatio || 1, 2); 3901 const cssW = canvas.width; 3902 const cssH = canvas.height; 3903 // Fill the container up to the logical width (don't pin a hard 600px, 3904 // which on a narrow phone forces the card wider than the viewport and 3905 // pushes the drawing outside the note). _pos() scales pointer coords by 3906 // the actual displayed width, so accuracy is preserved at any size. 3907 canvas.style.width = '100%'; 3908 canvas.style.maxWidth = cssW + 'px'; 3909 canvas.style.height = 'auto'; 3910 canvas.style.aspectRatio = cssW + ' / ' + cssH; 3911 canvas.width = cssW * dpr; 3912 canvas.height = cssH * dpr; 3913 const ctx = canvas.getContext('2d'); 3914 ctx.scale(dpr, dpr); 3915 ctx.fillStyle = '#ffffff'; 3916 ctx.fillRect(0, 0, cssW, cssH); 3917 ctx.lineCap = 'round'; 3918 ctx.lineJoin = 'round'; 3919 3920 // Load prior drawing as starting point so consecutive edits compose. 3921 const safeInitialImageUrl = _safeImgSrc(initialImageUrl); 3922 if (safeInitialImageUrl) { 3923 const img = new Image(); 3924 img.crossOrigin = 'anonymous'; 3925 img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} }; 3926 img.src = safeInitialImageUrl; 3927 // Float an X over the canvas so the user can blank it out and go back to 3928 // a clean draw surface. Removes itself once clicked. 3929 const wrap = container.querySelector('.note-form-draw-wrap'); 3930 if (wrap && !wrap.querySelector('.note-form-draw-bg-rm')) { 3931 const rm = document.createElement('button'); 3932 rm.type = 'button'; 3933 rm.className = 'note-form-draw-bg-rm'; 3934 rm.title = 'Clear photo (regular draw)'; 3935 rm.innerHTML = '×'; 3936 rm.addEventListener('click', (e) => { 3937 e.preventDefault(); 3938 e.stopPropagation(); 3939 ctx.fillStyle = '#ffffff'; 3940 ctx.fillRect(0, 0, cssW, cssH); 3941 rm.remove(); 3942 }); 3943 wrap.appendChild(rm); 3944 } 3945 } 3946 3947 const colorInput = container.querySelector('.note-form-draw-color'); 3948 // Swap the native browser color dialog for the in-house HSV picker 3949 // (same one used by Themes + the gallery editor). Existing `input` event 3950 // listeners + .value reads keep working â see colorPicker.js. 3951 if (colorInput) attachColorPicker(colorInput); 3952 const sizeInput = container.querySelector('.note-form-draw-size'); 3953 const beSeg = container.querySelector('.note-form-draw-be'); 3954 const brushBtn = container.querySelector('.note-form-draw-brush'); 3955 const eraserBtn = container.querySelector('.note-form-draw-eraser'); 3956 const textBtn = container.querySelector('.note-form-draw-text'); 3957 const lineBtn = container.querySelector('.note-form-draw-line'); 3958 const circleBtn = container.querySelector('.note-form-draw-circle'); 3959 const undoBtn = container.querySelector('.note-form-draw-undo'); 3960 // Single source of truth for what clicks/drags do. Other booleans are 3961 // derived from this so we never end up with conflicting "eraser AND text" 3962 // states (the bug that made T appear broken after using the eraser). 3963 // Modes: 'pen' | 'eraser' | 'text-s' | 'text-m' | 'text-l' | 'line' | 'circle' 3964 let mode = 'pen'; 3965 let drawing = false;
3966 let last = null; 3967 // The text tool has three preset sizes (CSS px font-size). 3968 const TEXT_SIZES = { 's': 16, 'm': 26, 'l': 40 }; 3969 // Line / circle stroke widths in logical pixels â three crisp options. 3970 const SHAPE_WIDTHS = { 's': 2, 'm': 5, 'l': 10 }; 3971 // Snapshot taken at the start of a shape drag so the preview can repaint 3972 // cleanly on each move without accumulating intermediate strokes. 3973 let _shapeSnapshot = null; 3974 3975 // Per-canvas undo stack. We snapshot the bitmap (as ImageData) BEFORE each 3976 // operation â stroke, text commit, or future operations â and pop+restore 3977 // on Undo. Cap to 30 to keep memory bounded. 3978 const _undoStack = []; 3979 const UNDO_LIMIT = 30; 3980 const _snapshot = () => { 3981 try { 3982 const w = canvas.width, h = canvas.height; 3983 _undoStack.push(ctx.getImageData(0, 0, w, h)); 3984 if (_undoStack.length > UNDO_LIMIT) _undoStack.shift(); 3985 } catch {} 3986 }; 3987 const _undo = () => { 3988 const prev = _undoStack.pop(); 3989 if (!prev) return; 3990 // Restore against the raw backing store: temporarily reset the active 3991 // ctx scale, paint the snapshot 1:1, then reapply our standard transform. 3992 ctx.save(); 3993 ctx.setTransform(1, 0, 0, 1, 0, 0); 3994 ctx.putImageData(prev, 0, 0); 3995 ctx.restore(); 3996 }; 3997 3998 const _pos = (e) => { 3999 // CSS can shrink the canvas (max-width:100%) without changing its logical 4000 // size, so compute the displayed-to-logical scale per axis. Pointer coords 4001 // are in CSS pixels; the dpr-scaled ctx expects logical (cssW Ã cssH). 4002 const r = canvas.getBoundingClientRect(); 4003 const sx = cssW / r.width; 4004 const sy = cssH / r.height; 4005 const t = e.touches ? e.touches[0] : e; 4006 return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy }; 4007 }; 4008 const _begin = (e) => { 4009 if (mode.startsWith('text-')) { 4010 // Stop the event so the browser doesn't synthesize a follow-up click 4011 // that would blur the input we're about to create. 4012 e.preventDefault?.(); 4013 e.stopPropagation?.(); 4014 _openTextInput(e); 4015 return; 4016 } 4017 _snapshot(); 4018 last = _pos(e); 4019 drawing = true; 4020 if (mode.startsWith('line-') || mode.startsWith('circle-')) { 4021 // Capture the backing pixels so the preview can replay each move from 4022 // the same starting state (otherwise live shapes accumulate). 4023 try { _shapeSnapshot = ctx.getImageData(0, 0, canvas.width, canvas.height); } catch {} 4024 return; 4025 } 4026 ctx.beginPath(); 4027 ctx.moveTo(last.x, last.y); 4028 }; 4029 4030 // Drop an HTML input at the click position so the user can type, then 4031 // rasterize the text onto the canvas at blur/Enter. Mirrors how PDF form 4032 // annotations work in our doc editor. 4033 let _activeTextInput = null; 4034 const _openTextInput = (e) => { 4035 // Commit any prior pending input before starting a new one â otherwise 4036 // the first click leaves an orphaned input the user thinks "didn't work". 4037 if (_activeTextInput) { try { _activeTextInput.blur(); } catch {} } 4038 const r = canvas.getBoundingClientRect(); 4039 const t = e.touches ? e.touches[0] : e; 4040 // Position is anchored to the wrap, not the canvas; since the canvas is 4041 // the first child of the wrap and the wrap has no padding, they share an 4042 // origin, so we offset from the canvas rect directly. 4043 const px = t.clientX - r.left; 4044 const py = t.clientY - r.top; 4045 const logical = _pos(e); 4046 // Size is decided by which T variant is active (S/M/L), not the stroke 4047 // slider â those are independent dials. 4048 const sizeKey = mode.startsWith('text-') ? mode.slice(-1) : 'm'; 4049 const sizeCss = TEXT_SIZES[sizeKey] || TEXT_SIZES.m; 4050 const wrap = container.querySelector('.note-form-draw-wrap'); 4051 if (!wrap) return; 4052 if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative'; 4053 const input = document.createElement('input'); 4054 input.type = 'text'; 4055 input.className = 'note-form-draw-textinput'; 4056 input.placeholder = 'type then Enter'; 4057 const color = colorInput?.value || '#222'; 4058 const maxW = Math.max(120, Math.floor(r.width - px - 4)); 4059 input.style.cssText = [ 4060 'position:absolute', 4061 `left:${px}px`, 4062 `top:${Math.max(0, py - sizeCss * 0.7)}px`, 4063 `font:${sizeCss}px Arial, sans-serif`, 4064 `color:${color}`, 4065 'background:#ffffff', 4066 'border:2px solid var(--accent)', 4067 'border-radius:4px', 4068 'outline:none', 4069 'padding:2px 6px', 4070 'min-width:120px', 4071 `max-width:${maxW}px`, 4072 'z-index:1000', 4073 'box-shadow:0 2px 8px rgba(0,0,0,0.25)', 4074 'pointer-events:auto', 4075 ].join(';'); 4076 wrap.appendChild(input); 4077 _activeTextInput = input; 4078 // Focus synchronously so the call still counts as inside the user gesture 4079 // on iOS / Android, then re-focus on the next frame in case a competing
4080 // synthetic event (touch â click) moved focus away. 4081 input.focus(); 4082 requestAnimationFrame(() => { if (document.activeElement !== input) input.focus(); }); 4083 let committed = false; 4084 const commit = () => { 4085 if (committed) return; 4086 committed = true; 4087 const text = input.value; 4088 if (_activeTextInput === input) _activeTextInput = null; 4089 input.remove(); 4090 if (!text) return; 4091 // Snapshot BEFORE rasterizing so Undo removes the text in one step. 4092 _snapshot(); 4093 ctx.save(); 4094 ctx.globalCompositeOperation = 'source-over'; 4095 ctx.fillStyle = color; 4096 // Canvas now scales uniformly (style.height: auto), so a single ratio 4097 // suffices for picking the logical font size that matches what the 4098 // user just saw in the HTML input. 4099 const sx = cssW / r.width; 4100 const logicalSize = sizeCss * sx; 4101 ctx.font = `${logicalSize}px sans-serif`; 4102 ctx.textBaseline = 'top'; 4103 ctx.fillText(text, logical.x, logical.y - logicalSize * 0.7); 4104 ctx.restore(); 4105 }; 4106 input.addEventListener('blur', commit); 4107 input.addEventListener('keydown', (ev) => { 4108 if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } 4109 else if (ev.key === 'Escape') { input.value = ''; input.blur(); } 4110 }); 4111 }; 4112 const _move = (e) => { 4113 if (!drawing) return; 4114 e.preventDefault?.(); 4115 const p = _pos(e); 4116 if (mode.startsWith('line-') || mode.startsWith('circle-')) { 4117 // Restore the pre-shape bitmap, then redraw the preview shape from 4118 // anchor â current pointer. 4119 if (_shapeSnapshot) { 4120 ctx.save(); 4121 ctx.setTransform(1, 0, 0, 1, 0, 0); 4122 ctx.putImageData(_shapeSnapshot, 0, 0); 4123 ctx.restore(); 4124 } 4125 ctx.globalCompositeOperation = 'source-over'; 4126 ctx.strokeStyle = colorInput?.value || '#222'; 4127 const sizeKey = mode.slice(-1); 4128 ctx.lineWidth = SHAPE_WIDTHS[sizeKey] || SHAPE_WIDTHS.m; 4129 ctx.beginPath(); 4130 if (mode.startsWith('line-')) { 4131 ctx.moveTo(last.x, last.y); 4132 ctx.lineTo(p.x, p.y); 4133 } else { 4134 const dx = p.x - last.x; 4135 const dy = p.y - last.y; 4136 const radius = Math.hypot(dx, dy); 4137 ctx.arc(last.x, last.y, radius, 0, Math.PI * 2); 4138 } 4139 ctx.stroke(); 4140 return; 4141 } 4142 const erasing = mode === 'eraser'; 4143 ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'; 4144 ctx.strokeStyle = erasing ? 'rgba(0,0,0,1)' : (colorInput?.value || '#222'); 4145 ctx.lineWidth = Number(sizeInput?.value || 3) * (erasing ? 2.5 : 1); 4146 ctx.lineTo(p.x, p.y); 4147 ctx.stroke(); 4148 last = p; 4149 }; 4150 const _end = () => { drawing = false; last = null; _shapeSnapshot = null; }; 4151 4152 canvas.addEventListener('mousedown', _begin); 4153 canvas.addEventListener('mousemove', _move); 4154 window.addEventListener('mouseup', _end); 4155 // Non-passive so text mode can preventDefault â otherwise the synthetic 4156 // mousedown/click that follows a touch can blur the freshly-created text 4157 // input on iOS Safari, making T look like a no-op. 4158 canvas.addEventListener('touchstart', (e) => { if (mode.startsWith('text-')) e.preventDefault(); _begin(e); }, { passive: false }); 4159 canvas.addEventListener('touchmove', _move, { passive: false }); 4160 canvas.addEventListener('touchend', _end); 4161 canvas.addEventListener('touchcancel', _end); 4162 4163 // Single mode setter â keeps the toolbar, swatch, and cursor in sync, and 4164 // makes sure exiting the eraser restores the user's chosen color whether 4165 // they exit by clicking another tool or by toggling eraser off. 4166 let _preEraseColor = null; 4167 const _setMode = (next) => { 4168 const wasEraser = mode === 'eraser'; 4169 mode = next; 4170 const isEraser = next === 'eraser'; 4171 const isPen = next === 'pen'; 4172 const isText = next.startsWith('text-'); 4173 // Swatch: white while erasing, restored as soon as we leave that mode. 4174 if (isEraser && !wasEraser && colorInput) { 4175 _preEraseColor = colorInput.value; 4176 colorInput.value = '#ffffff'; 4177 } else if (!isEraser && wasEraser && colorInput && _preEraseColor) { 4178 colorInput.value = _preEraseColor; 4179 _preEraseColor = null; 4180 } 4181 // Brush/Eraser segmented pill â slides to the active side. When a non- 4182 // pen/eraser tool (T / line / circle) is active, neither half is 4183 // highlighted, but the pill still indicates which side the user was on 4184 // last so they can return with one click. 4185 const isLine = next.startsWith('line-'); 4186 const isCircle = next.startsWith('circle-'); 4187 beSeg?.classList.toggle('is-eraser', isEraser); 4188 brushBtn?.classList.toggle('active', isPen); 4189 eraserBtn?.classList.toggle('active', isEraser); 4190 textBtn?.classList.toggle('active', isText); 4191 lineBtn?.classList.toggle('active', isLine); 4192 circleBtn?.classList.toggle('active', isCircle); 4193 // Per-button size badges (S/M/L), driven off the mode suffix. 4194 const tBadge = textBtn?.querySelector('.note-form-draw-text-badge'); 4195 if (tBadge) tBadge.textContent = isText ? next.slice(-1).toUpperCase() : ''; 4196 const lBadge = lineBtn?.querySelector('.note-form-draw-shape-badge'); 4197 if (lBadge) lBadge.textContent = isLine ? next.slice(-1).toUpperCase() : ''; 4198 const cBadge = circleBtn?.querySelector('.note-form-draw-shape-badge'); 4199 if (cBadge) cBadge.textContent = isCircle ? next.slice(-1).toUpperCase() : ''; 4200 // Reflect the chosen size in the icon itself â thicker line/circle stroke 4201 // for M+L, larger T glyph for M+L. CSS rules read `.size-s/.size-m/.size-l`. 4202 const _sz = next.slice(-1);
4203 [textBtn, lineBtn, circleBtn].forEach(b => b?.classList.remove('size-s', 'size-m', 'size-l')); 4204 if (isText && /[sml]/.test(_sz)) textBtn?.classList.add('size-' + _sz); 4205 if (isLine && /[sml]/.test(_sz)) lineBtn?.classList.add('size-' + _sz); 4206 if (isCircle && /[sml]/.test(_sz)) circleBtn?.classList.add('size-' + _sz); 4207 canvas.style.cursor = isText ? 'text' : 'crosshair'; 4208 }; 4209 brushBtn?.addEventListener('click', () => _setMode('pen')); 4210 eraserBtn?.addEventListener('click', () => _setMode('eraser')); 4211 // T / Line / Circle: each cycles its own three sizes, then back to pen. 4212 const _cycle = (prefix) => { 4213 const seq = ['s', 'm', 'l']; 4214 if (!mode.startsWith(prefix)) return prefix + 's'; 4215 const cur = mode.slice(-1); 4216 const next = seq[seq.indexOf(cur) + 1]; 4217 return next ? prefix + next : 'pen'; 4218 }; 4219 textBtn?.addEventListener('click', () => _setMode(_cycle('text-'))); 4220 lineBtn?.addEventListener('click', () => _setMode(_cycle('line-'))); 4221 circleBtn?.addEventListener('click', () => _setMode(_cycle('circle-'))); 4222 undoBtn?.addEventListener('click', () => _undo()); 4223 4224 // Stash so the save handler can read it later without re-resolving DOM. 4225 canvas._cssW = cssW; 4226 canvas._cssH = cssH; 4227 return canvas; 4228} 4229 4230// Export the canvas as a PNG dataURL, upload it via the existing /api/upload 4231// endpoint, and resolve to a persistent URL. Returns null on failure. 4232async function _uploadCanvasAsPng(canvas) { 4233 if (!canvas) return null; 4234 const blob = await new Promise(r => canvas.toBlob(r, 'image/png')); 4235 if (!blob) return null; 4236 const fd = new FormData(); 4237 fd.append('files', blob, 'drawing.png'); 4238 try { 4239 const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' }); 4240 const data = await res.json(); 4241 const id = data.files?.[0]?.id; 4242 return id ? `${API_BASE}/api/upload/${id}` : null; 4243 } catch { return null; } 4244} 4245 4246// ---- Create / Edit / Delete ---- 4247 4248function _createNote(type = 'todo') { 4249 const body = document.querySelector('#notes-pane .notes-pane-body'); 4250 if (!body || _editingId === '__new__') return; 4251 _editingId = '__new__'; 4252 // Restore an unsaved new-note draft if one survived a prior close/loss. 4253 const { note: _n, restored } = _applyDraftToNote({ note_type: type }, '__new__'); 4254 const form = _buildForm(_n); 4255 form.classList.add('note-form-new'); 4256 body.prepend(form); 4257 form.querySelector('.note-form-title').focus(); 4258 if (restored) uiModule.showToast('Restored unsaved note'); 4259} 4260 4261// Build the plain-text/markdown form of a note for clipboard copy. 4262function _serializeNoteForCopy(note) { 4263 const lines = []; 4264 if (note.title) lines.push(note.title); 4265 if (note.content) lines.push(note.content); 4266 if (Array.isArray(note.items) && note.items.length) { 4267 if (lines.length) lines.push(''); 4268 for (const it of note.items) { 4269 if (!it || !(it.text || '').trim()) continue; 4270 lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); 4271 } 4272 } 4273 return lines.join('\n').trim(); 4274} 4275 4276// Copy a note to the clipboard, briefly swap btnEl's icon to a checkmark, and 4277// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut. 4278// ââ ⯠corner menu (Copy + Agent) âââââââââââââââââââââââââââââââââââ 4279function _openNoteCornerMenu(btn) { 4280 document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove()); 4281 const id = btn.dataset.noteId; 4282 const note = _notes.find(n => n.id === id); 4283 if (!note) return; 4284 const menu = document.createElement('div'); 4285 menu.className = 'note-corner-menu-dropdown'; 4286 menu.innerHTML = ` 4287 <button type="button" class="ncm-item" data-act="copy"> 4288 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> 4289 <span>Copy</span> 4290 </button> 4291 <button type="button" class="ncm-item" data-act="agent"> 4292 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>
4293 <span>${note.agent_session_id ? 'Re-run agent' : 'Agent: solve this'}</span> 4294 </button>`; 4295 document.body.appendChild(menu); 4296 const r = btn.getBoundingClientRect(); 4297 // Right-align to the ⯠button, clamped to the viewport. 4298 const mw = 168; 4299 let left = Math.min(r.right - mw, window.innerWidth - mw - 8); 4300 left = Math.max(8, left); 4301 // Drop down by default; flip up if there isn't room below (the button 4302 // sits at the card's bottom edge now). 4303 const mh = menu.offsetHeight || 96; 4304 const below = window.innerHeight - r.bottom; 4305 const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); 4306 menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`; 4307 const close = (ev) => { 4308 if (ev && menu.contains(ev.target)) return; 4309 menu.remove(); 4310 document.removeEventListener('click', close, true); 4311 }; 4312 setTimeout(() => document.addEventListener('click', close, true), 0); 4313 menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); }); 4314 menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); }); 4315} 4316 4317// Build the prompt the agent gets from a note: title + body, plus any 4318// not-yet-done checklist items. 4319function _noteToAgentPrompt(note) { 4320 const parts = []; 4321 if ((note.title || '').trim()) parts.push(note.title.trim()); 4322 if ((note.content || '').trim()) parts.push(note.content.trim()); 4323 if (Array.isArray(note.items)) { 4324 note.items.filter(it => !it.done && (it.text || '').trim()) 4325 .forEach(it => parts.push('- ' + it.text.trim())); 4326 } 4327 const body = parts.join('\n'); 4328 return body ? `Help me get this done:\n\n${body}` : ''; 4329} 4330 4331// Agent-solve: create a chat session server-side, kick off an agent run 4332// on it IN THE BACKGROUND (the user stays in notes), and link the session 4333// to the note via a clickable tag. Tapping the tag later opens the chat. 4334async function _agentSolveNote(id) { 4335 const note = _notes.find(n => n.id === id); 4336 if (!note) return; 4337 const prompt = _noteToAgentPrompt(note); 4338 if (!prompt) { uiModule.showToast('Nothing to solve â note is empty'); return; } 4339 try { 4340 const dc = await (await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' })).json(); 4341 if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; } 4342 4343 // 1. Create the session server-side (no UI switch). skip_validation 4344 // avoids re-probing â the default-chat endpoint is already known good. 4345 const label = (note.title || (Array.isArray(note.items) && note.items[0]?.text) || 'todo').slice(0, 40); 4346 const csFd = new FormData(); 4347 csFd.append('name', 'Agent: ' + label); 4348 csFd.append('endpoint_url', dc.endpoint_url); 4349 csFd.append('model', dc.model); 4350 if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id); 4351 csFd.append('skip_validation', 'true'); 4352 const csRes = await fetch(`${API_BASE}/api/session`, { method: 'POST', credentials: 'same-origin', body: csFd }); 4353 if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; } 4354 const sess = await csRes.json(); 4355 const sid = sess.id; 4356 4357 // 2. Link the session to the note right away so the tag appears. 4358 const n = _notes.find(x => x.id === id); 4359 if (n) n.agent_session_id = sid; 4360 _renderNotes(); 4361 _patchNote(id, { agent_session_id: sid }).catch(() => {}); 4362 4363 // 3. Kick off the agent run in the background. POST to chat_stream in 4364 // agent mode and drain the SSE so the server runs the loop to 4365 // completion + saves â without rendering anything in the chat UI. 4366 const fd = new FormData(); 4367 fd.append('message', prompt); 4368 fd.append('session', sid); 4369 fd.append('mode', 'agent'); 4370 fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', credentials: 'same-origin', body: fd }) 4371 .then(async (res) => { 4372 if (!res.ok || !res.body) return; 4373 const reader = res.body.getReader(); 4374 // Drain to completion (server finishes + persists the run). 4375 while (true) { const { done } = await reader.read(); if (done) break; } 4376 if (window.sessionModule && window.sessionModule.markStreamComplete) { 4377 try { window.sessionModule.markStreamComplete(sid); } catch {} 4378 } 4379 }) 4380 .catch(() => {}); 4381
4382 uiModule.showToast('Agent working in background â tap the Agent tag when ready'); 4383 } catch (e) { 4384 uiModule.showError('Agent failed: ' + (e.message || e)); 4385 } 4386} 4387 4388async function _copyNote(noteId, btnEl) { 4389 const note = _notes.find(n => n.id === noteId); 4390 if (!note) return false; 4391 const text = _serializeNoteForCopy(note); 4392 if (!text) return false; 4393 let ok = false; 4394 try { 4395 await navigator.clipboard.writeText(text); 4396 ok = true; 4397 } catch { 4398 const ta = document.createElement('textarea'); 4399 ta.value = text; 4400 ta.style.position = 'fixed'; ta.style.left = '-9999px'; 4401 document.body.appendChild(ta); 4402 ta.select(); 4403 try { ok = document.execCommand('copy'); } catch { ok = false; } 4404 ta.remove(); 4405 } 4406 if (ok) { 4407 if (btnEl && !btnEl._copyFlashing) { 4408 const original = btnEl.innerHTML; 4409 btnEl._copyFlashing = true; 4410 btnEl.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 4411 btnEl.classList.add('copied'); 4412 setTimeout(() => { 4413 btnEl.innerHTML = original; 4414 btnEl.classList.remove('copied'); 4415 btnEl._copyFlashing = false; 4416 }, 1200); 4417 } 4418 uiModule.showToast?.('Copied'); 4419 } else { 4420 uiModule.showError?.('Copy failed'); 4421 } 4422 return ok; 4423} 4424 4425function _editNote(id) { 4426 const note = _notes.find(n => n.id === id); 4427 if (!note) return; 4428 _editingId = id; 4429 const card = document.querySelector(`.note-card[data-note-id="${id}"]`); 4430 if (!card) return; 4431 // Restore an unsaved draft (from a prior connection loss / close) over 4432 // the saved note so the user picks up where they left off. 4433 const { note: _n, restored } = _applyDraftToNote(note, id); 4434 const form = _buildForm(_n); 4435 card.replaceWith(form); 4436 if (restored) uiModule.showToast('Restored unsaved changes'); 4437 // Pinned notes live in the first masonry column â the edit form has 4438 // column-span:all, which can leave the form rendered above the fold or 4439 // visually buried under neighboring pinned cards. Bring it into view 4440 // (and onto a higher stacking context) so editing a pinned note always 4441 // pops to the top. 4442 form.style.position = 'relative'; 4443 form.style.zIndex = '5'; 4444 // Grid view: the form keeps the CSS default `grid-row-end: span 16` (64px) 4445 // after replaceWith, which is much shorter than the actual form. Recompute 4446 // masonry so the form gets the correct row span and cards below stop 4447 // overlapping it. ResizeObserver inside _applyMasonry keeps it in sync as 4448 // the user types / adds checklist items. 4449 const _body = form.closest('.notes-pane-body'); 4450 if (_body) { 4451 _applyMasonry(_body); 4452 requestAnimationFrame(() => _applyMasonry(_body)); 4453 } 4454 requestAnimationFrame(() => { 4455 try { form.scrollIntoView({ block: 'center', behavior: 'smooth' }); } 4456 catch { form.scrollIntoView(); } 4457 }); 4458 // Pick the most useful field to focus. On phones especially, the user 4459 // taps Edit to type â landing in the title when there's already a title 4460 // (and likely a body to extend) loses momentum. Prefer the body textarea 4461 // for plain notes, the first checklist item for todos, fall back to title. 4462 const _focusBest = () => { 4463 if (note.note_type === 'note' || !note.note_type) { 4464 const ta = form.querySelector('.note-form-content'); 4465 if (ta) { ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch {} return; } 4466 } 4467 if (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist') { 4468 // Last non-empty checklist row, or the first row if all empty. 4469 const rows = form.querySelectorAll('.note-cl-row .note-cl-text'); 4470 let target = null; 4471 for (const inp of rows) { if ((inp.value || '').trim()) target = inp; } 4472 target = target || rows[0]; 4473 if (target) { target.focus(); try { target.setSelectionRange(target.value.length, target.value.length); } catch {} return; } 4474 } 4475 const titleEl = form.querySelector('.note-form-title');
4476 if (titleEl) titleEl.focus(); 4477 }; 4478 _focusBest(); 4479} 4480 4481async function _deleteNote(id) { 4482 const ok = uiModule?.styledConfirm 4483 ? await uiModule.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) 4484 : confirm('Delete this note?'); 4485 if (!ok) return; 4486 try { await _deleteNoteApi(id); await _fetchNotes(); _renderNotes(); uiModule.showToast('Deleted'); } 4487 catch (err) { uiModule.showError(err.message); } 4488} 4489 4490// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4491// MOBILE NOTES UX â fullscreen tap-to-edit + long-press drag-to-reorder. 4492// On a touch device â¤768px wide, note tiles become read-only previews; 4493// a single tap opens the note in a full-bleed overlay (where all real 4494// editing happens), and a long-press flips the whole grid into 4495// rearrange mode where tiles can be dragged to a new sort_order. 4496// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4497 4498function _isNotesMobileMode() { 4499 return ('ontouchstart' in window) && window.innerWidth <= 768; 4500} 4501 4502// ââ Fullscreen single-note edit overlay ââââââââââââââââââââââââââââââ 4503let _mobileFsOverlay = null; 4504let _mobileFsNoteId = null; 4505 4506function _openMobileFullscreenEdit(id, fromCard) { 4507 const note = _notes.find(n => n.id === id); 4508 if (!note) return; 4509 // Tear down any previous overlay (defensive). 4510 _closeMobileFullscreenEdit({ save: false }); 4511 _mobileFsNoteId = id; 4512 _editingId = id; 4513 4514 const overlay = document.createElement('div'); 4515 overlay.className = 'note-fullscreen-overlay'; 4516 overlay.innerHTML = ` 4517 <div class="note-fullscreen-header"> 4518 <button type="button" class="note-fullscreen-back" title="Back"> 4519 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg> 4520 </button> 4521 <div class="note-fullscreen-actions"></div> 4522 </div> 4523 <div class="note-fullscreen-body"></div> 4524 `; 4525 const body = overlay.querySelector('.note-fullscreen-body'); 4526 // Reuse the same edit form the in-place flow builds. Save buttons, 4527 // checklist toggles, etc. all work as-is. Restore any unsaved draft. 4528 const { note: _n, restored } = _applyDraftToNote(note, id); 4529 const form = _buildForm(_n); 4530 body.appendChild(form); 4531 if (restored) uiModule.showToast('Restored unsaved changes'); 4532 document.body.appendChild(overlay); 4533 _mobileFsOverlay = overlay; 4534 4535 // Animate up from the tapped tile's position so the transition reads 4536 // as a zoom rather than a hard cut. 4537 if (fromCard) { 4538 const r = fromCard.getBoundingClientRect(); 4539 const vw = window.innerWidth, vh = window.innerHeight; 4540 overlay.style.transformOrigin = 4541 `${((r.left + r.width / 2) / vw) * 100}% ${((r.top + r.height / 2) / vh) * 100}%`; 4542 } 4543 overlay.classList.add('opening'); 4544 requestAnimationFrame(() => overlay.classList.add('open')); 4545 4546 // Wire the back button â saves whatever the form has and closes. 4547 // mousedown preventDefault so it doesn't blur the input on first tap (which 4548 // would eat the tap and require a second one). 4549 const _backBtn = overlay.querySelector('.note-fullscreen-back'); 4550 _backBtn.addEventListener('mousedown', (e) => e.preventDefault()); 4551 _backBtn.addEventListener('click', () => { 4552 _closeMobileFullscreenEdit({ save: true }); 4553 }); 4554 4555 // The form's built-in Cancel only resets the in-place edit state; in 4556 // the overlay context it does nothing visible. Replace its handler so 4557 // Cancel actually dismisses the overlay without saving. 4558 const cancelBtn = form.querySelector('.note-form-cancel'); 4559 if (cancelBtn) { 4560 const fresh = cancelBtn.cloneNode(true); 4561 cancelBtn.parentNode.replaceChild(fresh, cancelBtn); 4562 fresh.addEventListener('mousedown', (e) => e.preventDefault()); 4563 fresh.addEventListener('click', (e) => { 4564 e.preventDefault(); 4565 e.stopPropagation(); 4566 _closeMobileFullscreenEdit({ save: false }); 4567 }); 4568 } 4569 // The built-in Save handler does the API call + refresh, but doesn't 4570 // dismiss our overlay. Augment it (do NOT replace â the original is 4571 // async and we'd lose the API call) to schedule a close once the 4572 // save+render has had time to complete. 4573 const saveBtn = form.querySelector('.note-form-save'); 4574 if (saveBtn) { 4575 saveBtn.addEventListener('click', () => { 4576 setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 350); 4577 }); 4578 }
4579 // Make the checklist row drag handle (â®â®) actually work on touch. 4580 // The form's default uses HTML5 native draggable which never fires 4581 // on iOS/Android. Wire touch-based reorder for any row inside the 4582 // overlay's checklist. 4583 _wireChecklistTouchReorder(form); 4584 4585 // For each todo row whose text contains a URL, swap the bare <input> 4586 // for a linkified <span> so URLs are tappable. Tapping anywhere that 4587 // ISN'T a link flips back to the input for editing. 4588 form.querySelectorAll('.note-cl-row').forEach(_addRowReadMode); 4589 4590 // Move Archive + Delete from the form's footer actions row up into 4591 // the header bar (to the right of the back chevron) so they're 4592 // always reachable without scrolling and free up the footer for 4593 // Cancel/Save only. Handlers stay attached when nodes move. 4594 const headerActions = overlay.querySelector('.note-fullscreen-actions'); 4595 const archiveBtn = form.querySelector('.note-form-archive-btn'); 4596 const deleteBtn = form.querySelector('.note-form-delete-btn'); 4597 if (headerActions && archiveBtn) headerActions.appendChild(archiveBtn); 4598 if (headerActions && deleteBtn) headerActions.appendChild(deleteBtn); 4599 // The built-in archive/delete handlers re-render the notes grid but 4600 // leave THIS overlay sitting in front of it â looks like nothing 4601 // happened. Add follow-up listeners that close the overlay so the 4602 // user sees the action take effect. 4603 if (archiveBtn) { 4604 archiveBtn.addEventListener('click', () => { 4605 setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 200); 4606 }); 4607 } 4608 if (deleteBtn) { 4609 deleteBtn.addEventListener('click', () => { 4610 // Delete shows a styled confirm â give it room to resolve before 4611 // we try to dismiss the overlay. 4612 setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 500); 4613 }); 4614 } 4615 4616 // Tuck the tags input into the bottom actions row (Cancel / Update), 4617 // pinned to the LEFT. Frees the meta row of an extra wrapping line 4618 // and groups all the "exit" controls together. 4619 const actionsGroup = form.querySelector('.note-form-actions-group'); 4620 const tagsInput = form.querySelector('.note-form-label'); 4621 if (actionsGroup && tagsInput) { 4622 actionsGroup.insertBefore(tagsInput, actionsGroup.firstChild); 4623 } 4624 4625 // For checklist-type notes, move the photo (attach image) button into 4626 // the same row as the + Add button (right side) â keeps the meta row 4627 // tidy and puts the camera within thumb-reach of the active edit. 4628 const addBtn = form.querySelector('.note-cl-add'); 4629 const photoBtn = form.querySelector('.note-form-photo-btn'); 4630 if (addBtn && photoBtn) { 4631 const addRow = document.createElement('div'); 4632 addRow.className = 'note-cl-add-row'; 4633 addBtn.parentNode.insertBefore(addRow, addBtn); 4634 addRow.appendChild(addBtn); 4635 addRow.appendChild(photoBtn); 4636 // Tapping anywhere on the row (the empty gap, the dashed border, 4637 // the "+ Add" label) triggers add. The photo button keeps its own 4638 // click target so attach-image isn't ambushed. 4639 addRow.addEventListener('click', (e) => { 4640 if (e.target.closest('.note-form-photo-btn')) return; 4641 if (e.target === addBtn || addBtn.contains(e.target)) return; 4642 addBtn.click(); 4643 }); 4644 // The form's delegated "+ Add" handler does 4645 // inputs.insertBefore(newRow, addBtn) 4646 // â but addBtn is no longer a direct child of `.note-checklist-inputs` 4647 // now that we wrapped it. Bind a fresh handler that does the same 4648 // thing but inserts before the WRAPPING row, and stop propagation 4649 // so the broken delegate never runs. 4650 addBtn.addEventListener('click', (e) => { 4651 e.preventDefault(); 4652 e.stopPropagation(); 4653 const inputs = form.querySelector('.note-checklist-inputs'); 4654 if (!inputs) return; 4655 const newRow = document.createElement('div'); 4656 newRow.className = 'note-cl-row'; 4657 newRow.draggable = true; 4658 newRow.dataset.itemId = _uid(); 4659 newRow.dataset.indent = '0'; 4660 newRow.innerHTML = '<span class="note-cl-grip" title="Drag">â®â®</span><span class="note-cl-dot"></span><input type="text" class="note-cl-text" placeholder="Item..." /><button type="button" class="note-cl-rm">×</button>'; 4661 inputs.insertBefore(newRow, addRow); 4662 _wireRow(newRow, inputs); 4663 // Touch reorder on the freshly-added row's grip. 4664 _wireChecklistTouchReorder(form); 4665 newRow.querySelector('.note-cl-text')?.focus(); 4666 }, { capture: true }); 4667 } 4668
4669 // Read-mode overlay for plain notes: render the content as a div with 4670 // clickable hyperlinks, layered above the textarea. Tapping anywhere 4671 // in the overlay that ISN'T a link hides the overlay and focuses the 4672 // textarea so the user can start editing. Tapping a link opens it. 4673 const ta = form.querySelector('.note-form-content'); 4674 if (ta && (note.content || '').trim()) { 4675 const reader = document.createElement('div'); 4676 reader.className = 'note-form-content-reader'; 4677 reader.innerHTML = _linkify(note.content || ''); 4678 ta.style.display = 'none'; 4679 ta.insertAdjacentElement('beforebegin', reader); 4680 reader.addEventListener('click', (e) => { 4681 if (e.target.closest('a')) return; // let links open normally 4682 reader.remove(); 4683 ta.style.display = ''; 4684 // Let the browser place the cursor naturally â forcing 4685 // setSelectionRange right after focus() raced with the underlying 4686 // tap event and produced inconsistent cursor positions on mobile. 4687 ta.focus({ preventScroll: true }); 4688 }); 4689 } 4690
4691 // Opening an EXISTING note â read mode, no keyboard pop. Only a 4692 // brand-new note (created via the + button) should auto-focus an 4693 // input field. The user can tap the content to switch to edit. 4694 // (New-note creation flows through _createNote, not this function.) 4695} 4696 4697function _closeMobileFullscreenEdit(opts = {}) { 4698 if (!_mobileFsOverlay) return; 4699 const overlay = _mobileFsOverlay; 4700 _mobileFsOverlay = null; 4701 // If the form has a Save button, click it on close so edits aren't lost 4702 // when the user uses the back arrow instead of an explicit Save. 4703 if (opts.save) { 4704 const saveBtn = overlay.querySelector('.note-form-save, [data-action="save"]'); 4705 if (saveBtn) try { saveBtn.click(); } catch {} 4706 } 4707 overlay.classList.remove('open'); 4708 overlay.classList.add('closing'); 4709 setTimeout(() => { 4710 if (overlay.parentNode) overlay.parentNode.removeChild(overlay); 4711 _mobileFsNoteId = null; 4712 _editingId = null; 4713 // Refresh the grid so any save the user made is reflected. 4714 if (opts.save !== false) { _fetchNotes().then(_renderNotes).catch(() => {}); } 4715 }, 220); 4716} 4717 4718// ââ Long-press drag-to-reorder âââââââââââââââââââââââââââââââââââââââ 4719function _bindLongPressDrag(card) { 4720 let pressTimer = null; 4721 let startX = 0, startY = 0; 4722 let armed = false; 4723 const CANCEL_PX = 8; 4724 const HOLD_MS = 450; 4725 4726 card.addEventListener('touchstart', (e) => { 4727 // Don't fight scroll on touchpoints over real interactive children 4728 // (in mobile-mode they're CSS-hidden anyway, but be defensive). 4729 if (e.target.closest('button, input, a, .note-form')) return; 4730 if (e.touches.length !== 1) return; 4731 armed = true; 4732 startX = e.touches[0].clientX; 4733 startY = e.touches[0].clientY; 4734 // Capture the touch object so the timer callback can pass it to 4735 // _enterDragMode â _beginGrab. The finger is still held down, so 4736 // the drag starts the instant the timer fires. 4737 const heldTouch = { clientX: startX, clientY: startY }; 4738 pressTimer = setTimeout(() => { 4739 if (!armed) return; 4740 try { navigator.vibrate?.(15); } catch {} 4741 _enterDragMode(card, heldTouch); 4742 }, HOLD_MS); 4743 }, { passive: true }); 4744 card.addEventListener('touchmove', (e) => { 4745 if (!armed) return; 4746 const t = e.touches[0]; 4747 if (Math.abs(t.clientX - startX) > CANCEL_PX || Math.abs(t.clientY - startY) > CANCEL_PX) { 4748 armed = false; 4749 if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } 4750 } 4751 }, { passive: true }); 4752 const cancel = () => { 4753 armed = false; 4754 if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } 4755 }; 4756 card.addEventListener('touchend', cancel, { passive: true }); 4757 card.addEventListener('touchcancel', cancel, { passive: true }); 4758} 4759 4760// Lift-and-placeholder drag implementation. The dragged card detaches 4761// from the grid (position:fixed, anchored to the finger) while a same- 4762// sized placeholder takes its slot. Only the PLACEHOLDER moves between 4763// siblings as the finger crosses midpoints â the card never re-parents 4764// during the drag, which eliminates the oscillation/jumping the 4765// previous swap-on-every-frame implementation had. 4766 4767let _dragState = null; // { card, placeholder, grabOffsetX, grabOffsetY, grid, prevStyle } 4768let _docDragHandlersBound = false; 4769 4770function _enterDragMode(initialCard, initialTouch) { 4771 document.body.classList.add('notes-drag-mode'); 4772 document.querySelectorAll('.note-card').forEach(_setupDragForCard); 4773 if (!_docDragHandlersBound) { 4774 document.addEventListener('touchmove', _onDocTouchMove, { passive: false }); 4775 document.addEventListener('touchend', _onDocTouchEnd, { passive: true }); 4776 document.addEventListener('touchcancel', _onDocTouchEnd, { passive: true }); 4777 _docDragHandlersBound = true; 4778 } 4779 // Auto-grab the card the user long-pressed â they're already holding 4780 // their finger on it, so begin the drag straight away. Releasing 4781 // (touchend) commits the reorder AND exits drag mode in one motion. 4782 if (initialCard && initialTouch) { 4783 _beginGrab(initialCard, initialTouch); 4784 } 4785} 4786 4787function _exitDragMode() { 4788 document.body.classList.remove('notes-drag-mode'); 4789 if (_dragState) { 4790 // Defensive: if exit fires while a drag is in flight, snap the card back. 4791 _onDocTouchEnd(); 4792 } 4793 // Leave _docDragHandlersBound true so re-entering drag mode reuses them. 4794} 4795 4796function _setupDragForCard(card) { 4797 if (card.dataset.dragBound === '1') return; 4798 card.dataset.dragBound = '1'; 4799 card.addEventListener('touchstart', (e) => { 4800 if (!document.body.classList.contains('notes-drag-mode')) return; 4801 if (e.touches.length !== 1) return; 4802 if (_dragState) return; 4803 e.preventDefault(); 4804 e.stopPropagation(); 4805 _beginGrab(card, e.touches[0]); 4806 }, { passive: false }); 4807} 4808 4809function _beginGrab(card, touch) { 4810 const rect = card.getBoundingClientRect(); 4811 const prevStyle = card.getAttribute('style') || ''; 4812 // Placeholder fills the card's old slot so the grid layout doesn't reflow. 4813 const placeholder = document.createElement('div'); 4814 placeholder.className = 'note-card-placeholder'; 4815 placeholder.style.width = rect.width + 'px'; 4816 placeholder.style.height = rect.height + 'px'; 4817 placeholder.style.margin = getComputedStyle(card).margin; 4818 if (card.style.gridRowEnd) placeholder.style.gridRowEnd = card.style.gridRowEnd; 4819 const grid = card.parentNode; 4820 grid.insertBefore(placeholder, card); 4821 4822 // Detach the card visually â fixed-position, anchored to the finger. 4823 card.classList.add('note-card-dragging'); 4824 card.style.position = 'fixed'; 4825 card.style.left = rect.left + 'px'; 4826 card.style.top = rect.top + 'px'; 4827 card.style.width = rect.width + 'px'; 4828 card.style.height = rect.height + 'px'; 4829 card.style.margin = '0'; 4830 card.style.zIndex = '10001'; 4831 // pointer-events:none so elementFromPoint sees the card BENEATH the finger 4832 card.style.pointerEvents = 'none'; 4833 4834 _dragState = { 4835 card, placeholder, grid, prevStyle, 4836 grabOffsetX: touch.clientX - rect.left, 4837 grabOffsetY: touch.clientY - rect.top, 4838 }; 4839 try { navigator.vibrate?.(8); } catch {} 4840} 4841 4842function _onDocTouchMove(e) { 4843 if (!_dragState) return; 4844 if (e.touches.length !== 1) return; 4845 e.preventDefault(); 4846 const touch = e.touches[0]; 4847 const { card, placeholder, grid } = _dragState; 4848 card.style.left = (touch.clientX - _dragState.grabOffsetX) + 'px'; 4849 const quickAdd = grid.querySelector('.notes-quick-add'); 4850 const minTop = quickAdd ? quickAdd.getBoundingClientRect().bottom + 4 : grid.getBoundingClientRect().top; 4851 const maxTop = Math.max(minTop, window.innerHeight - card.getBoundingClientRect().height - 8); 4852 const nextTop = Math.max(minTop, Math.min(maxTop, touch.clientY - _dragState.grabOffsetY)); 4853 card.style.top = nextTop + 'px'; 4854 4855 const hitY = Math.max(minTop + 1, Math.min(window.innerHeight - 1, touch.clientY)); 4856 const under = document.elementFromPoint(touch.clientX, hitY); 4857 const target = under && under.closest 4858 ? under.closest('.note-card:not(.note-card-dragging)') 4859 : null; 4860 if (!target || target === card) return; 4861 if (target.parentNode !== grid) return; 4862 4863 // Move the PLACEHOLDER (not the card) above or below the target depending 4864 // on which half of the target the finger is in. This is the hysteresis 4865 // that stops the oscillation â once the placeholder moves past a card, 4866 // the cursor has to cross THAT card's midpoint in the other direction 4867 // to swap back. 4868 const tRect = target.getBoundingClientRect(); 4869 const targetMidY = tRect.top + tRect.height / 2; 4870 if (touch.clientY < targetMidY) { 4871 if (placeholder.nextElementSibling !== target) { 4872 grid.insertBefore(placeholder, target); 4873 } 4874 } else { 4875 if (target.nextElementSibling !== placeholder) { 4876 grid.insertBefore(placeholder, target.nextElementSibling); 4877 } 4878 } 4879} 4880 4881function _onDocTouchEnd() { 4882 if (!_dragState) return; 4883 const { card, placeholder, grid, prevStyle } = _dragState; 4884 _dragState = null; 4885 // Animate the card from its current fixed position to where the 4886 // placeholder sits, then re-parent and clear inline styles. Drag 4887 // mode auto-exits once the snap finishes â release = done. 4888 const phRect = placeholder.getBoundingClientRect(); 4889 card.style.transition = 'left 0.2s ease, top 0.2s ease'; 4890 card.style.left = phRect.left + 'px'; 4891 card.style.top = phRect.top + 'px'; 4892 setTimeout(() => { 4893 placeholder.parentNode.insertBefore(card, placeholder); 4894 placeholder.remove(); 4895 card.classList.remove('note-card-dragging'); 4896 // Restore the card's pre-drag inline styles. Mobile masonry stores 4897 // grid-row-end inline, and custom backgrounds use inline style too; wiping 4898 // cssText made dropped cards collapse into neighboring notes in grid view. 4899 if (prevStyle) card.setAttribute('style', prevStyle); 4900 else card.removeAttribute('style'); 4901 _applyMasonry(grid); 4902 _commitNoteReorder(); 4903 // One drag, one exit â release ends the rearrange session entirely. 4904 if (document.body.classList.contains('notes-drag-mode')) { 4905 document.body.classList.remove('notes-drag-mode'); 4906 } 4907 }, 210); 4908} 4909 4910// Per-row read mode for todo items â replaces the plain <input> with 4911// a linkified <span> when the value contains a URL, so tapping a link 4912// opens it instead of just placing the caret. Tapping non-link area 4913// restores the input for editing. 4914function _addRowReadMode(row) { 4915 const txt = row.querySelector('.note-cl-text'); 4916 if (!txt) return; 4917 const val = txt.value || ''; 4918 if (!/(https?:\/\/|www\.)/i.test(val)) return; 4919 if (row.querySelector('.note-cl-text-reader')) return; // already wired 4920 const span = document.createElement('span'); 4921 span.className = 'note-cl-text-reader'; 4922 span.innerHTML = _linkify(val); 4923 txt.style.display = 'none'; 4924 txt.insertAdjacentElement('beforebegin', span); 4925 span.addEventListener('click', (e) => { 4926 if (e.target.closest('a')) return; // let the link open 4927 span.remove(); 4928 txt.style.display = ''; 4929 txt.focus({ preventScroll: true }); 4930 }); 4931} 4932 4933// ââ Checklist row reorder via touch (inside fullscreen edit) ââââââââ 4934// The default checklist drag uses HTML5 `draggable="true"`, which is 4935// desktop-mouse-only. Wire touch handlers on each `.note-cl-grip` so 4936// the user can long-grab a row and drag it to a new position in the 4937// checklist. Uses the same lift-and-placeholder pattern as the card 4938// drag (no oscillation when hovering between siblings). 4939 4940let _clDrag = null; // { row, placeholder, container, grabOffsetX, grabOffsetY } 4941let _clDocBound = false;
4942 4943function _wireChecklistTouchReorder(form) { 4944 const container = form.querySelector('.note-checklist-inputs'); 4945 if (!container) return; 4946 container.querySelectorAll('.note-cl-grip').forEach(grip => { 4947 if (grip.dataset.touchBound === '1') return; 4948 grip.dataset.touchBound = '1'; 4949 grip.addEventListener('touchstart', (e) => { 4950 if (e.touches.length !== 1) return; 4951 e.preventDefault(); 4952 const row = grip.closest('.note-cl-row'); 4953 if (!row) return; 4954 _beginChecklistGrab(row, container, e.touches[0]); 4955 }, { passive: false }); 4956 }); 4957 if (!_clDocBound) { 4958 document.addEventListener('touchmove', _onClTouchMove, { passive: false }); 4959 document.addEventListener('touchend', _onClTouchEnd, { passive: true }); 4960 document.addEventListener('touchcancel', _onClTouchEnd, { passive: true }); 4961 _clDocBound = true; 4962 } 4963} 4964 4965function _beginChecklistGrab(row, container, touch) { 4966 if (_clDrag) return; 4967 const rect = row.getBoundingClientRect(); 4968 const placeholder = document.createElement('div'); 4969 placeholder.className = 'note-cl-row-placeholder'; 4970 placeholder.style.height = rect.height + 'px'; 4971 container.insertBefore(placeholder, row); 4972 4973 row.classList.add('note-cl-row-dragging'); 4974 row.style.position = 'fixed'; 4975 row.style.left = rect.left + 'px'; 4976 row.style.top = rect.top + 'px'; 4977 row.style.width = rect.width + 'px'; 4978 row.style.zIndex = '10002'; 4979 row.style.pointerEvents = 'none'; 4980 4981 _clDrag = { 4982 row, placeholder, container, 4983 grabOffsetX: touch.clientX - rect.left, 4984 grabOffsetY: touch.clientY - rect.top, 4985 }; 4986 try { navigator.vibrate?.(8); } catch {} 4987} 4988 4989function _onClTouchMove(e) { 4990 if (!_clDrag) return; 4991 if (e.touches.length !== 1) return; 4992 e.preventDefault(); 4993 const t = e.touches[0]; 4994 const { row, placeholder, container } = _clDrag; 4995 row.style.left = (t.clientX - _clDrag.grabOffsetX) + 'px'; 4996 row.style.top = (t.clientY - _clDrag.grabOffsetY) + 'px'; 4997 4998 const under = document.elementFromPoint(t.clientX, t.clientY); 4999 const target = under && under.closest 5000 ? under.closest('.note-cl-row:not(.note-cl-row-dragging)') 5001 : null; 5002 if (!target || target === row) return; 5003 if (target.parentNode !== container) return; 5004 5005 const tRect = target.getBoundingClientRect(); 5006 const targetMidY = tRect.top + tRect.height / 2; 5007 if (t.clientY < targetMidY) { 5008 if (placeholder.nextElementSibling !== target) { 5009 container.insertBefore(placeholder, target); 5010 } 5011 } else { 5012 if (target.nextElementSibling !== placeholder) { 5013 container.insertBefore(placeholder, target.nextElementSibling); 5014 } 5015 } 5016} 5017 5018function _onClTouchEnd() { 5019 if (!_clDrag) return; 5020 const { row, placeholder } = _clDrag; 5021 _clDrag = null; 5022 const phRect = placeholder.getBoundingClientRect(); 5023 row.style.transition = 'left 0.18s ease, top 0.18s ease'; 5024 row.style.left = phRect.left + 'px'; 5025 row.style.top = phRect.top + 'px'; 5026 setTimeout(() => { 5027 placeholder.parentNode.insertBefore(row, placeholder); 5028 placeholder.remove(); 5029 row.classList.remove('note-cl-row-dragging'); 5030 row.style.cssText = ''; 5031 // Order is persisted as part of the form's normal save (rows are 5032 // re-serialized in DOM order via _collectItems). 5033 }, 200); 5034} 5035 5036async function _commitNoteReorder() { 5037 const grid = document.querySelector('#notes-pane .notes-pane-body'); 5038 if (!grid) return; 5039 const ids = Array.from(grid.querySelectorAll('.note-card')).map(c => c.dataset.noteId).filter(Boolean); 5040 if (!ids.length) return; 5041 try { 5042 await fetch(`${API_BASE}/api/notes/reorder`, { 5043 method: 'POST', 5044 credentials: 'same-origin', 5045 headers: { 'Content-Type': 'application/json' }, 5046 body: JSON.stringify({ ids }), 5047 }); 5048 // Update local sort_order so subsequent renders agree with the server. 5049 ids.forEach((nid, i) => { 5050 const n = _notes.find(nn => nn.id === nid); 5051 if (n) n.sort_order = i; 5052 }); 5053 } catch (e) { 5054 console.warn('reorder failed', e); 5055 } 5056} 5057 5058 5059// Background reminder loop â runs whether panel is open or not 5060async function _initReminders() { 5061 try { 5062 const res = await fetch(`${API_BASE}/api/notes`, { credentials: 'same-origin' }); 5063 if (res.ok) { 5064 const data = await res.json(); 5065 _notes = data.notes || data || []; 5066 _startReminderLoop(); 5067 } 5068 } catch {} 5069} 5070 5071// Open the notes panel and scroll/flash the matching note card. Used 5072// by chatRenderer.js when the user clicks a [View note](#note-<id>) 5073// link the agent emits after a manage_notes create. Falls back to 5074// just opening the panel when the card isn't found (panel still
5075// loading, note in a different filter, etc.). 5076async function openNote(noteId) { 5077 // If the panel is already open, openPanel() short-circuits and does 5078 // nothing â including no re-fetch â so a freshly-created note added 5079 // server-side never shows up. Force a refresh by closing first when 5080 // open, then re-opening. Clicking the sidebar Notes button as a 5081 // last resort keeps this working even if the module state got out 5082 // of sync (rare but seen during HMR or after a stuck modal). 5083 try { 5084 if (isPanelOpen && isPanelOpen()) { 5085 closePanel(); 5086 // give the close animation a frame to settle 5087 await new Promise(r => setTimeout(r, 30)); 5088 } 5089 } catch (_) {} 5090 openPanel(); 5091 // openPanel() kicks off _fetchNotes() asynchronously, so the cards 5092 // for newly-created notes may not be in the DOM yet. Also poll the 5093 // _notes module array directly â if the note IS loaded but the 5094 // active filter (e.g. archive view) is hiding it, we can still 5095 // surface a confirmation toast. 5096 if (!noteId) return; 5097 let tries = 0; 5098 const findAndFlash = () => { 5099 const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`) 5100 || document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`); 5101 if (card) { 5102 try { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {} 5103 card.classList.add('note-card-flash'); 5104 setTimeout(() => card.classList.remove('note-card-flash'), 1600); 5105 return true; 5106 } 5107 return false; 5108 }; 5109 const tryNext = () => { 5110 if (findAndFlash()) return; 5111 if (++tries < 20) setTimeout(tryNext, 200); 5112 }; 5113 setTimeout(tryNext, 120); 5114} 5115 5116const notesModule = { openPanel, closePanel, togglePanel, isPanelOpen, openNote, openNotes: openPanel, closeNotes: closePanel, isNotesOpen: isPanelOpen, refreshDueBadge }; 5117export default notesModule; 5118export { openPanel as openNotes, closePanel as closeNotes, isPanelOpen as isNotesOpen, openNote }; 5119window.notesModule = notesModule; 5120 5121// Start reminder loop on module load (after a short delay so app loads first) 5122if (typeof window !== 'undefined') { 5123 setTimeout(_initReminders, 3000); 5124}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.