This commit is contained in:
Alexopus
2025-07-18 16:15:48 +02:00
commit be763c6504
7 changed files with 693 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__pycache__
+94
View File
@@ -0,0 +1,94 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a ComfyUI extension that adds a server-side notes sidebar for managing markdown notes. The extension provides a complete notes management system with both backend API endpoints and a frontend JavaScript interface.
## Architecture
### Backend (Python)
- **Main Module**: `__init__.py` - Core extension entry point
- **NotesManager Class**: Handles all note operations (CRUD) with file system storage
- **API Endpoints**: RESTful endpoints using aiohttp web framework integrated with ComfyUI's PromptServer
- **File Storage**: Notes stored as `.md` files in the `notes/` directory
### Frontend (JavaScript)
- **Extension Module**: `js/extension.js` - ComfyUI extension integration
- **NotesSidebar Class**: Main UI component managing tabs, content area, and user interactions
- **Styling**: `js/notes-sidebar.css` - Complete UI styling for dark theme integration
### Key Components
1. **Server Integration**: Uses ComfyUI's PromptServer.instance.routes for API endpoints
2. **File Management**: Notes stored as markdown files with automatic directory creation
3. **UI Architecture**: Tab-based interface with content area and action buttons
4. **Extension Registration**: Properly registers with ComfyUI's extension manager as a sidebar tab
5. **State Management**: Three Maps track content state:
- `noteContent`: Current content for each note (including unsaved)
- `savedContent`: Last saved content from server
- `unsavedChanges`: Boolean flag for each note's modification state
## API Endpoints
The extension exposes these REST endpoints:
- `GET /notes_sidebar/list` - List all notes
- `GET /notes_sidebar/note/{note_name}` - Get specific note content
- `POST /notes_sidebar/note/{note_name}` - Save/create note
- `DELETE /notes_sidebar/note/{note_name}` - Delete note
## Development Notes
### Extension Structure
- Must export `NODE_CLASS_MAPPINGS`, `NODE_DISPLAY_NAME_MAPPINGS`, and `WEB_DIRECTORY` for ComfyUI compatibility
- JavaScript extension uses ComfyUI's `app.registerExtension()` pattern
- Sidebar integration uses `app.extensionManager.registerSidebarTab()`
### File Operations
- Notes are automatically saved as `.md` files in the `notes/` directory
- File names are URL-encoded for safe handling
- Directory creation is handled automatically on initialization
### UI State Management
- Active note tracking with tab highlighting
- Content persistence across sidebar open/close cycles
- Real-time tab rendering on note operations
- Button layout: Save/Rename on left, Refresh/Delete on right
- **Unsaved Changes Tracking**: Visual indicators (*) for modified notes
- **Content Preservation**: Tab switching preserves unsaved content
- **Change Detection**: Automatic tracking of modifications vs saved state
### Unsaved Changes Behavior
- Notes with modifications show asterisk (*) in tab name
- Switching tabs preserves unsaved content in inactive notes
- `onTextAreaChange()` method tracks real-time content changes
- Save operation clears unsaved state and updates saved content reference
- Delete/rename operations properly clean up tracking maps
## Development Commands
### Installation & Setup
- No build process required - extension loads directly in ComfyUI
- Place extension folder in ComfyUI's `custom_nodes/` directory
- Restart ComfyUI to load the extension
### Testing the Extension
No automated tests defined. Manual testing workflow:
1. Load extension in ComfyUI
2. Verify sidebar tab appears with "Notes" label and book icon
3. Test CRUD operations: create, edit, save, rename, delete notes
4. Verify tab switching and content persistence
### Development Workflow
1. Modify Python code in `__init__.py` for backend changes
2. Modify JavaScript in `js/extension.js` for frontend changes
3. Update CSS in `js/notes-sidebar.css` for styling changes
4. Restart ComfyUI to reload the extension (required for Python changes)
5. Refresh browser for JavaScript/CSS changes
### File Structure
- `notes/` - Storage directory for markdown notes (auto-created)
- `js/` - Frontend assets loaded by ComfyUI
- `__init__.py` - Main extension code
- `pyproject.toml` - Project metadata
+3
View File
@@ -0,0 +1,3 @@
# ComfyUI Notes Sidebar
A ComfyUI extension that adds a notes sidebar for managing markdown notes on the server-side.
+79
View File
@@ -0,0 +1,79 @@
import os
from aiohttp import web
from server import PromptServer
WEB_DIRECTORY = "./js"
# Required for ComfyUI to recognize this as a valid extension
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
class NotesManager:
def __init__(self):
self.notes_dir = os.path.join(os.path.dirname(__file__), "notes")
os.makedirs(self.notes_dir, exist_ok=True)
def get_notes_list(self) -> list[str]:
notes : list[str] = []
for filename in os.listdir(self.notes_dir):
if filename.endswith('.md'):
notes.append(filename[:-3]) # Remove .md extension
return sorted(notes)
def get_note_content(self, note_name: str):
file_path = os.path.join(self.notes_dir, f"{note_name}.md")
if os.path.exists(file_path):
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
return ""
def save_note(self, note_name: str, content: str):
file_path = os.path.join(self.notes_dir, f"{note_name}.md")
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
def delete_note(self, note_name: str):
file_path = os.path.join(self.notes_dir, f"{note_name}.md")
if os.path.exists(file_path):
os.remove(file_path)
notes_manager = NotesManager()
@PromptServer.instance.routes.get("/notes_sidebar/list")
async def get_notes_list(request):
try:
notes = notes_manager.get_notes_list()
return web.json_response({"notes": notes})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.get("/notes_sidebar/note/{note_name}")
async def get_note(request):
try:
note_name = request.match_info['note_name']
content = notes_manager.get_note_content(note_name)
return web.json_response({"content": content})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/notes_sidebar/note/{note_name}")
async def save_note(request):
try:
note_name = request.match_info['note_name']
data = await request.json()
content = data.get('content', '')
notes_manager.save_note(note_name, content)
return web.json_response({"success": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.delete("/notes_sidebar/note/{note_name}")
async def delete_note(request):
try:
note_name = request.match_info['note_name']
notes_manager.delete_note(note_name)
return web.json_response({"success": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
+380
View File
@@ -0,0 +1,380 @@
import { app } from "/scripts/app.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.notes = [];
this.activeNote = null;
this.tabsContainer = null;
this.contentArea = null;
this.textArea = null;
this.isInitialized = false;
this.sidebarElement = null;
// Track unsaved changes and content for each note
this.unsavedChanges = new Map(); // noteName -> boolean
this.noteContent = new Map(); // noteName -> string (current content)
this.savedContent = new Map(); // noteName -> string (last saved content)
}
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);
// Refresh the active note content when reopening
if (this.activeNote) {
this.loadNoteContent(this.activeNote);
}
return;
}
// Create new sidebar content
this.sidebarElement = document.createElement('div');
this.sidebarElement.className = 'notes-sidebar';
this.createTabsContainer(this.sidebarElement);
this.createContentArea(this.sidebarElement);
el.appendChild(this.sidebarElement);
this.isInitialized = true;
// Load notes after creating UI
await this.loadNotes();
}
createTabsContainer(parent) {
this.tabsContainer = document.createElement('div');
this.tabsContainer.className = 'notes-tabs-container';
const newTabBtn = document.createElement('button');
newTabBtn.textContent = '+';
newTabBtn.title = 'New Note';
newTabBtn.className = 'notes-new-tab-btn';
newTabBtn.onclick = () => this.createNewNote();
this.tabsContainer.appendChild(newTabBtn);
parent.appendChild(this.tabsContainer);
}
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 = 'Start typing your notes...';
// Add event listener to track changes
this.textArea.addEventListener('input', () => this.onTextAreaChange());
const buttonContainer = document.createElement('div');
buttonContainer.className = 'notes-button-container';
const leftButtonContainer = document.createElement('div');
leftButtonContainer.className = 'notes-button-group';
const rightButtonContainer = document.createElement('div');
rightButtonContainer.className = 'notes-button-group';
const deleteBtn = document.createElement('button');
deleteBtn.textContent = 'delete';
deleteBtn.className = 'notes-btn delete';
deleteBtn.onclick = () => this.deleteActiveNote();
const renameBtn = document.createElement('button');
renameBtn.textContent = 'rename';
renameBtn.className = 'notes-btn rename';
renameBtn.onclick = () => this.renameActiveNote();
const saveBtn = document.createElement('button');
saveBtn.textContent = 'save';
saveBtn.className = 'notes-btn save';
saveBtn.onclick = () => this.saveActiveNote();
const refreshBtn = document.createElement('button');
refreshBtn.textContent = 'refresh';
refreshBtn.className = 'notes-btn refresh';
refreshBtn.onclick = () => this.refreshNotes();
leftButtonContainer.appendChild(saveBtn);
leftButtonContainer.appendChild(renameBtn);
rightButtonContainer.appendChild(refreshBtn);
rightButtonContainer.appendChild(deleteBtn);
buttonContainer.appendChild(leftButtonContainer);
buttonContainer.appendChild(rightButtonContainer);
this.contentArea.appendChild(this.textArea);
this.contentArea.appendChild(buttonContainer);
parent.appendChild(this.contentArea);
}
async loadNotes() {
try {
const response = await fetch('/notes_sidebar/list');
const data = await response.json();
this.notes = data.notes || [];
if (this.notes.length > 0 && !this.activeNote) {
this.activeNote = this.notes[0];
}
this.renderTabs();
// Load content for active note after rendering tabs
if (this.activeNote) {
await this.loadNoteContent(this.activeNote);
}
} catch (error) {
console.error('Error loading notes:', error);
}
}
renderTabs() {
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 = noteName + (isUnsaved ? ' *' : '');
tab.className = `note-tab ${this.activeNote === noteName ? 'active' : ''}`;
tab.onclick = () => this.selectNote(noteName);
this.tabsContainer.appendChild(tab);
});
}
async selectNote(noteName) {
// Save current content before switching
if (this.activeNote && this.textArea) {
this.noteContent.set(this.activeNote, this.textArea.value);
}
this.activeNote = noteName;
await this.loadNoteContent(noteName);
this.renderTabs();
}
async loadNoteContent(noteName) {
try {
// Check if we have unsaved content for this note
if (this.noteContent.has(noteName)) {
// Use the unsaved content
if (this.textArea) {
this.textArea.value = this.noteContent.get(noteName);
}
} else {
// Load from server
const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(noteName)}`);
const data = await response.json();
const content = data.content || '';
if (this.textArea) {
this.textArea.value = content;
}
// Store both current and saved content
this.noteContent.set(noteName, content);
this.savedContent.set(noteName, content);
}
} catch (error) {
console.error('Error loading note content:', error);
}
}
async saveActiveNote() {
if (!this.activeNote) return;
try {
const content = this.textArea.value;
const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(this.activeNote)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', },
body: JSON.stringify({ content: content })
});
if (response.ok) {
console.log('Note saved successfully');
// Mark as saved
this.savedContent.set(this.activeNote, content);
this.noteContent.set(this.activeNote, content);
this.unsavedChanges.set(this.activeNote, false);
this.renderTabs(); // Update tab display
} else {
console.error('Error saving note');
}
} catch (error) {
console.error('Error saving note:', error);
}
}
async deleteActiveNote() {
if (!this.activeNote) return;
if (!confirm(`Are you sure you want to delete "${this.activeNote}"?`)) return;
try {
const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(this.activeNote)}`, {
method: 'DELETE'
});
if (response.ok) {
// Clean up tracking for deleted note
const deletedNote = this.activeNote;
this.unsavedChanges.delete(deletedNote);
this.noteContent.delete(deletedNote);
this.savedContent.delete(deletedNote);
await this.refreshNotes();
// Select the first available note or clear if no notes left
this.activeNote = this.notes.length > 0 ? this.notes[0] : null;
if (this.activeNote) {
await this.loadNoteContent(this.activeNote);
} else {
this.textArea.value = '';
}
}
} catch (error) {
console.error('Error deleting note:', error);
}
}
async renameActiveNote() {
if (!this.activeNote) return;
const newName = prompt('Enter new note name:', this.activeNote);
if (!newName || newName === this.activeNote) return;
try {
// 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
const createResponse = await fetch(`/notes_sidebar/note/${encodeURIComponent(newName)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', },
body: JSON.stringify({ content: currentContent })
});
if (createResponse.ok) {
// Delete old note
await fetch(`/notes_sidebar/note/${encodeURIComponent(this.activeNote)}`, { method: 'DELETE' });
// Update tracking maps
const oldName = this.activeNote;
const unsavedState = this.unsavedChanges.get(oldName) || false;
const content = this.noteContent.get(oldName) || currentContent;
// Remove old tracking
this.unsavedChanges.delete(oldName);
this.noteContent.delete(oldName);
this.savedContent.delete(oldName);
// Add new tracking
this.noteContent.set(newName, content);
this.savedContent.set(newName, currentContent); // The content we just saved
this.unsavedChanges.set(newName, false); // Just saved, so no unsaved changes
// Update active note and refresh
this.activeNote = newName;
await this.refreshNotes();
this.renderTabs();
}
} catch (error) {
console.error('Error renaming note:', error);
}
}
async createNewNote() {
const noteName = prompt('Enter note name:');
if (!noteName) return;
try {
const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(noteName)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', },
body: JSON.stringify({ content: '' })
});
if (response.ok) {
await this.refreshNotes();
this.activeNote = noteName;
this.textArea.value = '';
// Initialize tracking for new note
this.noteContent.set(noteName, '');
this.savedContent.set(noteName, '');
this.unsavedChanges.set(noteName, false);
this.renderTabs();
}
} catch (error) {
console.error('Error creating note:', error);
}
}
async refreshNotes() {
// Preserve current content before refreshing
if (this.activeNote && this.textArea) {
this.noteContent.set(this.activeNote, this.textArea.value);
}
await this.loadNotes();
}
onTextAreaChange() {
if (!this.activeNote) return;
const currentContent = this.textArea.value;
const savedContent = this.savedContent.get(this.activeNote) || '';
// Update current content
this.noteContent.set(this.activeNote, currentContent);
// Check if content has changed from saved version
const hasUnsavedChanges = currentContent !== savedContent;
this.unsavedChanges.set(this.activeNote, hasUnsavedChanges);
// Update tab display
this.renderTabs();
}
}
const notesSidebar = new NotesSidebar();
app.registerExtension({
name: "comfyui.notes.sidebar",
async setup() {
await notesSidebar.init();
app.extensionManager.registerSidebarTab({
id: "notes",
icon: "pi pi-book",
title: "Notes",
tooltip: "Notes",
type: "custom",
render: (el) => {
notesSidebar.createSidebarContent(el);
},
});
}
});
+119
View File
@@ -0,0 +1,119 @@
/* Notes Sidebar Styles */
.notes-sidebar {
display: flex;
flex-direction: column;
height: 100%;
font-family: Arial, sans-serif;
color: #fff;
}
.notes-tabs-container {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 10px;
background: #1a1a1a;
border-bottom: 1px solid #444;
min-height: 40px;
}
.notes-new-tab-btn {
background: #444;
border: none;
color: #fff;
padding: 5px 10px;
cursor: pointer;
border-radius: 3px;
font-size: 16px;
}
.notes-new-tab-btn:hover {
background: #555;
}
.note-tab {
background: #444;
border: none;
color: #fff;
padding: 5px 10px;
cursor: pointer;
border-radius: 3px;
font-size: 12px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-tab:hover {
background: #555;
}
.note-tab.active {
background: #4CAF50;
}
.notes-content-area {
flex: 1;
display: flex;
flex-direction: column;
padding: 15px;
gap: 10px;
}
.notes-textarea {
flex: 1;
background: #1a1a1a;
border: 1px solid #444;
color: #fff;
padding: 10px;
font-family: monospace;
font-size: 14px;
resize: none;
outline: none;
}
.notes-textarea:focus {
border-color: #4CAF50;
}
.notes-button-container {
display: flex;
gap: 10px;
justify-content: space-between;
}
.notes-button-group {
display: flex;
gap: 10px;
}
.notes-btn {
border: none;
color: white;
padding: 8px 16px;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
}
.notes-btn:hover {
opacity: 0.8;
}
.notes-btn.delete {
background: #ff4444;
}
.notes-btn.save {
background: #4CAF50;
}
.notes-btn.refresh {
background: #2196F3;
}
.notes-btn.rename {
background: #FF9800;
}
+17
View File
@@ -0,0 +1,17 @@
[project]
name = "comfyui-notes-sidebar"
version = "1.0.0"
description = "A ComfyUI extension that adds a notes sidebar for managing markdown notes"
authors = [
{name = "ComfyUI Notes Extension", email = "example@email.com"}
]
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.7"
[project.urls]
Repository = "https://github.com/your-username/comfyui-notes-sidebar"
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"