Refactor for sanity
This commit is contained in:
@@ -15,9 +15,12 @@ This is a ComfyUI extension that adds a server-side notes sidebar for managing m
|
||||
- **File Storage**: Notes stored as `.md` files in the `notes/` directory
|
||||
|
||||
### Frontend (JavaScript)
|
||||
- **Extension Module**: `js/extension.js` - ComfyUI extension integration
|
||||
- **NotesStore Class**: Event-driven state management using EventTarget pattern
|
||||
- **NotesSidebar Class**: Main UI component managing tabs, content area, and user interactions
|
||||
- **Extension Module**: `js/extension.js` - Main ComfyUI extension integration and UI orchestration
|
||||
- **State Management**: `js/stores/NotesStore.js` - Event-driven state management using EventTarget pattern
|
||||
- **Components**:
|
||||
- `js/components/AppendMenu.js` - Append functionality (currently LoRA integration) with submenu UI
|
||||
- **API Layer**: `js/api/notesApi.js` - Centralized API calls with consistent error handling
|
||||
- **Utilities**: `js/utils/validation.js` - Filename validation helpers
|
||||
- **Styling**: `js/notes-sidebar.css` - Complete UI styling for dark theme integration
|
||||
|
||||
### Key Components
|
||||
@@ -66,7 +69,7 @@ The extension exposes these REST endpoints:
|
||||
- **Unsaved Changes Tracking**: Visual indicators (*) for modified notes
|
||||
- **Content Preservation**: Tab switching preserves unsaved content
|
||||
- **Change Detection**: Automatic tracking of modifications vs saved state
|
||||
- **LoRA Integration**: Submenu with three formatting options for appending LoRA lists
|
||||
- **Append Integration**: Submenu with three formatting options for appending LoRA lists (extensible for other content types)
|
||||
|
||||
### Unsaved Changes Behavior
|
||||
- Notes with modifications show asterisk (*) in tab name
|
||||
@@ -106,8 +109,32 @@ No automated tests defined. Manual testing workflow:
|
||||
4. **Python changes**: Restart ComfyUI completely to reload the extension
|
||||
5. **JavaScript/CSS changes**: Browser refresh is sufficient
|
||||
|
||||
### Common Development Tasks
|
||||
- **Adding new API endpoints**: Add route handlers in `__init__.py` and corresponding methods in `js/api/notesApi.js`
|
||||
- **Modifying note storage**: Update `NotesManager` class methods in `__init__.py`
|
||||
- **UI state changes**: Modify `NotesStore` class in `js/stores/NotesStore.js`
|
||||
- **Append functionality changes**: Update `js/components/AppendMenu.js` and related backend endpoints
|
||||
- **Adding new components**: Create new files in `js/components/` and import them in `extension.js`
|
||||
- **API changes**: Update `js/api/notesApi.js` static methods and error handling
|
||||
|
||||
### File Structure
|
||||
- `notes/` - Storage directory for markdown notes (auto-created)
|
||||
- `js/` - Frontend assets loaded by ComfyUI
|
||||
- `extension.js` - Main extension entry point (~450 lines)
|
||||
- `stores/NotesStore.js` - State management (~80 lines)
|
||||
- `components/AppendMenu.js` - Append functionality (~170 lines)
|
||||
- `api/notesApi.js` - API service layer (~90 lines)
|
||||
- `utils/validation.js` - Validation utilities (~10 lines)
|
||||
- `notes-sidebar.css` - UI styling
|
||||
- `__init__.py` - Main extension code
|
||||
- `pyproject.toml` - Project metadata
|
||||
- `pyproject.toml` - Project metadata
|
||||
|
||||
### Important Development Considerations
|
||||
- **Filename validation**: Use `isValidFilename()` from `js/utils/validation.js` to validate user input
|
||||
- **URL encoding**: Note names are URL-encoded in API calls for safe handling
|
||||
- **Event-driven architecture**: UI updates are triggered by state changes through EventTarget pattern in `NotesStore`
|
||||
- **Modular architecture**: Components are separated by concern (state, UI, API, utilities)
|
||||
- **API centralization**: All fetch calls are centralized in `js/api/notesApi.js` with consistent error handling
|
||||
- **ComfyUI integration**: Uses `folder_paths.get_filename_list("loras")` for LoRA discovery
|
||||
- **Error handling**: All API endpoints include try/catch with JSON error responses
|
||||
- **Import structure**: ES6 modules with clear dependency relationships
|
||||
@@ -0,0 +1,103 @@
|
||||
export class NotesApi {
|
||||
static async getNotesList() {
|
||||
try {
|
||||
const response = await fetch('/notes_sidebar/list');
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch notes list');
|
||||
}
|
||||
|
||||
return data.notes || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading notes:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getNoteContent(noteName) {
|
||||
try {
|
||||
const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(noteName)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch note content');
|
||||
}
|
||||
|
||||
return data.content || '';
|
||||
} catch (error) {
|
||||
console.error('Error loading note content:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async saveNote(noteName, content) {
|
||||
try {
|
||||
const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(noteName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: content })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to save note');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving note:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteNote(noteName) {
|
||||
try {
|
||||
const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(noteName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to delete note');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getLorasList() {
|
||||
try {
|
||||
const response = await fetch('/notes_sidebar/loras');
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch LoRAs list');
|
||||
}
|
||||
|
||||
return data.loras || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching LoRAs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getLorasWithMetadata() {
|
||||
try {
|
||||
const response = await fetch('/notes_sidebar/loras_with_metadata');
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch LoRAs with metadata');
|
||||
}
|
||||
|
||||
return data.loras || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching LoRAs with metadata:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { NotesApi } from "../api/notesApi.js";
|
||||
|
||||
export class AppendMenu {
|
||||
constructor(textArea, onContentChange) {
|
||||
this.textArea = textArea;
|
||||
this.onContentChange = onContentChange;
|
||||
this.container = null;
|
||||
this.button = null;
|
||||
this.submenu = null;
|
||||
this.isDisabled = true;
|
||||
}
|
||||
|
||||
createMenuElements() {
|
||||
// Append submenu container
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'notes-submenu-container';
|
||||
|
||||
this.button = document.createElement('button');
|
||||
this.button.innerHTML = '<i class="pi pi-plus-circle"></i>';
|
||||
this.button.className = 'notes-btn append-loras';
|
||||
this.button.title = 'Append';
|
||||
this.button.onclick = () => this.toggleMenu();
|
||||
this.button.disabled = this.isDisabled;
|
||||
|
||||
// Create submenu
|
||||
this.submenu = document.createElement('div');
|
||||
this.submenu.className = 'notes-submenu';
|
||||
this.submenu.style.display = 'none';
|
||||
|
||||
// Create submenu items
|
||||
const rawOption = document.createElement('div');
|
||||
rawOption.className = 'notes-submenu-item';
|
||||
rawOption.textContent = 'Append raw LoRA filenames list';
|
||||
rawOption.onclick = () => this.selectOption('raw');
|
||||
|
||||
const tagOption = document.createElement('div');
|
||||
tagOption.className = 'notes-submenu-item';
|
||||
tagOption.textContent = "Append LoRAs in '<lora:name>' format";
|
||||
tagOption.onclick = () => this.selectOption('tag');
|
||||
|
||||
const metaOption = document.createElement('div');
|
||||
metaOption.className = 'notes-submenu-item';
|
||||
metaOption.textContent = "Append LoRAs in '<lora:name>, metadata' format";
|
||||
metaOption.onclick = () => this.selectOption('meta');
|
||||
|
||||
this.submenu.appendChild(rawOption);
|
||||
this.submenu.appendChild(tagOption);
|
||||
this.submenu.appendChild(metaOption);
|
||||
|
||||
this.container.appendChild(this.button);
|
||||
this.container.appendChild(this.submenu);
|
||||
|
||||
return this.container;
|
||||
}
|
||||
|
||||
setDisabled(disabled) {
|
||||
this.isDisabled = disabled;
|
||||
if (this.button) {
|
||||
this.button.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
toggleMenu() {
|
||||
if (this.submenu.style.display === 'none') {
|
||||
this.submenu.style.display = 'block';
|
||||
// Add click listener to close menu when clicking outside
|
||||
document.addEventListener('click', this.closeMenuHandler);
|
||||
} else {
|
||||
this.submenu.style.display = 'none';
|
||||
document.removeEventListener('click', this.closeMenuHandler);
|
||||
}
|
||||
}
|
||||
|
||||
closeMenuHandler = (event) => {
|
||||
if (!this.container.contains(event.target)) {
|
||||
this.submenu.style.display = 'none';
|
||||
document.removeEventListener('click', this.closeMenuHandler);
|
||||
}
|
||||
}
|
||||
|
||||
selectOption(type) {
|
||||
this.submenu.style.display = 'none';
|
||||
document.removeEventListener('click', this.closeMenuHandler);
|
||||
|
||||
switch(type) {
|
||||
case 'raw':
|
||||
this.appendLorasList();
|
||||
break;
|
||||
case 'tag':
|
||||
this.appendLorasTags();
|
||||
break;
|
||||
case 'meta':
|
||||
this.appendLorasWithMetadata();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async appendLorasList() {
|
||||
try {
|
||||
const loras = await NotesApi.getLorasList();
|
||||
|
||||
// Normalize backslashes to forward slashes
|
||||
const normalizedLoras = loras.map(lora => lora.replace(/\\/g, '/'));
|
||||
const lorasText = normalizedLoras.join('\n');
|
||||
|
||||
this.insertTextIntoTextarea(lorasText);
|
||||
} catch (error) {
|
||||
console.error('Error appending LoRAs:', error);
|
||||
alert('Failed to append LoRAs list');
|
||||
}
|
||||
}
|
||||
|
||||
async appendLorasTags() {
|
||||
try {
|
||||
const loras = await NotesApi.getLorasList();
|
||||
|
||||
// Format each LoRA as <lora:name:1.0>, stripping .safetensors extension and normalizing slashes
|
||||
const lorasTags = loras.map(lora => {
|
||||
const nameWithoutExt = lora.replace(/\.safetensors$/i, '');
|
||||
const normalizedPath = nameWithoutExt.replace(/\\/g, '/');
|
||||
return `<lora:${normalizedPath}>`;
|
||||
});
|
||||
const lorasText = lorasTags.join('\n');
|
||||
|
||||
this.insertTextIntoTextarea(lorasText);
|
||||
} catch (error) {
|
||||
console.error('Error appending LoRAs tags:', error);
|
||||
alert('Failed to append LoRAs tags');
|
||||
}
|
||||
}
|
||||
|
||||
async appendLorasWithMetadata() {
|
||||
try {
|
||||
const loras = await NotesApi.getLorasWithMetadata();
|
||||
|
||||
// Format each LoRA with metadata as <lora:name:1.0>, metadata
|
||||
const lorasWithMeta = loras.map(lora => {
|
||||
const nameWithoutExt = lora.name.replace(/\.safetensors$/i, '');
|
||||
const normalizedPath = nameWithoutExt.replace(/\\/g, '/');
|
||||
const baseTag = `<lora:${normalizedPath}>`;
|
||||
|
||||
if (lora.metadata) {
|
||||
return `${baseTag}, ${lora.metadata}`;
|
||||
} else {
|
||||
return baseTag;
|
||||
}
|
||||
});
|
||||
const lorasText = lorasWithMeta.join('\n');
|
||||
|
||||
this.insertTextIntoTextarea(lorasText);
|
||||
} catch (error) {
|
||||
console.error('Error appending LoRAs with metadata:', error);
|
||||
alert('Failed to append LoRAs with metadata');
|
||||
}
|
||||
}
|
||||
|
||||
insertTextIntoTextarea(text) {
|
||||
if (!this.textArea) return;
|
||||
|
||||
// Add newline before appending if current content doesn't end with one
|
||||
const separator = this.textArea.value && !this.textArea.value.endsWith('\n') ? '\n' : '';
|
||||
const textToInsert = separator + text;
|
||||
|
||||
// Focus textarea to ensure it's active
|
||||
this.textArea.focus();
|
||||
|
||||
// Move cursor to end
|
||||
this.textArea.setSelectionRange(this.textArea.value.length, this.textArea.value.length);
|
||||
|
||||
// Use document.execCommand to preserve undo history
|
||||
document.execCommand('insertText', false, textToInsert);
|
||||
|
||||
// Trigger change handling
|
||||
if (this.onContentChange) {
|
||||
this.onContentChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-362
@@ -1,4 +1,8 @@
|
||||
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');
|
||||
@@ -6,99 +10,6 @@ link.rel = 'stylesheet';
|
||||
link.href = '/extensions/comfyui-notes-sidebar/notes-sidebar.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
class NotesStore extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
notes: [],
|
||||
activeNote: null,
|
||||
unsavedChanges: new Map(), // noteName -> boolean
|
||||
noteContent: new Map(), // noteName -> string (current content)
|
||||
savedContent: new Map() // noteName -> string (last saved content)
|
||||
};
|
||||
}
|
||||
|
||||
getState() {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
setState(updates) {
|
||||
const prevState = { ...this.state };
|
||||
this.state = { ...this.state, ...updates };
|
||||
|
||||
// Emit state change event
|
||||
this.dispatchEvent(new CustomEvent('stateChanged', {
|
||||
detail: {
|
||||
prevState,
|
||||
newState: this.getState(),
|
||||
updates
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Convenience methods for common state operations
|
||||
setNotes(notes) {
|
||||
this.setState({ notes });
|
||||
}
|
||||
|
||||
setActiveNote(activeNote) {
|
||||
this.setState({ activeNote });
|
||||
}
|
||||
|
||||
setNoteContent(noteName, content) {
|
||||
const noteContent = new Map(this.state.noteContent);
|
||||
noteContent.set(noteName, content);
|
||||
this.setState({ noteContent });
|
||||
}
|
||||
|
||||
setSavedContent(noteName, content) {
|
||||
const savedContent = new Map(this.state.savedContent);
|
||||
savedContent.set(noteName, content);
|
||||
this.setState({ savedContent });
|
||||
}
|
||||
|
||||
setUnsavedChanges(noteName, hasChanges) {
|
||||
const unsavedChanges = new Map(this.state.unsavedChanges);
|
||||
unsavedChanges.set(noteName, hasChanges);
|
||||
this.setState({ unsavedChanges });
|
||||
}
|
||||
|
||||
deleteNoteFromState(noteName) {
|
||||
const unsavedChanges = new Map(this.state.unsavedChanges);
|
||||
const noteContent = new Map(this.state.noteContent);
|
||||
const savedContent = new Map(this.state.savedContent);
|
||||
|
||||
unsavedChanges.delete(noteName);
|
||||
noteContent.delete(noteName);
|
||||
savedContent.delete(noteName);
|
||||
|
||||
this.setState({ unsavedChanges, noteContent, savedContent });
|
||||
}
|
||||
|
||||
renameNoteInState(oldName, newName) {
|
||||
const unsavedChanges = new Map(this.state.unsavedChanges);
|
||||
const noteContent = new Map(this.state.noteContent);
|
||||
const savedContent = new Map(this.state.savedContent);
|
||||
|
||||
// Transfer state from old name to new name
|
||||
const unsavedState = unsavedChanges.get(oldName) || false;
|
||||
const content = noteContent.get(oldName) || '';
|
||||
const saved = savedContent.get(oldName) || '';
|
||||
|
||||
// Remove old
|
||||
unsavedChanges.delete(oldName);
|
||||
noteContent.delete(oldName);
|
||||
savedContent.delete(oldName);
|
||||
|
||||
// Add new
|
||||
unsavedChanges.set(newName, unsavedState);
|
||||
noteContent.set(newName, content);
|
||||
savedContent.set(newName, saved);
|
||||
|
||||
this.setState({ unsavedChanges, noteContent, savedContent });
|
||||
}
|
||||
}
|
||||
|
||||
class NotesSidebar {
|
||||
constructor() {
|
||||
this.store = new NotesStore();
|
||||
@@ -108,9 +19,7 @@ class NotesSidebar {
|
||||
this.saveBtn = null;
|
||||
this.renameBtn = null;
|
||||
this.deleteBtn = null;
|
||||
this.appendLorasBtn = null;
|
||||
this.appendLorasTagsBtn = null;
|
||||
this.appendLorasMetaBtn = null;
|
||||
this.appendMenu = null;
|
||||
this.isInitialized = false;
|
||||
this.sidebarElement = null;
|
||||
|
||||
@@ -173,7 +82,7 @@ class NotesSidebar {
|
||||
}
|
||||
|
||||
updateButtonState() {
|
||||
if (!this.saveBtn || !this.renameBtn || !this.deleteBtn || !this.appendLorasBtn) return;
|
||||
if (!this.saveBtn || !this.renameBtn || !this.deleteBtn || !this.appendMenu) return;
|
||||
|
||||
const hasActiveNote = !!this.activeNote;
|
||||
const hasUnsavedChanges = hasActiveNote && this.unsavedChanges.get(this.activeNote);
|
||||
@@ -184,7 +93,7 @@ class NotesSidebar {
|
||||
// Rename, delete, and append buttons: enabled only when there's an active note
|
||||
this.renameBtn.disabled = !hasActiveNote;
|
||||
this.deleteBtn.disabled = !hasActiveNote;
|
||||
this.appendLorasBtn.disabled = !hasActiveNote;
|
||||
this.appendMenu.setDisabled(!hasActiveNote);
|
||||
}
|
||||
|
||||
async init() {
|
||||
@@ -288,48 +197,9 @@ class NotesSidebar {
|
||||
this.deleteBtn.onclick = () => this.deleteActiveNote();
|
||||
this.deleteBtn.disabled = true;
|
||||
|
||||
// LoRA submenu container
|
||||
this.loraMenuContainer = document.createElement('div');
|
||||
this.loraMenuContainer.className = 'notes-submenu-container';
|
||||
|
||||
this.appendLorasBtn = document.createElement('button');
|
||||
this.appendLorasBtn.innerHTML = '<i class="pi pi-plus-circle"></i>';
|
||||
this.appendLorasBtn.className = 'notes-btn append-loras';
|
||||
this.appendLorasBtn.title = 'Append';
|
||||
this.appendLorasBtn.onclick = () => this.toggleLoraMenu();
|
||||
this.appendLorasBtn.disabled = true;
|
||||
|
||||
// Create submenu
|
||||
this.loraSubmenu = document.createElement('div');
|
||||
this.loraSubmenu.className = 'notes-submenu';
|
||||
this.loraSubmenu.style.display = 'none';
|
||||
|
||||
// Create submenu items
|
||||
const rawOption = document.createElement('div');
|
||||
rawOption.className = 'notes-submenu-item';
|
||||
rawOption.textContent = 'Append raw LoRA filenames list';
|
||||
rawOption.onclick = () => this.selectLoraOption('raw');
|
||||
|
||||
const tagOption = document.createElement('div');
|
||||
tagOption.className = 'notes-submenu-item';
|
||||
tagOption.textContent = "Append LoRAs in '<lora:name>' format";
|
||||
tagOption.onclick = () => this.selectLoraOption('tag');
|
||||
|
||||
const metaOption = document.createElement('div');
|
||||
metaOption.className = 'notes-submenu-item';
|
||||
metaOption.textContent = "Append LoRAs in '<lora:name>, metadata' format";
|
||||
metaOption.onclick = () => this.selectLoraOption('meta');
|
||||
|
||||
this.loraSubmenu.appendChild(rawOption);
|
||||
this.loraSubmenu.appendChild(tagOption);
|
||||
this.loraSubmenu.appendChild(metaOption);
|
||||
|
||||
this.loraMenuContainer.appendChild(this.appendLorasBtn);
|
||||
this.loraMenuContainer.appendChild(this.loraSubmenu);
|
||||
|
||||
leftButtonContainer.appendChild(this.saveBtn);
|
||||
leftButtonContainer.appendChild(this.renameBtn);
|
||||
leftButtonContainer.appendChild(this.loraMenuContainer);
|
||||
// Append menu will be added later after textarea is created
|
||||
rightButtonContainer.appendChild(this.deleteBtn);
|
||||
|
||||
buttonContainer.appendChild(leftButtonContainer);
|
||||
@@ -352,15 +222,22 @@ class NotesSidebar {
|
||||
// Add event listener to track changes
|
||||
this.textArea.addEventListener('input', () => this.onTextAreaChange());
|
||||
|
||||
// Initialize AppendMenu with textarea reference and change handler
|
||||
this.appendMenu = new AppendMenu(this.textArea, () => this.onTextAreaChange());
|
||||
|
||||
// Add append menu to the left button container
|
||||
const leftButtonContainer = this.sidebarElement.querySelector('.notes-button-group');
|
||||
if (leftButtonContainer) {
|
||||
leftButtonContainer.appendChild(this.appendMenu.createMenuElements());
|
||||
}
|
||||
|
||||
this.contentArea.appendChild(this.textArea);
|
||||
parent.appendChild(this.contentArea);
|
||||
}
|
||||
|
||||
async loadNotes() {
|
||||
try {
|
||||
const response = await fetch('/notes_sidebar/list');
|
||||
const data = await response.json();
|
||||
const notes = data.notes || [];
|
||||
const notes = await NotesApi.getNotesList();
|
||||
|
||||
this.store.setNotes(notes);
|
||||
|
||||
@@ -416,9 +293,7 @@ class NotesSidebar {
|
||||
return;
|
||||
} else {
|
||||
// Load from server
|
||||
const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(noteName)}`);
|
||||
const data = await response.json();
|
||||
const content = data.content || '';
|
||||
const content = await NotesApi.getNoteContent(noteName);
|
||||
|
||||
// Store both current and saved content
|
||||
this.store.setNoteContent(noteName, content);
|
||||
@@ -432,21 +307,13 @@ class NotesSidebar {
|
||||
async saveActiveNote() {
|
||||
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 })
|
||||
});
|
||||
await NotesApi.saveNote(this.activeNote, content);
|
||||
|
||||
if (response.ok) {
|
||||
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);
|
||||
} else {
|
||||
console.error('Error saving note');
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -456,19 +323,17 @@ class NotesSidebar {
|
||||
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' });
|
||||
await NotesApi.deleteNote(this.activeNote);
|
||||
|
||||
if (response.ok) {
|
||||
// Clean up tracking for deleted note
|
||||
const deletedNote = 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);
|
||||
// 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();
|
||||
}
|
||||
// Refresh notes list (this will handle setting activeNote correctly)
|
||||
await this.refreshNotes();
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error);
|
||||
}
|
||||
@@ -498,28 +363,22 @@ class NotesSidebar {
|
||||
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 })
|
||||
});
|
||||
await NotesApi.saveNote(newName, currentContent);
|
||||
|
||||
if (createResponse.ok) {
|
||||
// Delete old note
|
||||
await fetch(`/notes_sidebar/note/${encodeURIComponent(this.activeNote)}`, { method: 'DELETE' });
|
||||
// Delete old note
|
||||
await NotesApi.deleteNote(this.activeNote);
|
||||
|
||||
// Update tracking maps through store
|
||||
const oldName = this.activeNote;
|
||||
this.store.renameNoteInState(oldName, newName);
|
||||
// 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);
|
||||
// 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();
|
||||
}
|
||||
// Update active note and refresh
|
||||
this.store.setActiveNote(newName);
|
||||
await this.refreshNotes();
|
||||
} catch (error) {
|
||||
console.error('Error renaming note:', error);
|
||||
}
|
||||
@@ -552,21 +411,15 @@ class NotesSidebar {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/notes_sidebar/note/${encodeURIComponent(noteName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', },
|
||||
body: JSON.stringify({ content: '' })
|
||||
});
|
||||
await NotesApi.saveNote(noteName, '');
|
||||
|
||||
if (response.ok) {
|
||||
await this.refreshNotes();
|
||||
this.store.setActiveNote(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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
@@ -593,171 +446,8 @@ class NotesSidebar {
|
||||
this.store.setUnsavedChanges(this.activeNote, hasUnsavedChanges);
|
||||
}
|
||||
|
||||
toggleLoraMenu() {
|
||||
if (this.loraSubmenu.style.display === 'none') {
|
||||
this.loraSubmenu.style.display = 'block';
|
||||
// Add click listener to close menu when clicking outside
|
||||
document.addEventListener('click', this.closeLoraMenuHandler);
|
||||
} else {
|
||||
this.loraSubmenu.style.display = 'none';
|
||||
document.removeEventListener('click', this.closeLoraMenuHandler);
|
||||
}
|
||||
}
|
||||
|
||||
closeLoraMenuHandler = (event) => {
|
||||
if (!this.loraMenuContainer.contains(event.target)) {
|
||||
this.loraSubmenu.style.display = 'none';
|
||||
document.removeEventListener('click', this.closeLoraMenuHandler);
|
||||
}
|
||||
}
|
||||
|
||||
selectLoraOption(type) {
|
||||
this.loraSubmenu.style.display = 'none';
|
||||
document.removeEventListener('click', this.closeLoraMenuHandler);
|
||||
|
||||
switch(type) {
|
||||
case 'raw':
|
||||
this.appendLorasList();
|
||||
break;
|
||||
case 'tag':
|
||||
this.appendLorasTags();
|
||||
break;
|
||||
case 'meta':
|
||||
this.appendLorasWithMetadata();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async appendLorasList() {
|
||||
try {
|
||||
const response = await fetch('/notes_sidebar/loras');
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.loras) {
|
||||
// Normalize backslashes to forward slashes
|
||||
const normalizedLoras = data.loras.map(lora => lora.replace(/\\/g, '/'));
|
||||
const lorasText = normalizedLoras.join('\n');
|
||||
|
||||
// Add newline before appending if current content doesn't end with one
|
||||
const separator = this.textArea.value && !this.textArea.value.endsWith('\n') ? '\n' : '';
|
||||
const textToInsert = separator + lorasText;
|
||||
|
||||
// Focus textarea to ensure it's active
|
||||
this.textArea.focus();
|
||||
|
||||
// Move cursor to end
|
||||
this.textArea.setSelectionRange(this.textArea.value.length, this.textArea.value.length);
|
||||
|
||||
// Use document.execCommand to preserve undo history
|
||||
document.execCommand('insertText', false, textToInsert);
|
||||
|
||||
// Trigger change handling
|
||||
this.onTextAreaChange();
|
||||
} else {
|
||||
console.error('Error fetching LoRAs:', data.error || 'Unknown error');
|
||||
alert('Failed to fetch LoRAs list');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error appending LoRAs:', error);
|
||||
alert('Failed to append LoRAs list');
|
||||
}
|
||||
}
|
||||
|
||||
async appendLorasTags() {
|
||||
try {
|
||||
const response = await fetch('/notes_sidebar/loras');
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.loras) {
|
||||
// Format each LoRA as <lora:name:1.0>, stripping .safetensors extension and normalizing slashes
|
||||
const lorasTags = data.loras.map(lora => {
|
||||
const nameWithoutExt = lora.replace(/\.safetensors$/i, '');
|
||||
const normalizedPath = nameWithoutExt.replace(/\\/g, '/');
|
||||
return `<lora:${normalizedPath}>`;
|
||||
});
|
||||
const lorasText = lorasTags.join('\n');
|
||||
|
||||
// Add newline before appending if current content doesn't end with one
|
||||
const separator = this.textArea.value && !this.textArea.value.endsWith('\n') ? '\n' : '';
|
||||
const textToInsert = separator + lorasText;
|
||||
|
||||
// Focus textarea to ensure it's active
|
||||
this.textArea.focus();
|
||||
|
||||
// Move cursor to end
|
||||
this.textArea.setSelectionRange(this.textArea.value.length, this.textArea.value.length);
|
||||
|
||||
// Use document.execCommand to preserve undo history
|
||||
document.execCommand('insertText', false, textToInsert);
|
||||
|
||||
// Trigger change handling
|
||||
this.onTextAreaChange();
|
||||
} else {
|
||||
console.error('Error fetching LoRAs:', data.error || 'Unknown error');
|
||||
alert('Failed to fetch LoRAs list');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error appending LoRAs tags:', error);
|
||||
alert('Failed to append LoRAs tags');
|
||||
}
|
||||
}
|
||||
|
||||
async appendLorasWithMetadata() {
|
||||
try {
|
||||
const response = await fetch('/notes_sidebar/loras_with_metadata');
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.loras) {
|
||||
// Format each LoRA with metadata as <lora:name:1.0>, metadata
|
||||
const lorasWithMeta = data.loras.map(lora => {
|
||||
const nameWithoutExt = lora.name.replace(/\.safetensors$/i, '');
|
||||
const normalizedPath = nameWithoutExt.replace(/\\/g, '/');
|
||||
const baseTag = `<lora:${normalizedPath}>`;
|
||||
|
||||
if (lora.metadata) {
|
||||
return `${baseTag}, ${lora.metadata}`;
|
||||
} else {
|
||||
return baseTag;
|
||||
}
|
||||
});
|
||||
const lorasText = lorasWithMeta.join('\n');
|
||||
|
||||
// Add newline before appending if current content doesn't end with one
|
||||
const separator = this.textArea.value && !this.textArea.value.endsWith('\n') ? '\n' : '';
|
||||
const textToInsert = separator + lorasText;
|
||||
|
||||
// Focus textarea to ensure it's active
|
||||
this.textArea.focus();
|
||||
|
||||
// Move cursor to end
|
||||
this.textArea.setSelectionRange(this.textArea.value.length, this.textArea.value.length);
|
||||
|
||||
// Use document.execCommand to preserve undo history
|
||||
document.execCommand('insertText', false, textToInsert);
|
||||
|
||||
// Trigger change handling
|
||||
this.onTextAreaChange();
|
||||
} else {
|
||||
console.error('Error fetching LoRAs with metadata:', data.error || 'Unknown error');
|
||||
alert('Failed to fetch LoRAs with metadata');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error appending LoRAs with metadata:', error);
|
||||
alert('Failed to append LoRAs with metadata');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to validate filename safety
|
||||
function isValidFilename(filename) {
|
||||
if (!filename || filename.trim() === '') return false;
|
||||
|
||||
// Check for unsafe characters: / \\ : * ? " < > |
|
||||
const unsafeChars = /[/\\:*?"<>|]/;
|
||||
if (unsafeChars.test(filename)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const notesSidebar = new NotesSidebar();
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export class NotesStore extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
notes: [],
|
||||
activeNote: null,
|
||||
unsavedChanges: new Map(), // noteName -> boolean
|
||||
noteContent: new Map(), // noteName -> string (current content)
|
||||
savedContent: new Map() // noteName -> string (last saved content)
|
||||
};
|
||||
}
|
||||
|
||||
getState() {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
setState(updates) {
|
||||
const prevState = { ...this.state };
|
||||
this.state = { ...this.state, ...updates };
|
||||
|
||||
// Emit state change event
|
||||
this.dispatchEvent(new CustomEvent('stateChanged', {
|
||||
detail: {
|
||||
prevState,
|
||||
newState: this.getState(),
|
||||
updates
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Convenience methods for common state operations
|
||||
setNotes(notes) {
|
||||
this.setState({ notes });
|
||||
}
|
||||
|
||||
setActiveNote(activeNote) {
|
||||
this.setState({ activeNote });
|
||||
}
|
||||
|
||||
setNoteContent(noteName, content) {
|
||||
const noteContent = new Map(this.state.noteContent);
|
||||
noteContent.set(noteName, content);
|
||||
this.setState({ noteContent });
|
||||
}
|
||||
|
||||
setSavedContent(noteName, content) {
|
||||
const savedContent = new Map(this.state.savedContent);
|
||||
savedContent.set(noteName, content);
|
||||
this.setState({ savedContent });
|
||||
}
|
||||
|
||||
setUnsavedChanges(noteName, hasChanges) {
|
||||
const unsavedChanges = new Map(this.state.unsavedChanges);
|
||||
unsavedChanges.set(noteName, hasChanges);
|
||||
this.setState({ unsavedChanges });
|
||||
}
|
||||
|
||||
deleteNoteFromState(noteName) {
|
||||
const unsavedChanges = new Map(this.state.unsavedChanges);
|
||||
const noteContent = new Map(this.state.noteContent);
|
||||
const savedContent = new Map(this.state.savedContent);
|
||||
|
||||
unsavedChanges.delete(noteName);
|
||||
noteContent.delete(noteName);
|
||||
savedContent.delete(noteName);
|
||||
|
||||
this.setState({ unsavedChanges, noteContent, savedContent });
|
||||
}
|
||||
|
||||
renameNoteInState(oldName, newName) {
|
||||
const unsavedChanges = new Map(this.state.unsavedChanges);
|
||||
const noteContent = new Map(this.state.noteContent);
|
||||
const savedContent = new Map(this.state.savedContent);
|
||||
|
||||
// Transfer state from old name to new name
|
||||
const unsavedState = unsavedChanges.get(oldName) || false;
|
||||
const content = noteContent.get(oldName) || '';
|
||||
const saved = savedContent.get(oldName) || '';
|
||||
|
||||
// Remove old
|
||||
unsavedChanges.delete(oldName);
|
||||
noteContent.delete(oldName);
|
||||
savedContent.delete(oldName);
|
||||
|
||||
// Add new
|
||||
unsavedChanges.set(newName, unsavedState);
|
||||
noteContent.set(newName, content);
|
||||
savedContent.set(newName, saved);
|
||||
|
||||
this.setState({ unsavedChanges, noteContent, savedContent });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Helper function to validate filename safety
|
||||
export function isValidFilename(filename) {
|
||||
if (!filename || filename.trim() === '') return false;
|
||||
|
||||
// Check for unsafe characters: / \\ : * ? " < > |
|
||||
const unsafeChars = /[/\\:*?"<>|]/;
|
||||
if (unsafeChars.test(filename)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user