/** Panel mounting, keyboard handlers, minimization and note navigation. */ export function createPanel({ wireToolbar, wireSelectionActions, _wireNotesWindow, _restoreNotesSidebarDock, _bringNotesToFront, disposeUI, lifecycle, Modals, NOTES_FIRST_OPEN_HINT_KEY, REMINDER_DISMISSED_AT_KEY, _clearViewedReminderGlows, _closeMobileFullscreenEdit, _commitOpenInPlaceEditor, _copyNote, _enterSelectMode, _exitSelectMode, _fetchNotes, _flushPendingHighlights, _isNotesMobileMode, _popAndRunUndo, _renderLoadingSkeleton, _renderNotes, _startReminderLoop, app, bindTourHintDismiss, dismissOrRemove, persistence, state, workflows }) { function _forceCloseNotesPanel() { app.setOpen(false); app.setEditingId(null); try { _commitOpenInPlaceEditor(); } catch {} try { _closeMobileFullscreenEdit({ save: true }); } catch {} try { _clearViewedReminderGlows(); } catch {} if (_notesKeydownHandler) { document.removeEventListener('keydown', _notesKeydownHandler); _notesKeydownHandler = null; } if (_notesSelectEscHandler) { document.removeEventListener('keydown', _notesSelectEscHandler, true); _notesSelectEscHandler = null; } disposeOwnedPortals(); disposeUI(); document.body.classList.remove('notes-view', 'notes-mobile-mode', 'notes-drag-mode'); document.getElementById('tool-notes-btn')?.classList.remove('active'); try { Modals.unregister('notes-panel'); } catch {} try { document.getElementById('notes-pane')?.remove(); } catch {} try { document.getElementById('notes-pane-backdrop')?.remove(); } catch {} try { window._restoreSidebarIfRouteCollapsed?.(); } catch {} } function _showNotesFirstOpenHint(pane) { if (!pane || typeof persistence === 'undefined') return; try { if (persistence.getItem(NOTES_FIRST_OPEN_HINT_KEY)) return; persistence.setItem(NOTES_FIRST_OPEN_HINT_KEY, '1'); } catch { return; } dismissOrRemove(document.getElementById('notes-first-open-hint')); const hint = document.createElement('div'); hint.id = 'notes-first-open-hint'; hint.className = 'tour-hint'; hint.innerHTML = `
Notes is your basic todo list, and also where reminders are managed.
`; document.body.appendChild(hint); const place = () => { const r = pane.getBoundingClientRect(); const hw = hint.offsetWidth || 260; hint.style.top = Math.max(12, r.top + 58) + 'px'; hint.style.left = Math.min(window.innerWidth - hw - 12, Math.max(12, r.left + 18)) + 'px'; }; requestAnimationFrame(() => { place(); hint.classList.add('tour-hint-in'); }); lifecycle.listen(hint, window, 'resize', place); bindTourHintDismiss(hint, { timeout: 6500, fade: 180, onDismiss: () => window.removeEventListener('resize', place), }); } function _ensureNotesChipRegistered() { if (Modals.isRegistered('notes-panel')) return; Modals.register('notes-panel', { railBtnId: 'rail-notes', sidebarBtnId: 'tool-notes-btn', restoreFn: () => { openPanel(); }, closeFn: () => { _forceCloseNotesPanel(); }, }); } function openPanel() { if (state.open) { _bringNotesToFront(); return; } // Notes and the document editor occupy the same primary workspace. Preserve // an open document as a dock chip instead of allowing the two panes to stack // into a broken layout (or closing the user's document outright). try { if (window.documentModule?.isPanelOpen?.()) { window.documentModule.closePanel('down'); } } catch (_) {} app.setOpen(true); app.setEditingId(null); app.setSearchQuery(''); _clearViewedReminderGlows(); app.setFiredDotDismissedAt(Date.now()); try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {} const container = document.getElementById('chat-container'); if (!container) return; document.body.classList.add('notes-view'); // On mobile the notes panel takes the whole screen : auto-close the // sidebar so the panel isn't cropped underneath it. if (window.innerWidth <= 768) { const sb = document.getElementById('sidebar'); if (sb) sb.classList.add('hidden'); document.body.classList.add('sidebar-collapsed'); } // Mobile mode: tiles become read-only previews (no inline checkbox / // edit / archive / etc.), tap opens a fullscreen edit overlay, // long-press enters drag-to-reorder mode. See _bindCardEvents + // .notes-mobile-mode CSS rules. if (_isNotesMobileMode()) document.body.classList.add('notes-mobile-mode'); // Toggle button state const btn = document.getElementById('tool-notes-btn'); if (btn) btn.classList.add('active'); // Create panel const pane = document.createElement('div'); pane.id = 'notes-pane'; pane.className = 'notes-pane'; pane.innerHTML = `

Notes

`; // On mobile open as a full-screen bottom sheet (slide up), not the desktop // side panel. Set inline so it wins over the base .notes-pane rule regardless // of cascade specifics (the CSS @media override wasn't reliably applying, // which left it as a side panel squeezing the chat). if (window.innerWidth <= 768) { pane.style.position = 'fixed'; pane.style.inset = '0'; pane.style.width = '100%'; pane.style.maxWidth = '100%'; pane.style.zIndex = '170'; pane.style.borderRadius = '14px 14px 0 0'; pane.style.animation = 'sheet-enter 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both'; pane.style.transformOrigin = 'bottom center'; } // Mount on body so Notes can behave like the other draggable windows. On // desktop it is immediately docked to the right by _restoreNotesSidebarDock. const backdrop = document.createElement('div'); backdrop.className = 'notes-pane-backdrop'; backdrop.id = 'notes-pane-backdrop'; backdrop.addEventListener('click', (ev) => { if (ev.target === backdrop) closePanel('down'); }); backdrop.appendChild(pane); document.body.appendChild(backdrop); _wireNotesWindow(pane); _restoreNotesSidebarDock(pane); _bringNotesToFront(pane); // Events // (Close chevron removed : swipe down on mobile, tool-rail toggle on desktop.) // Mobile: swipe the grab handle / header down to dismiss (minimise to chip). // Mirrors the document sheet gesture : finger-following, velocity-based // dismiss, rubber-band on up-drag, spring snap-back. _wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane); _wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane); pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true); pane.addEventListener('focusin', () => _bringNotesToFront(pane), true); const minBtn = document.getElementById('notes-minimize-btn'); if (minBtn) minBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); closePanel('down'); }); wireToolbar(pane); // Select mode document.getElementById('notes-select-btn').addEventListener('click', () => { if (state.selectMode) _exitSelectMode(); else _enterSelectMode(); }); // Esc cancels select mode. Notes uses a toggle "Select" button rather // than a *-bulk-cancel button, so the global Esc-cancel handler in // keyboard-shortcuts.js can't reach it : handle it here. Capture phase // + stopPropagation so Esc cancels select instead of closing the panel. if (_notesSelectEscHandler) { document.removeEventListener('keydown', _notesSelectEscHandler, true); } _notesSelectEscHandler = (e) => { if (e.key === 'Escape' && state.selectMode) { e.preventDefault(); e.stopPropagation(); _exitSelectMode(); } }; document.addEventListener('keydown', _notesSelectEscHandler, true); wireSelectionActions(); // Escape: exit select mode first (if active), otherwise close the panel. // Skip when the user is editing a form field : those have their own // ESC-to-cancel handlers and we don't want to nuke the whole panel // mid-edit. // Idempotent: remove any previous handler from a prior openPanel so // re-opening doesn't stack multiple handlers. if (_notesKeydownHandler) { document.removeEventListener('keydown', _notesKeydownHandler); _notesKeydownHandler = null; } _notesKeydownHandler = (e) => { if (!state.open) return; const t = e.target; const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable); // Ctrl/Cmd+Z anywhere in the panel : undo the last note action. Skip when // typing in a field so the browser's normal text-undo still works. if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) { if (inField) return; if (workflows.undoCount() === 0) return; e.preventDefault(); _popAndRunUndo(); return; } // Ctrl/Cmd+C while hovering a note card → copy that note. Skip when the // user is editing or has an active text selection (let the browser handle // a real text copy in that case). if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !e.shiftKey && !e.altKey) { if (inField) return; const sel = window.getSelection?.(); if (sel && sel.toString && sel.toString().length > 0) return; const hovered = document.querySelector('.note-card:hover'); if (!hovered) return; const id = hovered.dataset.noteId; if (!id) return; e.preventDefault(); // Flash the ⋯ menu button (copy now lives in that menu). const btn = hovered.querySelector('.note-card-corner-menu'); _copyNote(id, btn); return; } if (e.key !== 'Escape') return; if (inField) return; if (state.selectMode) { _exitSelectMode(); return; } if (state.showingArchived) { // Mirror the archive toggle button: flip back to active notes. document.getElementById('notes-archive-toggle')?.click(); return; } _forceCloseNotesPanel(); }; document.addEventListener('keydown', _notesKeydownHandler); // Load : show skeleton immediately, then fetch _renderLoadingSkeleton(); // Defer the highlight flush to the next frame so it runs *after* the cards // are committed to the DOM (and any FLIP animations have settled), giving // the querySelector lookups inside something to find. _fetchNotes().then(() => { if (!pane.isConnected || !state.open) { _startReminderLoop(); return; } _renderNotes(); requestAnimationFrame(() => _flushPendingHighlights()); _startReminderLoop(); _showNotesFirstOpenHint(pane); }); } function closePanel(direction) { if (!state.open) return; app.setOpen(false); app.setEditingId(null); _clearViewedReminderGlows(); const _minimize = direction === 'down'; if (_minimize) { _ensureNotesChipRegistered(); } else if (Modals.isRegistered('notes-panel')) { Modals.unregister('notes-panel'); } // Drop panel-owned keydown listeners. The reminder interval is page-owned. if (_notesKeydownHandler) { document.removeEventListener('keydown', _notesKeydownHandler); _notesKeydownHandler = null; } if (_notesSelectEscHandler) { document.removeEventListener('keydown', _notesSelectEscHandler, true); _notesSelectEscHandler = null; } disposeOwnedPortals(); disposeUI(); document.body.classList.remove('notes-view'); document.body.classList.remove('notes-mobile-mode'); document.body.classList.remove('notes-drag-mode'); // Closing the panel should PRESERVE in-progress edits, not drop them. // Commit any open in-place editor, and close the mobile fullscreen // overlay with save=true so the note is persisted. try { _commitOpenInPlaceEditor(); } catch {} _closeMobileFullscreenEdit({ save: true }); // /notes route may have collapsed the wide sidebar to a rail; restore. try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} const btn = document.getElementById('tool-notes-btn'); if (btn) btn.classList.remove('active'); const pane = document.getElementById('notes-pane'); const backdrop = document.getElementById('notes-pane-backdrop'); if (pane) { // Scale-out + fade. Match the enter animation duration so close feels // like the same gesture played backwards. pane.classList.add('notes-pane-leaving'); const _cleanup = () => { try { pane.remove(); } catch {} try { backdrop?.remove(); } catch {} }; pane.addEventListener('animationend', _cleanup, { once: true }); // Belt-and-braces: if animation is skipped (reduced motion / detached // tab) the listener won't fire; remove after the expected duration. setTimeout(_cleanup, 220); } else if (backdrop) { backdrop.remove(); } // Show the dock chip for a swipe-down minimize (tap it to reopen). if (_minimize) { try { Modals.minimize('notes-panel'); } catch {} } } function togglePanel() { if (state.open) closePanel(); else openPanel(); } function isPanelOpen() { return state.open; } async function openNote(noteId) { const wasOpen = !!(isPanelOpen && isPanelOpen()); app.setShowingArchived(false); app.setActiveLabel(null); app.setActiveFilter(null); app.setSearchQuery(''); if (!wasOpen) { openPanel(); } else { _bringNotesToFront(); const searchEl = document.getElementById('notes-search'); if (searchEl) searchEl.value = ''; const pane = document.getElementById('notes-pane'); if (pane) pane.classList.remove('notes-pane-archive'); const archiveBtn = document.getElementById('notes-archive-toggle'); if (archiveBtn) archiveBtn.classList.remove('active'); await _fetchNotes(); _renderNotes(); } // openPanel() kicks off _fetchNotes() asynchronously, so the cards for // newly-created notes may not be in the DOM yet. Poll until the card exists. if (!noteId) return; let tries = 0; const findAndFlash = () => { const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`) || document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`); if (card) { _smoothScrollToNoteCard(card); card.classList.add('note-card-flash'); setTimeout(() => card.classList.remove('note-card-flash'), 1600); return true; } return false; }; const tryNext = () => { if (findAndFlash()) return; if (++tries < 20) setTimeout(tryNext, 200); }; setTimeout(tryNext, 120); } let _notesKeydownHandler = null; let _notesSelectEscHandler = null; function _wireNotesSwipeDismiss(el, pane) { if (!el || !pane) return; const DISMISS_THRESHOLD = 50, VELOCITY_THRESHOLD = 0.3, RUBBER = 0.35; let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0; let dragging = false, cancelled = false; el.addEventListener('touchstart', (e) => { if (window.innerWidth > 768 || e.touches.length !== 1) return; if (e.target.closest('button, input, select, label, textarea')) return; const t = e.touches[0]; startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp; velocity = 0; dragging = false; cancelled = false; }, { passive: true }); el.addEventListener('touchmove', (e) => { if (cancelled || window.innerWidth > 768) return; const t = e.touches[0]; const dx = Math.abs(t.clientX - startX); const dy = t.clientY - startY; if (!dragging) { if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } if (Math.abs(dy) > 8) { dragging = true; pane.style.animation = 'none'; pane.style.transition = 'none'; pane.style.willChange = 'transform'; } else return; } const dt = e.timeStamp - lastT; if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4; lastY = t.clientY; lastT = e.timeStamp; e.preventDefault(); pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER}px)`; }, { passive: false }); const endSwipe = () => { if (!dragging) return; dragging = false; pane.style.willChange = ''; const dy = lastY - startY; if (dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD)) { // Slide fully off-screen, then minimise. Keep it translated down (don't // reset) so it doesn't flash back before closePanel removes it. pane.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.4, 1)'; pane.style.transform = 'translateY(100%)'; setTimeout(() => closePanel('down'), 200); } else { pane.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; pane.style.transform = ''; setTimeout(() => { pane.style.transition = ''; }, 260); } }; el.addEventListener('touchend', endSwipe, { passive: true }); el.addEventListener('touchcancel', endSwipe, { passive: true }); } function _smoothScrollToNoteCard(card) { if (!card) return; const scroller = card.closest('.notes-pane-body'); if (!scroller) { try { card.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {} return; } const scrollerRect = scroller.getBoundingClientRect(); const cardRect = card.getBoundingClientRect(); const delta = (cardRect.top - scrollerRect.top) - (scroller.clientHeight - cardRect.height) / 2; scroller.scrollTo({ top: Math.max(0, scroller.scrollTop + delta), behavior: 'smooth' }); } function disposeOwnedPortals() { document.querySelectorAll('.note-reminder-menu, .note-corner-menu-dropdown, #notes-first-open-hint').forEach(dismissOrRemove); } return { _forceCloseNotesPanel, openPanel, closePanel, togglePanel, isPanelOpen, openNote, _smoothScrollToNoteCard }; }