import { app } from "/scripts/app.js"; import { NotesStore } from "./stores/NotesStore.js"; import { AppendMenu } from "./components/AppendMenu.js"; import { NotesApi } from "./api/notesApi.js"; import { isValidFilename } from "./utils/validation.js"; // Load CSS const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '/extensions/comfyui-notes-sidebar/notes-sidebar.css'; document.head.appendChild(link); class NotesSidebar { constructor() { this.store = new NotesStore(); this.tabsContainer = null; this.contentArea = null; this.textArea = null; this.saveBtn = null; this.renameBtn = null; this.deleteBtn = null; this.appendMenu = null; this.searchInput = null; this.searchResults = null; this.isInitialized = false; this.sidebarElement = null; this.currentFontSize = app.extensionManager.setting.get('Notes Sidebar.Font Size') || 14; this.leftButtonContainer = null; // Listen to state changes this.store.addEventListener('stateChanged', (event) => { this.onStateChanged(event.detail); }); } onStateChanged(changeDetails) { const { prevState, newState, updates } = changeDetails; // Update tabs if notes changed, active note changed, or unsaved changes changed if (updates.notes || updates.activeNote || updates.unsavedChanges) { this.renderTabs(); } // Update textarea if active note changed or note content changed if (updates.activeNote || updates.noteContent) { this.updateTextArea(); this.updateTextAreaState(); } // Update button states if active note or unsaved changes changed if (updates.activeNote || updates.unsavedChanges) { this.updateButtonState(); } // Update search results if active note changed and search is active if (updates.activeNote && this.searchInput && this.searchInput.value.trim()) { this.onSearchChange(); } } get notes() { return this.store.state.notes; } get activeNote() { return this.store.state.activeNote; } get unsavedChanges() { return this.store.state.unsavedChanges; } get noteContent() { return this.store.state.noteContent; } get savedContent() { return this.store.state.savedContent; } updateTextArea() { if (!this.textArea) return; if (!this.activeNote) { this.textArea.value = ''; return; } // Check if we have unsaved content for this note if (this.noteContent.has(this.activeNote)) { this.textArea.value = this.noteContent.get(this.activeNote); } else { this.textArea.value = ''; } } updateTextAreaState() { if (!this.textArea) return; const hasActiveNote = !!this.activeNote; this.textArea.disabled = !hasActiveNote; this.textArea.placeholder = hasActiveNote ? 'Start typing your notes...' : 'Select a note to start editing...'; } updateButtonState() { if (!this.saveBtn || !this.renameBtn || !this.deleteBtn || !this.appendMenu) return; const hasActiveNote = !!this.activeNote; const hasUnsavedChanges = hasActiveNote && this.unsavedChanges.get(this.activeNote); // Save button: enabled only when there's an active note AND unsaved changes this.saveBtn.disabled = !hasActiveNote || !hasUnsavedChanges; // Rename, delete, and append buttons: enabled only when there's an active note this.renameBtn.disabled = !hasActiveNote; this.deleteBtn.disabled = !hasActiveNote; this.appendMenu.setDisabled(!hasActiveNote); } async init() { await this.loadNotes(); } async createSidebarContent(el) { // Ensure the parent element has proper height el.style.height = '100%'; el.style.display = 'flex'; el.style.flexDirection = 'column'; // If already initialized, just update the existing content if (this.isInitialized && this.sidebarElement) { el.innerHTML = ''; el.appendChild(this.sidebarElement); // Trigger reactive updates when reopening this.renderTabs(); this.updateTextArea(); this.updateTextAreaState(); return; } // Create new sidebar content this.sidebarElement = document.createElement('div'); this.sidebarElement.className = 'notes-sidebar'; this.createTabsContainer(this.sidebarElement); this.createMenuContainer(this.sidebarElement); this.createSearchContainer(this.sidebarElement); this.createContentArea(this.sidebarElement); el.appendChild(this.sidebarElement); this.isInitialized = true; // Load notes after creating UI await this.loadNotes(); // Ensure UI reflects the current state after initialization this.renderTabs(); this.updateTextArea(); this.updateTextAreaState(); this.updateButtonState(); } createTabsContainer(parent) { const tabsWrapper = document.createElement('div'); tabsWrapper.className = 'notes-tabs-wrapper'; // Left side: tabs container for + button and note tabs this.tabsContainer = document.createElement('div'); this.tabsContainer.className = 'notes-tabs-container'; const newNoteButtonGroup = document.createElement('div'); newNoteButtonGroup.className = 'notes-button-group'; const newTabBtn = document.createElement('button'); newTabBtn.innerHTML = ''; newTabBtn.title = 'New Note'; newTabBtn.className = 'notes-new-tab-btn'; newTabBtn.onclick = () => this.createNewNote(); newNoteButtonGroup.appendChild(newTabBtn); this.tabsContainer.appendChild(newNoteButtonGroup); const rightControls = document.createElement('div'); rightControls.style.display = 'flex'; rightControls.style.gap = '10px'; const zoomButtonGroup = document.createElement('div'); zoomButtonGroup.className = 'notes-button-group'; const zoomOutBtn = document.createElement('button'); zoomOutBtn.innerHTML = ''; zoomOutBtn.className = 'notes-btn'; zoomOutBtn.title = 'Zoom Out'; zoomOutBtn.onclick = () => this.zoomOut(); const zoomInBtn = document.createElement('button'); zoomInBtn.innerHTML = ''; zoomInBtn.className = 'notes-btn'; zoomInBtn.title = 'Zoom In'; zoomInBtn.onclick = () => this.zoomIn(); zoomButtonGroup.appendChild(zoomOutBtn); zoomButtonGroup.appendChild(zoomInBtn); const refreshButtonGroup = document.createElement('div'); refreshButtonGroup.className = 'notes-button-group'; const refreshBtn = document.createElement('button'); refreshBtn.innerHTML = ''; refreshBtn.className = 'notes-btn refresh'; refreshBtn.title = 'Refresh'; refreshBtn.onclick = () => this.refreshNotes(); refreshButtonGroup.appendChild(refreshBtn); rightControls.appendChild(zoomButtonGroup); rightControls.appendChild(refreshButtonGroup); tabsWrapper.appendChild(this.tabsContainer); tabsWrapper.appendChild(rightControls); parent.appendChild(tabsWrapper); } createMenuContainer(parent) { const buttonContainer = document.createElement('div'); buttonContainer.className = 'notes-button-container'; this.leftButtonContainer = document.createElement('div'); this.leftButtonContainer.className = 'notes-button-group'; const rightButtonContainer = document.createElement('div'); rightButtonContainer.className = 'notes-button-group'; this.saveBtn = document.createElement('button'); this.saveBtn.innerHTML = ''; this.saveBtn.className = 'notes-btn save'; this.saveBtn.title = 'Save'; this.saveBtn.onclick = () => this.saveActiveNote(); this.saveBtn.disabled = true; this.renameBtn = document.createElement('button'); this.renameBtn.innerHTML = ''; this.renameBtn.className = 'notes-btn rename'; this.renameBtn.title = 'Rename'; this.renameBtn.onclick = () => this.renameActiveNote(); this.renameBtn.disabled = true; this.deleteBtn = document.createElement('button'); this.deleteBtn.innerHTML = ''; this.deleteBtn.className = 'notes-btn delete'; this.deleteBtn.title = 'Delete'; this.deleteBtn.onclick = () => this.deleteActiveNote(); this.deleteBtn.disabled = true; this.leftButtonContainer.appendChild(this.saveBtn); this.leftButtonContainer.appendChild(this.renameBtn); // Append menu will be added later after textarea is created rightButtonContainer.appendChild(this.deleteBtn); buttonContainer.appendChild(this.leftButtonContainer); buttonContainer.appendChild(rightButtonContainer); parent.appendChild(buttonContainer); } createSearchContainer(parent) { const searchContainer = document.createElement('div'); searchContainer.className = 'notes-search-container'; const inputWrapper = document.createElement('div'); inputWrapper.className = 'notes-search-input-wrapper'; this.searchInput = document.createElement('input'); this.searchInput.type = 'text'; this.searchInput.className = 'notes-search-input'; this.searchInput.placeholder = 'Search lines...'; this.searchInput.addEventListener('input', () => this.onSearchChange()); const clearButton = document.createElement('button'); clearButton.className = 'notes-search-clear'; clearButton.innerHTML = ''; clearButton.title = 'Clear search'; clearButton.addEventListener('click', () => this.clearSearch()); this.searchResults = document.createElement('div'); this.searchResults.className = 'notes-search-results'; this.searchResults.style.display = 'none'; inputWrapper.appendChild(this.searchInput); inputWrapper.appendChild(clearButton); searchContainer.appendChild(inputWrapper); searchContainer.appendChild(this.searchResults); parent.appendChild(searchContainer); } createContentArea(parent) { this.contentArea = document.createElement('div'); this.contentArea.className = 'notes-content-area'; this.textArea = document.createElement('textarea'); this.textArea.className = 'notes-textarea'; this.textArea.placeholder = 'Select a note to start editing...'; // Apply saved font size this.updateFontSize(); // Initialize as disabled (will be enabled reactively if there's an active note) this.textArea.disabled = true; // Add event listener to track changes this.textArea.addEventListener('input', () => this.onTextAreaChange()); // Add keydown event listener for Ctrl+S shortcut this.textArea.addEventListener('keydown', (e) => this.onTextAreaKeyDown(e)); // Initialize AppendMenu with textarea reference and change handler this.appendMenu = new AppendMenu(this.textArea, () => this.onTextAreaChange()); // Add append menu to the left button container if (this.leftButtonContainer) { this.leftButtonContainer.appendChild(this.appendMenu.createMenuElements()); } this.contentArea.appendChild(this.textArea); parent.appendChild(this.contentArea); } async loadNotes() { try { const notes = await NotesApi.getNotesList(); this.store.setNotes(notes); if (notes.length > 0 && !this.activeNote) { this.store.setActiveNote(notes[0]); } else if (notes.length === 0) { // Ensure active note is cleared when no notes exist this.store.setActiveNote(null); } // Load content for active note after setting state if (this.activeNote) { await this.loadNoteContent(this.activeNote); } } catch (error) { console.error('Error loading notes:', error); } } renderTabs() { if (!this.tabsContainer) return; const existingTabs = this.tabsContainer.querySelectorAll('.note-tab'); existingTabs.forEach(tab => tab.remove()); this.notes.forEach(noteName => { const tab = document.createElement('button'); const isUnsaved = this.unsavedChanges.get(noteName) || false; tab.textContent = (isUnsaved ? '* ' : '') + noteName; tab.className = `note-tab ${this.activeNote === noteName ? 'active' : ''}`; tab.title = noteName + (isUnsaved ? ' (unsaved)' : ''); // Full name in tooltip tab.onclick = () => this.selectNote(noteName); this.tabsContainer.appendChild(tab); }); } async selectNote(noteName) { // Save current content before switching if (this.activeNote) { this.store.setNoteContent(this.activeNote, this.textArea.value); } this.store.setActiveNote(noteName); await this.loadNoteContent(noteName); } async loadNoteContent(noteName) { try { // Check if we have unsaved content for this note if (this.noteContent.has(noteName)) { // Content is already in state, updateTextArea will handle it return; } else { // Load from server const content = await NotesApi.getNoteContent(noteName); // Store both current and saved content this.store.setNoteContent(noteName, content); this.store.setSavedContent(noteName, content); } } catch (error) { console.error('Error loading note content:', error); } } async saveActiveNote() { try { const content = this.textArea.value; await NotesApi.saveNote(this.activeNote, content); console.log('Note saved successfully'); // Mark as saved this.store.setSavedContent(this.activeNote, content); this.store.setNoteContent(this.activeNote, content); this.store.setUnsavedChanges(this.activeNote, false); } catch (error) { console.error('Error saving note:', error); } } async deleteActiveNote() { if (!confirm(`Are you sure you want to delete "${this.activeNote}"?`)) return; try { await NotesApi.deleteNote(this.activeNote); // Clean up tracking for deleted note const deletedNote = this.activeNote; // Clear active note first to ensure state change is triggered this.store.setActiveNote(null); this.store.deleteNoteFromState(deletedNote); // Refresh notes list (this will handle setting activeNote correctly) await this.refreshNotes(); } catch (error) { console.error('Error deleting note:', error); } } async renameActiveNote() { let newName = prompt('Enter new note name:', this.activeNote); if (!newName) return; // Trim spaces newName = newName.trim(); if (!newName || newName === this.activeNote) return; try { if (!isValidFilename(newName)) { alert('Invalid filename! Please avoid special characters like / \\ : * ? " < > |'); return; } // Check if new name already exists if (this.notes.includes(newName)) { alert('A note with this name already exists!'); return; } // Get current content const currentContent = this.textArea.value; // Create new note with the content await NotesApi.saveNote(newName, currentContent); // Delete old note await NotesApi.deleteNote(this.activeNote); // Update tracking maps through store const oldName = this.activeNote; this.store.renameNoteInState(oldName, newName); // Set saved content to what we just saved this.store.setSavedContent(newName, currentContent); this.store.setUnsavedChanges(newName, false); // Update active note and refresh this.store.setActiveNote(newName); await this.refreshNotes(); } catch (error) { console.error('Error renaming note:', error); } } async createNewNote() { let noteName; // Keep asking for a name until user cancels or enters a valid unique name while (true) { noteName = prompt('Enter note name:'); if (!noteName) return; // User cancelled // Trim spaces noteName = noteName.trim(); // Validate filename safety if (!isValidFilename(noteName)) { alert('Invalid filename! Please avoid special characters like / \\ : * ? " < > |'); continue; // Ask again } // Check if note name already exists if (this.notes.includes(noteName)) { alert('A note with this name already exists!'); continue; // Ask again } break; // Valid unique name } try { await NotesApi.saveNote(noteName, ''); await this.refreshNotes(); this.store.setActiveNote(noteName); // Initialize tracking for new note this.store.setNoteContent(noteName, ''); this.store.setSavedContent(noteName, ''); this.store.setUnsavedChanges(noteName, false); } catch (error) { console.error('Error creating note:', error); } } async refreshNotes() { // Preserve current content before refreshing if (this.activeNote && this.textArea) { this.store.setNoteContent(this.activeNote, this.textArea.value); } await this.loadNotes(); } onTextAreaChange() { const currentContent = this.textArea.value; const savedContent = this.savedContent.get(this.activeNote) || ''; // Update current content this.store.setNoteContent(this.activeNote, currentContent); // Check if content has changed from saved version const hasUnsavedChanges = currentContent !== savedContent; this.store.setUnsavedChanges(this.activeNote, hasUnsavedChanges); // Update search results if search is active if (this.searchInput && this.searchInput.value.trim()) { this.onSearchChange(); } } onTextAreaKeyDown(event) { // Handle Ctrl+S shortcut for saving notes if (event.ctrlKey && event.key === 's') { const saveShortcutSetting = app.extensionManager.setting.get('Notes Sidebar.Save Shortcut') || 'note_only'; if (saveShortcutSetting === 'none') { // No shortcut behavior - let ComfyUI handle it normally return; } if (saveShortcutSetting === 'note_only') { // Prevent ComfyUI from seeing the event event.preventDefault(); event.stopPropagation(); } else if (saveShortcutSetting === 'both') { // Only prevent browser default, let ComfyUI handle it too event.preventDefault(); } // Save note if there's an active note and unsaved changes if (this.activeNote && this.unsavedChanges.get(this.activeNote)) { this.saveActiveNote(); } } } onSearchChange() { const searchText = this.searchInput.value.trim(); if (!searchText) { // Hide search results when search is empty this.searchResults.style.display = 'none'; return; } if (!this.activeNote || !this.textArea.value) { // No active note or content to search this.searchResults.style.display = 'none'; return; } const content = this.textArea.value; const lines = content.split('\n'); const matchingLines = []; lines.forEach((line, index) => { if (line.toLowerCase().includes(searchText.toLowerCase())) { matchingLines.push({ lineNumber: index + 1, content: line }); } }); if (matchingLines.length === 0) { this.searchResults.innerHTML = '