2025-11-05 16:48:41 +00:00
// NoteDiscovery Frontend Application
// Configuration constants
const CONFIG = {
AUTOSAVE _DELAY : 1000 , // ms - Delay before triggering autosave
SAVE _INDICATOR _DURATION : 2000 , // ms - How long to show "saved" indicator
SCROLL _SYNC _DELAY : 50 , // ms - Delay to prevent scroll sync interference
SCROLL _SYNC _MAX _RETRIES : 10 , // Maximum attempts to find editor/preview elements
SCROLL _SYNC _RETRY _INTERVAL : 100 , // ms - Time between setupScrollSync retries
MAX _UNDO _HISTORY : 50 , // Maximum number of undo steps to keep
DEFAULT _SIDEBAR _WIDTH : 256 , // px - Default sidebar width (w-64 in Tailwind)
} ;
// Centralized error handling
const ErrorHandler = {
/ * *
* Handle errors consistently across the app
* @ param { string } operation - The operation that failed ( e . g . , "load notes" , "save note" )
* @ param { Error } error - The error object
* @ param { boolean } showAlert - Whether to show an alert to the user
* /
handle ( operation , error , showAlert = true ) {
// Always log to console for debugging
console . error ( ` Failed to ${ operation } : ` , error ) ;
// Show user-friendly alert if requested
if ( showAlert ) {
2025-12-14 17:02:57 +00:00
// Note: ErrorHandler doesn't have access to Alpine's t() function
// This message remains in English as a fallback
2025-11-05 16:48:41 +00:00
alert ( ` Failed to ${ operation } . Please try again. ` ) ;
}
}
} ;
function noteApp ( ) {
return {
// App state
appName : 'NoteDiscovery' ,
appTagline : 'Your Self-Hosted Knowledge Base' ,
2025-11-23 15:45:47 +00:00
appVersion : '0.0.0' ,
2025-12-06 17:40:28 +00:00
authEnabled : false ,
2025-11-05 16:48:41 +00:00
notes : [ ] ,
currentNote : '' ,
currentNoteName : '' ,
noteContent : '' ,
viewMode : 'split' , // 'edit', 'split', 'preview'
searchQuery : '' ,
2025-12-04 17:05:41 +00:00
// Graph state (separate overlay, doesn't affect viewMode)
showGraph : false ,
graphInstance : null ,
graphLoaded : false ,
graphData : null ,
2025-11-05 16:48:41 +00:00
searchResults : [ ] ,
currentSearchHighlight : '' , // Track current highlighted search term
currentMatchIndex : 0 , // Current match being viewed
totalMatches : 0 , // Total number of matches in the note
isSaving : false ,
lastSaved : false ,
2025-12-12 15:42:14 +00:00
linkCopied : false ,
2025-12-12 16:18:14 +00:00
zenMode : false ,
previousViewMode : 'split' ,
2025-12-12 17:11:29 +00:00
favorites : [ ] ,
favoritesSet : new Set ( ) , // For O(1) lookups
favoritesExpanded : true ,
2025-11-05 16:48:41 +00:00
saveTimeout : null ,
2025-12-12 17:11:29 +00:00
// Note lookup maps for O(1) wikilink resolution (built on loadNotes)
_noteLookup : {
byPath : new Map ( ) , // path -> true
byPathLower : new Map ( ) , // path.toLowerCase() -> true
byName : new Map ( ) , // name (without .md) -> true
byNameLower : new Map ( ) , // name.toLowerCase() -> true
byEndPath : new Map ( ) , // '/filename' and '/filename.md' -> true
} ,
// Preview rendering debounce
_previewDebounceTimeout : null ,
_lastRenderedContent : '' ,
_cachedRenderedHTML : '' ,
_mathDebounceTimeout : null ,
_mermaidDebounceTimeout : null ,
2025-11-05 16:48:41 +00:00
// Theme state
currentTheme : 'light' ,
availableThemes : [ ] ,
2025-12-14 17:02:57 +00:00
// Locale/i18n state
currentLocale : 'en-US' ,
availableLocales : [ ] ,
// Default translations (loaded inline for immediate availability before API fetch)
translations : {
common : { save : "Save" , cancel : "Cancel" , delete : "Delete" , rename : "Rename" , create : "Create" , close : "Close" , yes : "✓ Yes" , no : "✗ No" , ok : "OK" , error : "Error" , loading : "Loading..." , saved : "✓ Saved" , saving : "Saving..." , copied : "✓ Copied!" } ,
sidebar : { title : "FILES" , favorites _title : "Favorites" , folders _and _notes : "Folders & Notes" , new _button : "+ New" , new _note : "New Note" , new _folder : "New Folder" , new _from _template : "New from Template" , search _placeholder : "Search notes..." , search _hint : "Type to search your notes" , clear _search : "Clear search" , drag _hint : "💡 Drag=Move" , root _folder : "📂 Root folder" , no _notes : "No notes yet" , no _favorites : "No favorites yet" , no _results : "No results found" , expand _all : "Expand all folders" , collapse _all : "Collapse all folders" , toggle _sidebar : "Toggle sidebar" , go _to _homepage : "Go to homepage" , files : "Files" , search : "Search" , search _title : "SEARCH" , settings : "Settings" , settings _title : "SETTINGS" , filtered _notes : "Filtered Notes" } ,
editor : { placeholder : "Start writing in markdown..." , drop _hint : "💡 Drop here to insert at cursor position..." , mode _edit : "Edit" , mode _split : "Split" , mode _preview : "Preview" , edited : "Edited {{time}}" , just _now : "just now" , minutes _ago : "{{count}}m ago" , hours _ago : "{{count}}h ago" , days _ago : "{{count}}d ago" } ,
notes : { confirm _delete : "Delete \"{{name}}\"?" , already _exists : "A note named \"{{name}}\" already exists in this location.\nPlease choose a different name." , prompt _name : "Enter note name:" , prompt _name _in _folder : "Create note in \"{{folder}}\".\nEnter note name:" , prompt _name _with _path : "Enter note name (you can use folder/name):" , prompt _rename : "Enter new name:" , invalid _name : "Invalid note name." , empty _name : "Note name cannot be empty." , not _found : "Note not found: {{path}}" , no _content : "No note content to export" , open _first : "Please open a note first before uploading images." } ,
folders : { confirm _delete : "⚠️ WARNING ⚠️\n\nAre you sure you want to delete the folder \"{{name}}\"?\n\nThis will PERMANENTLY delete:\n• All notes inside this folder\n• All subfolders and their contents\n\nThis action CANNOT be undone!" , already _exists : "A folder named \"{{name}}\" already exists in this location.\nPlease choose a different name." , prompt _name : "Enter folder name:" , prompt _name _in _folder : "Create subfolder in \"{{folder}}\".\nEnter folder name:" , prompt _name _with _path : "Create new folder.\nEnter folder path (e.g., \"Projects\" or \"Work/2025\"):" , prompt _rename : "Rename folder \"{{name}}\" to:" , invalid _name : "Invalid folder name." , cannot _move _into _self : "Cannot move folder into itself or its subfolder." } ,
toolbar : { undo : "Undo (Ctrl+Z)" , redo : "Redo (Ctrl+Y)" , delete _note : "Delete note" , delete _image : "Delete image" , export _html : "Export as HTML" , copy _link : "Copy link to clipboard" , add _favorite : "Add to favorites" , remove _favorite : "Remove from favorites" } ,
zen _mode : { title : "Zen Mode" , tooltip : "Zen Mode (Ctrl+Alt+Z)" , exit : "Exit Zen Mode" , exit _hint : "Exit Zen Mode (Esc)" } ,
tags : { title : "Tags" , no _tags : "No tags found" , clear _all : "Clear tag filters" , filter _by : "Filter by {{tag}} ({{count}} notes)" } ,
stats : { words : "words" , reading _time : "~{{minutes}}m read" , links : "{{count}} links" , click _details : "▼ Click for details" } ,
graph : { title : "Graph View" , empty : "No connections to display" , markdown _links : "Markdown links" , click _hint : "Click: select • Double-click: open" } ,
templates : { title : "Templates" , select : "Select a template..." , prompt _name : "Enter note name:" , no _templates : "No templates available" , create _failed : "Failed to create note from template" } ,
images : { confirm _delete : "Delete image \"{{name}}\"?" , upload _failed : "Failed to upload image" , no _valid _files : "No valid image files found. Supported formats: JPG, PNG, GIF, WEBP" , formats : "Supports JPG, PNG, GIF, WebP (max 10MB)" } ,
move : { failed _note : "Failed to move note." , failed _folder : "Failed to move folder." } ,
search : { previous : "Previous (Shift+F3)" , next : "Next (F3)" , matches : "{{current}} of {{total}}" } ,
theme : { title : "Theme" } ,
language : { title : "Language" } ,
syntax _highlight : { title : "Editor Syntax Highlight" , enable : "Enable" , disable : "Disable" } ,
homepage : { title : "Home" , welcome : "Welcome to NoteDiscovery" , get _started : "Create something to get started" , no _notes _title : "No notes yet" , no _notes _desc : "Create your first note or folder" , folder _empty : "This folder is empty" } ,
export : { failed : "Failed to export HTML: {{error}}" }
} ,
2025-12-09 14:33:49 +00:00
// Syntax highlighting
syntaxHighlightEnabled : false ,
syntaxHighlightTimeout : null ,
2025-12-06 17:06:07 +00:00
// Icon rail / panel state
activePanel : 'files' , // 'files', 'search', 'tags', 'settings'
2025-11-05 16:48:41 +00:00
// Folder state
folderTree : [ ] ,
allFolders : [ ] ,
expandedFolders : new Set ( ) ,
draggedNote : null ,
draggedFolder : null ,
2025-11-18 09:03:47 +00:00
dragOverFolder : null , // Track which folder is being hovered during drag
2025-11-05 16:48:41 +00:00
2025-11-24 16:11:22 +00:00
// Tags state
allTags : { } ,
selectedTags : [ ] ,
tagsExpanded : false ,
tagReloadTimeout : null , // For debouncing tag reloads
2025-11-05 16:48:41 +00:00
// Scroll sync state
isScrolling : false ,
2025-11-20 13:10:17 +00:00
// Unified drag state
draggedItem : null , // { path: string, type: 'note' | 'image' }
dropTarget : null , // 'editor' | 'folder' | null
2025-11-05 16:48:41 +00:00
// Undo/Redo history
undoHistory : [ ] ,
redoHistory : [ ] ,
maxHistorySize : CONFIG . MAX _UNDO _HISTORY ,
isUndoRedo : false ,
// Stats plugin state
statsPluginEnabled : false ,
noteStats : null ,
statsExpanded : false ,
2025-12-04 09:07:07 +00:00
// Note metadata (frontmatter) state
noteMetadata : null ,
metadataExpanded : false ,
_lastFrontmatter : null , // Cache to avoid re-parsing unchanged frontmatter
2025-11-05 16:48:41 +00:00
// Sidebar resize state
sidebarWidth : CONFIG . DEFAULT _SIDEBAR _WIDTH ,
isResizing : false ,
2025-11-13 14:09:26 +00:00
// Mobile sidebar state
mobileSidebarOpen : false ,
2025-11-09 09:17:09 +00:00
// Split view resize state
editorWidth : 50 , // percentage
isResizingSplit : false ,
2025-11-17 13:20:57 +00:00
// Dropdown state
showNewDropdown : false ,
2025-11-27 13:13:39 +00:00
dropdownTargetFolder : null , // Folder context for "New" dropdown ('' = root, null = not set)
2025-11-26 13:38:47 +00:00
dropdownPosition : { top : 0 , left : 0 } , // Position for contextual dropdown
2025-11-17 13:20:57 +00:00
2025-11-26 10:28:09 +00:00
// Template state
showTemplateModal : false ,
availableTemplates : [ ] ,
selectedTemplate : '' ,
newTemplateNoteName : '' ,
2025-11-24 09:04:20 +00:00
// Homepage state
2025-11-20 03:01:27 +00:00
selectedHomepageFolder : '' ,
2025-11-24 09:04:20 +00:00
_homepageCache : {
folderPath : null ,
notes : null ,
folders : null ,
breadcrumb : null
} ,
// Homepage constants
HOMEPAGE _MAX _NOTES : 50 ,
2025-11-20 03:01:27 +00:00
2025-11-24 09:04:20 +00:00
// Computed-like helpers for homepage (cached for performance)
2025-11-20 14:31:36 +00:00
homepageNotes ( ) {
2025-11-24 09:04:20 +00:00
// Return cached result if folder hasn't changed
if ( this . _homepageCache . folderPath === this . selectedHomepageFolder && this . _homepageCache . notes ) {
return this . _homepageCache . notes ;
}
2025-11-20 14:31:36 +00:00
if ( ! this . folderTree || typeof this . folderTree !== 'object' ) {
2025-11-20 03:01:27 +00:00
return [ ] ;
}
2025-11-20 14:31:36 +00:00
const folderNode = this . getFolderNode ( this . selectedHomepageFolder || '' ) ;
2025-11-24 09:04:20 +00:00
const result = ( folderNode && Array . isArray ( folderNode . notes ) ) ? folderNode . notes : [ ] ;
2025-11-20 14:31:36 +00:00
2025-11-24 09:04:20 +00:00
// Cache the result
this . _homepageCache . notes = result ;
this . _homepageCache . folderPath = this . selectedHomepageFolder ;
return result ;
2025-11-20 03:01:27 +00:00
} ,
2025-11-20 14:31:36 +00:00
homepageFolders ( ) {
2025-11-24 09:04:20 +00:00
// Return cached result if folder hasn't changed
if ( this . _homepageCache . folderPath === this . selectedHomepageFolder && this . _homepageCache . folders ) {
return this . _homepageCache . folders ;
}
2025-11-20 14:31:36 +00:00
if ( ! this . folderTree || typeof this . folderTree !== 'object' ) {
2025-11-20 03:01:27 +00:00
return [ ] ;
}
2025-11-24 09:04:20 +00:00
// Get child folders
2025-11-20 14:31:36 +00:00
let childFolders = [ ] ;
2025-11-20 03:01:27 +00:00
if ( ! this . selectedHomepageFolder ) {
2025-11-24 09:04:20 +00:00
// Root level: all top-level folders
2025-11-20 14:31:36 +00:00
childFolders = Object . entries ( this . folderTree )
. filter ( ( [ key ] ) => key !== '__root__' )
. map ( ( [ , folder ] ) => folder ) ;
2025-11-20 03:01:27 +00:00
} else {
2025-11-24 09:04:20 +00:00
// Inside a folder: get its children
2025-11-20 14:31:36 +00:00
const parentFolder = this . getFolderNode ( this . selectedHomepageFolder ) ;
2025-11-24 09:04:20 +00:00
if ( parentFolder && parentFolder . children ) {
childFolders = Object . values ( parentFolder . children ) ;
2025-11-20 14:31:36 +00:00
}
}
2025-11-24 09:04:20 +00:00
// Map to simplified structure (note count already cached in folder node)
const result = childFolders
2025-11-20 14:31:36 +00:00
. map ( folder => ( {
name : folder . name ,
path : folder . path ,
2025-11-24 09:04:20 +00:00
noteCount : folder . noteCount || 0 // Use pre-calculated count
2025-11-20 14:31:36 +00:00
} ) )
. sort ( ( a , b ) => a . name . toLowerCase ( ) . localeCompare ( b . name . toLowerCase ( ) ) ) ;
2025-11-24 09:04:20 +00:00
// Cache the result
this . _homepageCache . folders = result ;
this . _homepageCache . folderPath = this . selectedHomepageFolder ;
return result ;
2025-11-20 03:01:27 +00:00
} ,
2025-11-20 14:31:36 +00:00
homepageBreadcrumb ( ) {
2025-11-24 09:04:20 +00:00
// Return cached result if folder hasn't changed
if ( this . _homepageCache . folderPath === this . selectedHomepageFolder && this . _homepageCache . breadcrumb ) {
return this . _homepageCache . breadcrumb ;
}
2025-12-14 17:02:57 +00:00
const breadcrumb = [ { name : this . t ( 'homepage.title' ) , path : '' } ] ;
2025-11-24 09:04:20 +00:00
if ( this . selectedHomepageFolder ) {
const parts = this . selectedHomepageFolder . split ( '/' ) . filter ( Boolean ) ;
2025-11-20 03:01:27 +00:00
let currentPath = '' ;
2025-11-24 09:04:20 +00:00
2025-11-20 03:01:27 +00:00
parts . forEach ( part => {
2025-11-24 09:04:20 +00:00
currentPath = currentPath ? ` ${ currentPath } / ${ part } ` : part ;
2025-11-20 03:01:27 +00:00
breadcrumb . push ( { name : part , path : currentPath } ) ;
} ) ;
}
2025-11-24 09:04:20 +00:00
// Cache the result
this . _homepageCache . breadcrumb = breadcrumb ;
this . _homepageCache . folderPath = this . selectedHomepageFolder ;
return breadcrumb ;
} ,
// Helper: Format file size nicely
formatSize ( bytes ) {
if ( ! bytes ) return '0 B' ;
const k = 1024 ;
const sizes = [ 'B' , 'KB' , 'MB' , 'GB' ] ;
const i = Math . floor ( Math . log ( bytes ) / Math . log ( k ) ) ;
return parseFloat ( ( bytes / Math . pow ( k , i ) ) . toFixed ( 1 ) ) + ' ' + sizes [ i ] ;
2025-11-20 14:31:36 +00:00
} ,
2025-12-14 17:02:57 +00:00
// Helper: Format date using current locale
formatDate ( dateStr ) {
if ( ! dateStr ) return '' ;
const date = new Date ( dateStr ) ;
if ( isNaN ( date . getTime ( ) ) ) return '' ;
return date . toLocaleDateString ( this . currentLocale , {
year : 'numeric' ,
month : 'short' ,
day : 'numeric'
} ) ;
} ,
2025-11-20 14:31:36 +00:00
getFolderNode ( folderPath = '' ) {
if ( ! this . folderTree || typeof this . folderTree !== 'object' ) {
return null ;
}
if ( ! folderPath ) {
2025-11-24 09:04:20 +00:00
return this . folderTree [ '__root__' ] || { name : '' , path : '' , children : { } , notes : [ ] , noteCount : 0 } ;
2025-11-20 14:31:36 +00:00
}
const parts = folderPath . split ( '/' ) . filter ( Boolean ) ;
let currentLevel = this . folderTree ;
let node = null ;
for ( const part of parts ) {
if ( ! currentLevel [ part ] ) {
return null ;
}
node = currentLevel [ part ] ;
currentLevel = node . children || { } ;
}
return node ;
} ,
// Check if app is empty (no notes and no folders)
get isAppEmpty ( ) {
const notesArray = Array . isArray ( this . notes ) ? this . notes : [ ] ;
const foldersArray = Array . isArray ( this . allFolders ) ? this . allFolders : [ ] ;
return notesArray . length === 0 && foldersArray . length === 0 ;
2025-11-20 03:01:27 +00:00
} ,
2025-11-17 16:29:00 +00:00
// Mermaid state cache
lastMermaidTheme : null ,
2025-11-20 13:10:17 +00:00
// Image viewer state
currentImage : '' ,
2025-11-05 16:48:41 +00:00
// DOM element cache (to avoid repeated querySelector calls)
_domCache : {
editor : null ,
previewContainer : null ,
previewContent : null
} ,
// Initialize app
async init ( ) {
2025-12-10 08:41:54 +00:00
// Prevent double initialization (Alpine.js may call x-init twice in some cases)
if ( window . _ _noteapp _initialized ) return ;
window . _ _noteapp _initialized = true ;
2025-12-09 11:08:12 +00:00
// Store global reference for native event handlers in x-html content
window . $root = this ;
// ESC key to cancel drag operations
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' && ( this . draggedNote || this . draggedFolder || this . draggedItem ) ) {
this . cancelDrag ( ) ;
}
} ) ;
2025-11-05 16:48:41 +00:00
await this . loadConfig ( ) ;
await this . loadThemes ( ) ;
await this . initTheme ( ) ;
2025-12-14 17:02:57 +00:00
await this . loadAvailableLocales ( ) ;
await this . loadLocale ( ) ;
2025-11-05 16:48:41 +00:00
await this . loadNotes ( ) ;
2025-11-26 10:28:09 +00:00
await this . loadTemplates ( ) ;
2025-11-05 16:48:41 +00:00
await this . checkStatsPlugin ( ) ;
this . loadSidebarWidth ( ) ;
2025-11-09 09:17:09 +00:00
this . loadEditorWidth ( ) ;
2025-11-05 16:48:41 +00:00
this . loadViewMode ( ) ;
2025-11-24 16:11:22 +00:00
this . loadTagsExpanded ( ) ;
2025-12-12 17:11:29 +00:00
this . loadFavorites ( ) ;
this . loadFavoritesExpanded ( ) ;
2025-12-09 14:33:49 +00:00
this . loadSyntaxHighlightSetting ( ) ;
2025-11-05 16:48:41 +00:00
// Parse URL and load specific note if provided
this . loadNoteFromURL ( ) ;
2025-11-24 16:23:05 +00:00
// Set initial homepage state ONLY if we're actually on the homepage
if ( window . location . pathname === '/' ) {
2025-11-20 03:01:27 +00:00
window . history . replaceState ( { homepageFolder : '' } , '' , '/' ) ;
}
2025-11-05 16:48:41 +00:00
// Listen for browser back/forward navigation
window . addEventListener ( 'popstate' , ( e ) => {
if ( e . state && e . state . notePath ) {
2025-11-20 03:01:27 +00:00
// Navigating to a note
2025-11-05 16:48:41 +00:00
const searchQuery = e . state . searchQuery || '' ;
this . loadNote ( e . state . notePath , false , searchQuery ) ; // false = don't update history
// Update search box and trigger search if needed
if ( searchQuery ) {
this . searchQuery = searchQuery ;
this . searchNotes ( ) ;
} else {
this . searchQuery = '' ;
this . searchResults = [ ] ;
this . clearSearchHighlights ( ) ;
}
2025-11-20 03:01:27 +00:00
} else {
// Navigating back to homepage
this . currentNote = '' ;
this . noteContent = '' ;
this . currentNoteName = '' ;
// Restore homepage folder state if it was saved
if ( e . state && e . state . homepageFolder !== undefined ) {
this . selectedHomepageFolder = e . state . homepageFolder || '' ;
} else {
// No folder state in history, go to root
this . selectedHomepageFolder = '' ;
}
2025-11-24 09:04:20 +00:00
// Invalidate cache to force recalculation
this . _homepageCache = {
folderPath : null ,
notes : null ,
folders : null ,
breadcrumb : null
} ;
2025-11-20 03:01:27 +00:00
// Clear search
this . searchQuery = '' ;
this . searchResults = [ ] ;
this . clearSearchHighlights ( ) ;
2025-11-05 16:48:41 +00:00
}
} ) ;
// Cache DOM references after initial render
this . $nextTick ( ( ) => {
this . refreshDOMCache ( ) ;
} ) ;
2025-11-13 14:09:26 +00:00
// Setup mobile view mode handler
this . setupMobileViewMode ( ) ;
2025-11-05 16:48:41 +00:00
// Watch view mode changes and auto-save
this . $watch ( 'viewMode' , ( newValue ) => {
this . saveViewMode ( ) ;
// Scroll to top when switching modes
this . $nextTick ( ( ) => {
this . scrollToTop ( ) ;
} ) ;
} ) ;
// Watch for changes in note content to re-apply search highlights
this . $watch ( 'noteContent' , ( ) => {
if ( this . currentSearchHighlight ) {
// Re-apply highlights after content changes (with small delay for render)
this . $nextTick ( ( ) => {
setTimeout ( ( ) => {
// Don't focus editor during content changes (false)
this . highlightSearchTerm ( this . currentSearchHighlight , false ) ;
} , 50 ) ;
} ) ;
}
} ) ;
2025-11-24 16:11:22 +00:00
// Watch tags panel expanded state and save to localStorage
this . $watch ( 'tagsExpanded' , ( ) => {
this . saveTagsExpanded ( ) ;
} ) ;
2025-12-12 17:11:29 +00:00
// Watch favorites expanded state and save to localStorage
this . $watch ( 'favoritesExpanded' , ( ) => {
this . saveFavoritesExpanded ( ) ;
} ) ;
2025-11-05 16:48:41 +00:00
// Setup keyboard shortcuts (only once to prevent double triggers)
if ( ! window . _ _noteapp _shortcuts _initialized ) {
window . _ _noteapp _shortcuts _initialized = true ;
window . addEventListener ( 'keydown' , ( e ) => {
2025-12-14 14:33:53 +00:00
// Use e.code for all letter keys for consistency across keyboard layouts
2025-11-05 16:48:41 +00:00
// Ctrl/Cmd + S to save
2025-12-14 14:33:53 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . code === 'KeyS' ) {
2025-11-05 16:48:41 +00:00
e . preventDefault ( ) ;
this . saveNote ( ) ;
}
2025-12-14 14:33:53 +00:00
// Ctrl/Cmd + Alt/Option + N for new note
if ( ( e . ctrlKey || e . metaKey ) && e . altKey && e . code === 'KeyN' ) {
2025-11-05 16:48:41 +00:00
e . preventDefault ( ) ;
2025-11-17 13:24:40 +00:00
this . createNote ( ) ;
2025-11-05 16:48:41 +00:00
}
2025-12-14 14:33:53 +00:00
// Ctrl/Cmd + Alt/Option + F for new folder
if ( ( e . ctrlKey || e . metaKey ) && e . altKey && e . code === 'KeyF' ) {
2025-11-17 13:38:07 +00:00
e . preventDefault ( ) ;
this . createFolder ( ) ;
}
2025-12-14 14:33:53 +00:00
// Ctrl/Cmd + Z for undo (without shift or alt)
if ( ( e . ctrlKey || e . metaKey ) && ! e . shiftKey && ! e . altKey && e . code === 'KeyZ' ) {
2025-11-05 16:48:41 +00:00
e . preventDefault ( ) ;
this . undo ( ) ;
}
2025-12-14 14:33:53 +00:00
// Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo
if ( ( e . ctrlKey || e . metaKey ) && e . code === 'KeyY' ) {
e . preventDefault ( ) ;
this . redo ( ) ;
}
if ( ( e . ctrlKey || e . metaKey ) && e . shiftKey && ! e . altKey && e . code === 'KeyZ' ) {
2025-11-05 16:48:41 +00:00
e . preventDefault ( ) ;
this . redo ( ) ;
}
// F3 for next search match
2025-12-14 14:33:53 +00:00
if ( e . code === 'F3' && ! e . shiftKey ) {
2025-11-05 16:48:41 +00:00
e . preventDefault ( ) ;
this . nextMatch ( ) ;
}
// Shift + F3 for previous search match
2025-12-14 14:33:53 +00:00
if ( e . code === 'F3' && e . shiftKey ) {
2025-11-05 16:48:41 +00:00
e . preventDefault ( ) ;
this . previousMatch ( ) ;
}
2025-11-24 09:17:21 +00:00
// Only apply markdown shortcuts when editor is focused and a note is open
const isEditorFocused = document . activeElement ? . id === 'note-editor' ;
if ( isEditorFocused && this . currentNote ) {
// Ctrl/Cmd + B for bold
2025-12-14 14:33:53 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . code === 'KeyB' ) {
2025-11-24 09:17:21 +00:00
e . preventDefault ( ) ;
this . wrapSelection ( '**' , '**' , 'bold text' ) ;
}
// Ctrl/Cmd + I for italic
2025-12-14 14:33:53 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . code === 'KeyI' ) {
2025-11-24 09:17:21 +00:00
e . preventDefault ( ) ;
this . wrapSelection ( '*' , '*' , 'italic text' ) ;
}
// Ctrl/Cmd + K for link
2025-12-14 14:33:53 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . code === 'KeyK' ) {
2025-11-24 09:17:21 +00:00
e . preventDefault ( ) ;
this . insertLink ( ) ;
}
2025-12-11 17:11:25 +00:00
2025-12-14 14:33:53 +00:00
// Ctrl/Cmd + Alt/Option + T for table
if ( ( e . ctrlKey || e . metaKey ) && e . altKey && e . code === 'KeyT' ) {
2025-12-11 17:11:25 +00:00
e . preventDefault ( ) ;
this . insertTable ( ) ;
}
2025-12-12 16:18:14 +00:00
2025-12-14 14:33:53 +00:00
// Ctrl/Cmd + Alt/Option + Z for Zen mode
if ( ( e . ctrlKey || e . metaKey ) && e . altKey && e . code === 'KeyZ' ) {
2025-12-12 16:18:14 +00:00
e . preventDefault ( ) ;
this . toggleZenMode ( ) ;
}
}
// Escape to exit Zen mode (works anywhere)
if ( e . key === 'Escape' && this . zenMode ) {
e . preventDefault ( ) ;
this . toggleZenMode ( ) ;
2025-11-24 09:17:21 +00:00
}
2025-11-05 16:48:41 +00:00
} ) ;
}
// Note: setupScrollSync() is called when a note is loaded (see loadNote())
// Listen for system theme changes
if ( window . matchMedia ) {
window . matchMedia ( '(prefers-color-scheme: dark)' ) . addEventListener ( 'change' , ( e ) => {
if ( this . currentTheme === 'system' ) {
this . applyTheme ( 'system' ) ;
}
} ) ;
}
2025-12-12 16:18:14 +00:00
// Listen for fullscreen changes (to sync zen mode state)
document . addEventListener ( 'fullscreenchange' , ( ) => {
if ( ! document . fullscreenElement && this . zenMode ) {
// User exited fullscreen manually, exit zen mode too
this . zenMode = false ;
this . viewMode = this . previousViewMode ;
}
} ) ;
2025-11-05 16:48:41 +00:00
} ,
// Load app configuration
async loadConfig ( ) {
try {
const response = await fetch ( '/api/config' ) ;
const config = await response . json ( ) ;
this . appName = config . name ;
this . appTagline = config . tagline ;
2025-11-23 15:45:47 +00:00
this . appVersion = config . version || '0.0.0' ;
2025-12-06 17:40:28 +00:00
this . authEnabled = config . authentication ? . enabled || false ;
2025-11-05 16:48:41 +00:00
} catch ( error ) {
console . error ( 'Failed to load config:' , error ) ;
}
} ,
// Load available themes from backend
async loadThemes ( ) {
try {
const response = await fetch ( '/api/themes' ) ;
const data = await response . json ( ) ;
// Use theme names directly from backend (already include emojis)
this . availableThemes = data . themes ;
} catch ( error ) {
console . error ( 'Failed to load themes:' , error ) ;
// Fallback to default themes
this . availableThemes = [
{ id : 'light' , name : '🌞 Light' } ,
{ id : 'dark' , name : '🌙 Dark' }
] ;
}
} ,
// Initialize theme system
async initTheme ( ) {
// Load saved theme preference from localStorage
const savedTheme = localStorage . getItem ( 'noteDiscoveryTheme' ) || 'light' ;
this . currentTheme = savedTheme ;
await this . applyTheme ( savedTheme ) ;
} ,
// Set and apply theme
async setTheme ( themeId ) {
this . currentTheme = themeId ;
localStorage . setItem ( 'noteDiscoveryTheme' , themeId ) ;
await this . applyTheme ( themeId ) ;
} ,
2025-12-09 14:33:49 +00:00
// Syntax highlighting toggle
toggleSyntaxHighlight ( ) {
this . syntaxHighlightEnabled = ! this . syntaxHighlightEnabled ;
localStorage . setItem ( 'syntaxHighlightEnabled' , this . syntaxHighlightEnabled ) ;
if ( this . syntaxHighlightEnabled ) {
this . updateSyntaxHighlight ( ) ;
}
} ,
loadSyntaxHighlightSetting ( ) {
try {
const saved = localStorage . getItem ( 'syntaxHighlightEnabled' ) ;
this . syntaxHighlightEnabled = saved === 'true' ;
} catch ( error ) {
console . error ( 'Error loading syntax highlight setting:' , error ) ;
}
} ,
// Update syntax highlight overlay (debounced, called on input)
updateSyntaxHighlight ( ) {
if ( ! this . syntaxHighlightEnabled ) return ;
clearTimeout ( this . syntaxHighlightTimeout ) ;
this . syntaxHighlightTimeout = setTimeout ( ( ) => {
const overlay = document . getElementById ( 'syntax-overlay' ) ;
if ( overlay ) {
overlay . innerHTML = this . highlightMarkdown ( this . noteContent ) ;
}
} , 50 ) ; // 50ms debounce
} ,
// Sync overlay scroll with textarea
syncOverlayScroll ( ) {
const textarea = document . getElementById ( 'note-editor' ) ;
const overlay = document . getElementById ( 'syntax-overlay' ) ;
if ( textarea && overlay ) {
overlay . scrollTop = textarea . scrollTop ;
overlay . scrollLeft = textarea . scrollLeft ;
}
} ,
// Highlight markdown syntax
highlightMarkdown ( text ) {
if ( ! text ) return '' ;
// Escape HTML first
let html = this . escapeHtml ( text ) ;
2025-12-10 08:10:58 +00:00
// Store code blocks and inline code with placeholders to protect from other patterns
const codePlaceholders = [ ] ;
2025-12-09 14:33:49 +00:00
2025-12-13 11:24:43 +00:00
// Code blocks FIRST - protect them before anything else
2025-12-10 08:10:58 +00:00
html = html . replace ( /(```[\s\S]*?```)/g , ( match ) => {
codePlaceholders . push ( '<span class="md-codeblock">' + match + '</span>' ) ;
return ` \x 00CODE ${ codePlaceholders . length - 1 } \x 00 ` ;
} ) ;
2025-12-13 11:24:43 +00:00
// Frontmatter (must be at VERY start of document, not any line)
if ( html . startsWith ( '---\n' ) ) {
html = html . replace ( /^(---\n[\s\S]*?\n---)/ , ( match ) => {
codePlaceholders . push ( '<span class="md-frontmatter">' + match + '</span>' ) ;
return ` \x 00CODE ${ codePlaceholders . length - 1 } \x 00 ` ;
} ) ;
}
2025-12-10 08:10:58 +00:00
// Inline code - protect it
html = html . replace ( /`([^`\n]+)`/g , ( match ) => {
codePlaceholders . push ( '<span class="md-code">' + match + '</span>' ) ;
return ` \x 00CODE ${ codePlaceholders . length - 1 } \x 00 ` ;
} ) ;
// Now apply other patterns (they won't match inside protected code)
2025-12-09 14:33:49 +00:00
// Headings
html = html . replace ( /^(#{1,6})\s(.*)$/gm , '<span class="md-heading">$1 $2</span>' ) ;
// Bold (must come before italic)
html = html . replace ( /\*\*([^*]+)\*\*/g , '<span class="md-bold">**$1**</span>' ) ;
html = html . replace ( /__([^_]+)__/g , '<span class="md-bold">__$1__</span>' ) ;
// Italic
html = html . replace ( /(?<![*\\])\*([^*\n]+)\*(?!\*)/g , '<span class="md-italic">*$1*</span>' ) ;
html = html . replace ( /(?<![_\\])_([^_\n]+)_(?!_)/g , '<span class="md-italic">_$1_</span>' ) ;
// Wikilinks [[...]]
html = html . replace ( /\[\[([^\]]+)\]\]/g , '<span class="md-wikilink">[[$1]]</span>' ) ;
// Links [text](url)
html = html . replace ( /\[([^\]]+)\]\(([^)]+)\)/g , '<span class="md-link">[$1]</span><span class="md-link-url">($2)</span>' ) ;
// Lists
html = html . replace ( /^(\s*)([-*+])\s/gm , '$1<span class="md-list">$2</span> ' ) ;
html = html . replace ( /^(\s*)(\d+\.)\s/gm , '$1<span class="md-list">$2</span> ' ) ;
// Blockquotes
html = html . replace ( /^(>.*)$/gm , '<span class="md-blockquote">$1</span>' ) ;
// Horizontal rules
html = html . replace ( /^([-*_]{3,})$/gm , '<span class="md-hr">$1</span>' ) ;
2025-12-10 08:10:58 +00:00
// Restore protected code blocks
html = html . replace ( /\x00CODE(\d+)\x00/g , ( match , index ) => codePlaceholders [ parseInt ( index ) ] ) ;
2025-12-14 11:19:49 +00:00
// Add trailing space to match textarea's phantom line for cursor
// This ensures the overlay and textarea have the same content height
html += '\n ' ;
2025-12-09 14:33:49 +00:00
return html ;
} ,
2025-11-05 16:48:41 +00:00
// Apply theme to document
async applyTheme ( themeId ) {
// Load theme CSS from file
try {
const response = await fetch ( ` /api/themes/ ${ themeId } ` ) ;
const data = await response . json ( ) ;
// Create or update style element
let styleEl = document . getElementById ( 'dynamic-theme' ) ;
if ( ! styleEl ) {
styleEl = document . createElement ( 'style' ) ;
styleEl . id = 'dynamic-theme' ;
document . head . appendChild ( styleEl ) ;
}
styleEl . textContent = data . css ;
// Set data attribute for theme-specific selectors
document . documentElement . setAttribute ( 'data-theme' , themeId ) ;
// Load appropriate Highlight.js theme for code syntax highlighting
const highlightTheme = document . getElementById ( 'highlight-theme' ) ;
if ( highlightTheme ) {
if ( themeId === 'light' ) {
highlightTheme . href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css' ;
} else {
// Use dark theme for dark/custom themes
highlightTheme . href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css' ;
}
}
2025-11-17 16:29:00 +00:00
// Re-render Mermaid diagrams with new theme if there's a current note
if ( this . currentNote ) {
// Small delay to allow theme CSS to load
setTimeout ( ( ) => {
// Clear existing Mermaid renders
const previewContent = document . querySelector ( '.markdown-preview' ) ;
if ( previewContent ) {
const mermaidContainers = previewContent . querySelectorAll ( '.mermaid-rendered' ) ;
mermaidContainers . forEach ( container => {
// Replace with the original code block for re-rendering
const parent = container . parentElement ;
if ( parent && container . dataset . originalCode ) {
const pre = document . createElement ( 'pre' ) ;
const code = document . createElement ( 'code' ) ;
code . className = 'language-mermaid' ;
code . textContent = container . dataset . originalCode ;
pre . appendChild ( code ) ;
parent . replaceChild ( pre , container ) ;
}
} ) ;
}
// Re-render with new theme
this . renderMermaid ( ) ;
} , 100 ) ;
}
2025-12-04 17:05:41 +00:00
// Refresh graph if visible (longer delay to ensure CSS is applied)
if ( this . showGraph ) {
setTimeout ( ( ) => this . initGraph ( ) , 300 ) ;
}
2025-11-05 16:48:41 +00:00
} catch ( error ) {
console . error ( 'Failed to load theme:' , error ) ;
}
} ,
2025-12-14 17:02:57 +00:00
// ==================== INTERNATIONALIZATION ====================
// Translation function - get translated string by key
t ( key , params = { } ) {
const keys = key . split ( '.' ) ;
let value = this . translations ;
for ( const k of keys ) {
value = value ? . [ k ] ;
}
// Fallback to key if translation not found (silently - default translations are inline)
if ( typeof value !== 'string' ) {
return key ;
}
// Replace {{param}} placeholders
return value . replace ( /\{\{(\w+)\}\}/g , ( _ , name ) => params [ name ] ? ? ` {{ ${ name } }} ` ) ;
} ,
// Load available locales from backend
async loadAvailableLocales ( ) {
try {
const response = await fetch ( '/api/locales' ) ;
const data = await response . json ( ) ;
this . availableLocales = data . locales || [ ] ;
} catch ( error ) {
console . error ( 'Failed to load available locales:' , error ) ;
this . availableLocales = [ { code : 'en-US' , name : 'English' , flag : '🇺🇸' } ] ;
}
} ,
// Load translations for a specific locale
async loadLocale ( localeCode = null ) {
const targetLocale = localeCode || localStorage . getItem ( 'locale' ) || 'en-US' ;
try {
const response = await fetch ( ` /api/locales/ ${ targetLocale } ` ) ;
if ( response . ok ) {
this . translations = await response . json ( ) ;
this . currentLocale = targetLocale ;
localStorage . setItem ( 'locale' , targetLocale ) ;
} else if ( targetLocale !== 'en-US' ) {
// Fallback to en-US if requested locale not found
await this . loadLocale ( 'en-US' ) ;
}
} catch ( error ) {
console . error ( 'Failed to load locale:' , error ) ;
// If en-US also fails, translations will be empty and t() will return keys
if ( targetLocale !== 'en-US' ) {
await this . loadLocale ( 'en-US' ) ;
}
}
} ,
// Change locale and reload translations
async changeLocale ( localeCode ) {
await this . loadLocale ( localeCode ) ;
} ,
// ==================== END INTERNATIONALIZATION ====================
2025-11-05 16:48:41 +00:00
// Load all notes
async loadNotes ( ) {
try {
const response = await fetch ( '/api/notes' ) ;
const data = await response . json ( ) ;
this . notes = data . notes ;
this . allFolders = data . folders || [ ] ;
2025-12-12 17:11:29 +00:00
this . buildNoteLookupMaps ( ) ; // Build O(1) lookup maps
2025-11-05 16:48:41 +00:00
this . buildFolderTree ( ) ;
2025-11-24 16:11:22 +00:00
await this . loadTags ( ) ; // Load tags after notes are loaded
2025-11-05 16:48:41 +00:00
} catch ( error ) {
ErrorHandler . handle ( 'load notes' , error ) ;
}
} ,
2025-12-12 17:11:29 +00:00
// Build lookup maps for O(1) wikilink resolution
buildNoteLookupMaps ( ) {
// Clear existing maps
this . _noteLookup . byPath . clear ( ) ;
this . _noteLookup . byPathLower . clear ( ) ;
this . _noteLookup . byName . clear ( ) ;
this . _noteLookup . byNameLower . clear ( ) ;
this . _noteLookup . byEndPath . clear ( ) ;
for ( const note of this . notes ) {
const path = note . path ;
const pathLower = path . toLowerCase ( ) ;
const name = note . name ;
const nameLower = name . toLowerCase ( ) ;
const nameWithoutMd = name . replace ( /\.md$/i , '' ) ;
const nameWithoutMdLower = nameWithoutMd . toLowerCase ( ) ;
// Store all variations for fast lookup
this . _noteLookup . byPath . set ( path , true ) ;
this . _noteLookup . byPath . set ( path . replace ( /\.md$/i , '' ) , true ) ;
this . _noteLookup . byPathLower . set ( pathLower , true ) ;
this . _noteLookup . byPathLower . set ( pathLower . replace ( /\.md$/i , '' ) , true ) ;
this . _noteLookup . byName . set ( name , true ) ;
this . _noteLookup . byName . set ( nameWithoutMd , true ) ;
this . _noteLookup . byNameLower . set ( nameLower , true ) ;
this . _noteLookup . byNameLower . set ( nameWithoutMdLower , true ) ;
// End path matching (for /folder/note style links)
this . _noteLookup . byEndPath . set ( '/' + nameWithoutMdLower , true ) ;
this . _noteLookup . byEndPath . set ( '/' + nameLower , true ) ;
}
} ,
// Fast O(1) check if a wikilink target exists
wikiLinkExists ( linkTarget ) {
const targetLower = linkTarget . toLowerCase ( ) ;
// Check all lookup maps
return (
this . _noteLookup . byPath . has ( linkTarget ) ||
this . _noteLookup . byPath . has ( linkTarget + '.md' ) ||
this . _noteLookup . byPathLower . has ( targetLower ) ||
this . _noteLookup . byPathLower . has ( targetLower + '.md' ) ||
this . _noteLookup . byName . has ( linkTarget ) ||
this . _noteLookup . byNameLower . has ( targetLower ) ||
this . _noteLookup . byEndPath . has ( '/' + targetLower ) ||
this . _noteLookup . byEndPath . has ( '/' + targetLower + '.md' )
) ;
} ,
2025-11-24 16:11:22 +00:00
// Load all tags
async loadTags ( ) {
try {
const response = await fetch ( '/api/tags' ) ;
const data = await response . json ( ) ;
this . allTags = data . tags || { } ;
} catch ( error ) {
ErrorHandler . handle ( 'load tags' , error , false ) ; // Don't show alert, tags are optional
}
} ,
// Debounced tag reload (prevents excessive API calls during typing)
loadTagsDebounced ( ) {
// Clear existing timeout
if ( this . tagReloadTimeout ) {
clearTimeout ( this . tagReloadTimeout ) ;
}
// Set new timeout - reload tags 2 seconds after last save
this . tagReloadTimeout = setTimeout ( ( ) => {
this . loadTags ( ) ;
} , 2000 ) ;
} ,
// Toggle tag selection for filtering
toggleTag ( tag ) {
const index = this . selectedTags . indexOf ( tag ) ;
if ( index > - 1 ) {
this . selectedTags . splice ( index , 1 ) ;
} else {
this . selectedTags . push ( tag ) ;
}
// Apply unified filtering
this . applyFilters ( ) ;
} ,
2025-11-26 10:28:09 +00:00
// ========================================================================
// Template Methods
// ========================================================================
// Load available templates from _templates folder
async loadTemplates ( ) {
try {
const response = await fetch ( '/api/templates' ) ;
const data = await response . json ( ) ;
this . availableTemplates = data . templates || [ ] ;
} catch ( error ) {
ErrorHandler . handle ( 'load templates' , error , false ) ; // Don't show alert, templates are optional
}
} ,
// Create a new note from a template
async createNoteFromTemplate ( ) {
if ( ! this . selectedTemplate || ! this . newTemplateNoteName . trim ( ) ) {
return ;
}
try {
2025-11-26 11:43:37 +00:00
// Determine the note path based on dropdown context
2025-11-26 10:28:09 +00:00
let notePath = this . newTemplateNoteName . trim ( ) ;
if ( ! notePath . endsWith ( '.md' ) ) {
notePath += '.md' ;
}
2025-11-27 13:13:39 +00:00
// Determine target folder: use dropdown context if set, otherwise homepage folder
let targetFolder ;
if ( this . dropdownTargetFolder !== null && this . dropdownTargetFolder !== undefined ) {
targetFolder = this . dropdownTargetFolder ; // Can be '' for root or a folder path
} else {
targetFolder = this . selectedHomepageFolder || '' ;
}
// If we have a target folder, create note in that folder
if ( targetFolder ) {
2025-11-26 11:43:37 +00:00
notePath = ` ${ targetFolder } / ${ notePath } ` ;
2025-11-26 10:28:09 +00:00
}
2025-11-26 18:20:02 +00:00
// CRITICAL: Check if note already exists
const existingNote = this . notes . find ( note => note . path === notePath ) ;
if ( existingNote ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'notes.already_exists' , { name : this . newTemplateNoteName . trim ( ) } ) ) ;
2025-11-26 18:20:02 +00:00
return ;
}
2025-11-26 10:28:09 +00:00
// Create note from template
const response = await fetch ( '/api/templates/create-note' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
templateName : this . selectedTemplate ,
notePath : notePath
} )
} ) ;
if ( ! response . ok ) {
const error = await response . json ( ) ;
2025-12-14 17:02:57 +00:00
alert ( error . detail || this . t ( 'templates.create_failed' ) ) ;
2025-11-26 10:28:09 +00:00
return ;
}
const data = await response . json ( ) ;
// Close modal and reset state
this . showTemplateModal = false ;
this . selectedTemplate = '' ;
this . newTemplateNoteName = '' ;
// Reload notes and open the new note
await this . loadNotes ( ) ;
await this . loadNote ( data . path ) ;
} catch ( error ) {
ErrorHandler . handle ( 'create note from template' , error ) ;
}
} ,
2025-11-24 16:11:22 +00:00
// Clear all tag filters
clearTagFilters ( ) {
this . selectedTags = [ ] ;
// Apply unified filtering
this . applyFilters ( ) ;
} ,
// Unified filtering logic combining tags and text search
async applyFilters ( ) {
const hasTextSearch = this . searchQuery . trim ( ) . length > 0 ;
const hasTagFilter = this . selectedTags . length > 0 ;
// Case 1: No filters at all → show full folder tree
if ( ! hasTextSearch && ! hasTagFilter ) {
this . searchResults = [ ] ;
this . currentSearchHighlight = '' ;
this . clearSearchHighlights ( ) ;
this . buildFolderTree ( ) ;
return ;
}
// Case 2: Only tag filter → convert to flat list of matching notes
if ( hasTagFilter && ! hasTextSearch ) {
this . searchResults = this . notes . filter ( note =>
note . type === 'note' && this . noteMatchesTags ( note )
) ;
this . currentSearchHighlight = '' ;
this . clearSearchHighlights ( ) ;
return ;
}
// Case 3: Text search (with or without tag filter)
if ( hasTextSearch ) {
try {
const response = await fetch ( ` /api/search?q= ${ encodeURIComponent ( this . searchQuery ) } ` ) ;
const data = await response . json ( ) ;
// Apply tag filtering to search results if tags are selected
let results = data . results ;
if ( hasTagFilter ) {
results = results . filter ( result => {
const note = this . notes . find ( n => n . path === result . path ) ;
return note ? this . noteMatchesTags ( note ) : false ;
} ) ;
}
this . searchResults = results ;
// Highlight search term in current note if open
if ( this . currentNote && this . noteContent ) {
this . currentSearchHighlight = this . searchQuery ;
this . $nextTick ( ( ) => {
this . highlightSearchTerm ( this . searchQuery , false ) ;
} ) ;
}
} catch ( error ) {
console . error ( 'Search failed:' , error ) ;
}
}
} ,
// Check if a note matches selected tags (AND logic)
noteMatchesTags ( note ) {
if ( this . selectedTags . length === 0 ) {
return true ; // No filter active
}
if ( ! note . tags || note . tags . length === 0 ) {
return false ; // Note has no tags but filter is active
}
// Check if note has ALL selected tags (AND logic)
return this . selectedTags . every ( tag => note . tags . includes ( tag ) ) ;
} ,
// Get all tags sorted by name
get sortedTags ( ) {
return Object . entries ( this . allTags ) . sort ( ( a , b ) => a [ 0 ] . localeCompare ( b [ 0 ] ) ) ;
} ,
// Get tags for current note
get currentNoteTags ( ) {
if ( ! this . currentNote ) return [ ] ;
const note = this . notes . find ( n => n . path === this . currentNote ) ;
return note && note . tags ? note . tags : [ ] ;
} ,
2025-12-12 17:11:29 +00:00
// ==================== FAVORITES ====================
// Load favorites from localStorage
loadFavorites ( ) {
try {
const stored = localStorage . getItem ( 'noteFavorites' ) ;
if ( stored ) {
this . favorites = JSON . parse ( stored ) ;
this . favoritesSet = new Set ( this . favorites ) ;
}
} catch ( e ) {
this . favorites = [ ] ;
this . favoritesSet = new Set ( ) ;
}
} ,
// Save favorites to localStorage
saveFavorites ( ) {
try {
localStorage . setItem ( 'noteFavorites' , JSON . stringify ( this . favorites ) ) ;
} catch ( e ) {
console . warn ( 'Could not save favorites to localStorage' ) ;
}
} ,
// Check if a note is favorited (O(1) lookup)
isFavorite ( notePath ) {
return this . favoritesSet . has ( notePath ) ;
} ,
// Toggle favorite status for a note
toggleFavorite ( notePath = null ) {
const path = notePath || this . currentNote ;
if ( ! path ) return ;
if ( this . favoritesSet . has ( path ) ) {
// Remove from favorites
this . favorites = this . favorites . filter ( f => f !== path ) ;
} else {
// Add to favorites
2025-12-15 11:18:14 +00:00
this . favorites = [ ... this . favorites , path ] ;
2025-12-12 17:11:29 +00:00
}
2025-12-15 11:18:14 +00:00
// Recreate Set from array for consistency
this . favoritesSet = new Set ( this . favorites ) ;
2025-12-12 17:11:29 +00:00
this . saveFavorites ( ) ;
} ,
// Get favorite notes with full details (for display)
get favoriteNotes ( ) {
return this . favorites
. map ( path => {
2025-12-15 11:18:14 +00:00
// Find note by exact path or case-insensitive match
let note = this . notes . find ( n => n . path === path ) ;
if ( ! note ) {
note = this . notes . find ( n => n . path . toLowerCase ( ) === path . toLowerCase ( ) ) ;
}
2025-12-12 17:11:29 +00:00
if ( ! note ) return null ;
return {
2025-12-15 11:18:14 +00:00
path : note . path , // Use actual path from notes (fixes case issues)
2025-12-12 17:11:29 +00:00
name : note . path . split ( '/' ) . pop ( ) . replace ( '.md' , '' ) ,
folder : note . folder || ''
} ;
} )
. filter ( Boolean ) ; // Remove nulls (deleted notes)
} ,
loadFavoritesExpanded ( ) {
try {
const saved = localStorage . getItem ( 'favoritesExpanded' ) ;
if ( saved !== null ) {
this . favoritesExpanded = saved === 'true' ;
}
} catch ( e ) {
console . error ( 'Error loading favorites expanded state:' , e ) ;
}
} ,
saveFavoritesExpanded ( ) {
try {
localStorage . setItem ( 'favoritesExpanded' , this . favoritesExpanded . toString ( ) ) ;
} catch ( e ) {
console . error ( 'Error saving favorites expanded state:' , e ) ;
}
} ,
2025-12-12 16:35:04 +00:00
// Get current note's last modified time as relative string
get lastEditedText ( ) {
if ( ! this . currentNote ) return '' ;
const note = this . notes . find ( n => n . path === this . currentNote ) ;
if ( ! note || ! note . modified ) return '' ;
const modified = new Date ( note . modified ) ;
const now = new Date ( ) ;
const diffMs = now - modified ;
const diffSecs = Math . floor ( diffMs / 1000 ) ;
const diffMins = Math . floor ( diffSecs / 60 ) ;
const diffHours = Math . floor ( diffMins / 60 ) ;
const diffDays = Math . floor ( diffHours / 24 ) ;
2025-12-14 17:02:57 +00:00
if ( diffSecs < 60 ) return this . t ( 'editor.just_now' ) ;
if ( diffMins < 60 ) return this . t ( 'editor.minutes_ago' , { count : diffMins } ) ;
if ( diffHours < 24 ) return this . t ( 'editor.hours_ago' , { count : diffHours } ) ;
if ( diffDays < 7 ) return this . t ( 'editor.days_ago' , { count : diffDays } ) ;
2025-12-12 16:35:04 +00:00
2025-12-14 17:02:57 +00:00
// For older dates, show the date in selected locale
return modified . toLocaleDateString ( this . currentLocale , { month : 'short' , day : 'numeric' } ) ;
2025-12-12 16:35:04 +00:00
} ,
2025-11-24 16:11:22 +00:00
// Parse tags from markdown content (matches backend logic)
parseTagsFromContent ( content ) {
if ( ! content || ! content . trim ( ) . startsWith ( '---' ) ) {
return [ ] ;
}
try {
const lines = content . split ( '\n' ) ;
if ( lines [ 0 ] . trim ( ) !== '---' ) return [ ] ;
// Find closing ---
let endIdx = - 1 ;
for ( let i = 1 ; i < lines . length ; i ++ ) {
if ( lines [ i ] . trim ( ) === '---' ) {
endIdx = i ;
break ;
}
}
if ( endIdx === - 1 ) return [ ] ;
const frontmatterLines = lines . slice ( 1 , endIdx ) ;
const tags = [ ] ;
let inTagsList = false ;
for ( const line of frontmatterLines ) {
const stripped = line . trim ( ) ;
// Check for inline array: tags: [tag1, tag2]
if ( stripped . startsWith ( 'tags:' ) ) {
const rest = stripped . substring ( 5 ) . trim ( ) ;
if ( rest . startsWith ( '[' ) && rest . endsWith ( ']' ) ) {
const tagsStr = rest . substring ( 1 , rest . length - 1 ) ;
const rawTags = tagsStr . split ( ',' ) . map ( t => t . trim ( ) ) ;
tags . push ( ... rawTags . filter ( t => t ) . map ( t => t . toLowerCase ( ) ) ) ;
break ;
} else if ( rest ) {
tags . push ( rest . toLowerCase ( ) ) ;
break ;
} else {
inTagsList = true ;
}
} else if ( inTagsList ) {
if ( stripped . startsWith ( '-' ) ) {
const tag = stripped . substring ( 1 ) . trim ( ) ;
if ( tag && ! tag . startsWith ( '#' ) ) {
tags . push ( tag . toLowerCase ( ) ) ;
}
} else if ( stripped && ! stripped . startsWith ( '#' ) ) {
break ;
}
}
}
return [ ... new Set ( tags ) ] . sort ( ) ;
} catch ( e ) {
console . error ( 'Error parsing tags:' , e ) ;
return [ ] ;
}
} ,
2025-11-05 16:48:41 +00:00
// Build folder tree structure
buildFolderTree ( ) {
const tree = { } ;
// Add ALL folders from backend (including empty ones)
this . allFolders . forEach ( folderPath => {
const parts = folderPath . split ( '/' ) ;
let current = tree ;
parts . forEach ( ( part , index ) => {
const fullPath = parts . slice ( 0 , index + 1 ) . join ( '/' ) ;
if ( ! current [ part ] ) {
current [ part ] = {
name : part ,
path : fullPath ,
children : { } ,
notes : [ ]
} ;
}
current = current [ part ] . children ;
} ) ;
} ) ;
2025-11-24 16:11:22 +00:00
// Add ALL notes to their folders (no filtering - tree only shown when no filters active)
2025-11-05 16:48:41 +00:00
this . notes . forEach ( note => {
if ( ! note . folder ) {
// Root level note
if ( ! tree [ '__root__' ] ) {
tree [ '__root__' ] = {
name : '' ,
path : '' ,
children : { } ,
notes : [ ]
} ;
}
tree [ '__root__' ] . notes . push ( note ) ;
} else {
// Navigate to the folder and add note
const parts = note . folder . split ( '/' ) ;
let current = tree ;
for ( let i = 0 ; i < parts . length ; i ++ ) {
if ( ! current [ parts [ i ] ] ) {
current [ parts [ i ] ] = {
name : parts [ i ] ,
path : parts . slice ( 0 , i + 1 ) . join ( '/' ) ,
children : { } ,
notes : [ ]
} ;
}
if ( i === parts . length - 1 ) {
current [ parts [ i ] ] . notes . push ( note ) ;
} else {
current = current [ parts [ i ] ] . children ;
}
}
}
} ) ;
// Sort all notes arrays alphabetically (create new sorted arrays for reactivity)
const sortNotes = ( obj ) => {
if ( obj . notes && obj . notes . length > 0 ) {
// Create a new sorted array instead of mutating for Alpine reactivity
obj . notes = [ ... obj . notes ] . sort ( ( a , b ) => a . name . toLowerCase ( ) . localeCompare ( b . name . toLowerCase ( ) ) ) ;
}
if ( obj . children && Object . keys ( obj . children ) . length > 0 ) {
Object . values ( obj . children ) . forEach ( child => sortNotes ( child ) ) ;
}
} ;
// Sort notes in root (create new array for reactivity)
if ( tree [ '__root__' ] && tree [ '__root__' ] . notes ) {
tree [ '__root__' ] . notes = [ ... tree [ '__root__' ] . notes ] . sort ( ( a , b ) => a . name . toLowerCase ( ) . localeCompare ( b . name . toLowerCase ( ) ) ) ;
}
// Sort notes in all folders
Object . values ( tree ) . forEach ( folder => {
if ( folder . path !== undefined ) { // Skip __root__ as it was already sorted
sortNotes ( folder ) ;
}
} ) ;
2025-11-24 09:04:20 +00:00
// Calculate and cache note counts recursively (for performance)
const calculateNoteCounts = ( folderNode ) => {
const directNotes = folderNode . notes ? folderNode . notes . length : 0 ;
if ( ! folderNode . children || Object . keys ( folderNode . children ) . length === 0 ) {
folderNode . noteCount = directNotes ;
return directNotes ;
}
const childNotesCount = Object . values ( folderNode . children ) . reduce (
( total , child ) => total + calculateNoteCounts ( child ) ,
0
) ;
folderNode . noteCount = directNotes + childNotesCount ;
return folderNode . noteCount ;
} ;
// Calculate note counts for all folders
Object . values ( tree ) . forEach ( folder => {
if ( folder . path !== undefined || folder === tree [ '__root__' ] ) {
calculateNoteCounts ( folder ) ;
}
} ) ;
// Invalidate homepage cache when tree is rebuilt
this . _homepageCache = {
folderPath : null ,
notes : null ,
folders : null ,
breadcrumb : null
} ;
// Assign new tree (Alpine will detect the change)
this . folderTree = tree ;
2025-11-05 16:48:41 +00:00
} ,
2025-11-08 11:23:07 +00:00
// Render folder recursively (helper for deep nesting)
2025-11-08 11:28:31 +00:00
renderFolderRecursive ( folder , level = 0 , isTopLevel = false ) {
2025-11-08 11:23:07 +00:00
if ( ! folder ) return '' ;
let html = '' ;
2025-11-08 11:28:31 +00:00
const isExpanded = this . expandedFolders . has ( folder . path ) ;
2025-11-08 11:23:07 +00:00
2025-11-08 11:28:31 +00:00
// Render this folder's header
2025-12-09 11:08:12 +00:00
// Note: Using native event handlers (ondragstart, onclick, etc.) instead of Alpine directives
// because x-html doesn't process Alpine directives in dynamically generated content
const escapedPath = folder . path . replace ( /'/g , "\\'" ) . replace ( /\\/g , "\\\\" ) ;
2025-11-08 11:28:31 +00:00
html += `
< div >
< div
draggable = "true"
2025-12-09 11:08:12 +00:00
ondragstart = "window.$root.onFolderDragStart('${escapedPath}', event)"
ondragend = "window.$root.onFolderDragEnd()"
ondragover = "event.preventDefault(); window.$root.dragOverFolder = '${escapedPath}'; this.classList.add('drag-over')"
ondragenter = "event.preventDefault(); window.$root.dragOverFolder = '${escapedPath}'; this.classList.add('drag-over')"
ondragleave = "window.$root.dragOverFolder = null; this.classList.remove('drag-over')"
ondrop = "event.stopPropagation(); this.classList.remove('drag-over'); window.$root.onFolderDrop('${escapedPath}')"
onclick = "window.$root.toggleFolder('${escapedPath}')"
onmouseover = "if(!window.$root.draggedNote && !window.$root.draggedFolder) this.style.backgroundColor='var(--bg-hover)'"
onmouseout = "if(!window.$root.draggedNote && !window.$root.draggedFolder) this.style.backgroundColor='transparent'"
2025-12-05 13:31:52 +00:00
class = "folder-item px-2 py-1 text-sm relative"
2025-11-08 11:28:31 +00:00
style = "color: var(--text-primary); cursor: pointer;"
>
< div class = "flex items-center gap-1" >
< button
class = "flex-shrink-0 w-4 h-4 flex items-center justify-center"
2025-12-08 15:40:40 +00:00
style = "color: var(--text-tertiary); cursor: pointer; transition: transform 0.2s; pointer-events: none; margin-left: -5px; ${isExpanded ? 'transform: rotate(90deg);' : ''}"
2025-11-08 11:28:31 +00:00
>
< svg width = "12" height = "12" viewBox = "0 0 16 16" fill = "currentColor" >
< path d = "M6 4l4 4-4 4V4z" / >
< / s v g >
< / b u t t o n >
< span class = "flex items-center gap-1 flex-1" style = "overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; pointer-events: none;" >
< span > $ { folder . name } < / s p a n >
$ { folder . notes . length === 0 && ( ! folder . children || Object . keys ( folder . children ) . length === 0 ) ? '<span class="text-xs" style="color: var(--text-tertiary); font-weight: 400;">(empty)</span>' : '' }
< / s p a n >
< / d i v >
2025-12-09 11:08:12 +00:00
< div class = "hover-buttons flex gap-1 transition-opacity absolute right-2 top-1/2 transform -translate-y-1/2" style = "opacity: 0; pointer-events: none; background: linear-gradient(to right, transparent, var(--bg-hover) 20%, var(--bg-hover)); padding-left: 20px;" onclick = "event.stopPropagation()" >
2025-11-08 11:28:31 +00:00
< button
2025-12-09 11:08:12 +00:00
onclick = "event.stopPropagation(); window.$root.dropdownTargetFolder = '${escapedPath}'; window.$root.toggleNewDropdown(event)"
2025-11-08 11:28:31 +00:00
class = "px-1.5 py-0.5 text-xs rounded hover:brightness-110"
style = "background-color: var(--bg-tertiary); color: var(--text-secondary);"
2025-11-26 11:43:37 +00:00
title = "Add item here"
> + < / b u t t o n >
2025-11-08 11:28:31 +00:00
< button
2025-12-09 11:08:12 +00:00
onclick = "event.stopPropagation(); window.$root.renameFolder('${escapedPath}', '${folder.name.replace(/'/g, " \ \ '").replace(/\\/g, "\\\\")}' ) "
2025-11-08 11:28:31 +00:00
class = "px-1.5 py-0.5 text-xs rounded hover:brightness-110"
style = "background-color: var(--bg-tertiary); color: var(--text-secondary);"
title = "Rename folder"
> ✏ ️ < / b u t t o n >
< button
2025-12-09 11:08:12 +00:00
onclick = "event.stopPropagation(); window.$root.deleteFolder('${escapedPath}', '${folder.name.replace(/'/g, " \ \ '").replace(/\\/g, "\\\\")}' ) "
2025-11-08 11:28:31 +00:00
class = "px-1 py-0.5 text-xs rounded hover:brightness-110"
style = "color: var(--error);"
2025-11-17 13:20:57 +00:00
title = "Delete folder"
2025-11-08 11:28:31 +00:00
>
< svg class = "w-4 h-4" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke - linecap = "round" stroke - linejoin = "round" stroke - width = "2" d = "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" > < / p a t h >
< / s v g >
< / b u t t o n >
< / d i v >
< / d i v >
` ;
// If expanded, render folder contents (child folders + notes)
if ( isExpanded ) {
2025-12-05 13:31:52 +00:00
html += ` <div class="folder-contents" style="padding-left: 10px;"> ` ;
2025-11-08 11:23:07 +00:00
2025-11-08 11:28:31 +00:00
// First, render child folders (if any)
if ( folder . children && Object . keys ( folder . children ) . length > 0 ) {
const children = Object . entries ( folder . children ) . sort ( ( a , b ) =>
a [ 1 ] . name . toLowerCase ( ) . localeCompare ( b [ 1 ] . name . toLowerCase ( ) )
) ;
children . forEach ( ( [ childKey , childFolder ] ) => {
html += this . renderFolderRecursive ( childFolder , 0 , false ) ;
} ) ;
}
2025-11-08 11:23:07 +00:00
2025-11-20 13:10:17 +00:00
// Then, render notes and images in this folder (after subfolders)
2025-11-08 11:28:31 +00:00
if ( folder . notes && folder . notes . length > 0 ) {
folder . notes . forEach ( note => {
2025-11-20 13:10:17 +00:00
// Check if it's an image or a note
const isImage = note . type === 'image' ;
2025-11-08 11:28:31 +00:00
const isCurrentNote = this . currentNote === note . path ;
2025-11-20 13:10:17 +00:00
const isCurrentImage = this . currentImage === note . path ;
const isCurrent = isImage ? isCurrentImage : isCurrentNote ;
// Different icon for images
const icon = isImage ? '🖼️' : '' ;
2025-12-09 11:08:12 +00:00
// Escape paths for use in native event handlers
const escapedNotePath = note . path . replace ( /'/g , "\\'" ) . replace ( /\\/g , "\\\\" ) ;
const escapedNoteName = note . name . replace ( /'/g , "\\'" ) . replace ( /\\/g , "\\\\" ) ;
2025-11-20 13:10:17 +00:00
// Click handler
2025-12-09 11:08:12 +00:00
const clickHandler = ` window. $ root.openItem(' ${ escapedNotePath } ', ' ${ note . type } ') ` ;
2025-11-20 13:10:17 +00:00
// Delete handler
const deleteHandler = isImage
2025-12-09 11:08:12 +00:00
? ` event.stopPropagation(); window. $ root.deleteImage(' ${ escapedNotePath } ') `
: ` event.stopPropagation(); window. $ root.deleteNote(' ${ escapedNotePath } ', ' ${ escapedNoteName } ') ` ;
2025-11-20 13:10:17 +00:00
2025-11-08 11:28:31 +00:00
html += `
< div
draggable = "true"
2025-12-09 11:08:12 +00:00
ondragstart = "window.$root.onNoteDragStart('${escapedNotePath}', event)"
ondragend = "window.$root.onNoteDragEnd()"
onclick = "${clickHandler}"
2025-12-08 15:40:40 +00:00
class = "note-item px-2 py-1 text-sm relative"
2025-11-20 13:10:17 +00:00
style = "${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isImage ? 'opacity: 0.85;' : ''} cursor: pointer;"
2025-12-09 11:08:12 +00:00
onmouseover = "if('${escapedNotePath}' !== window.$root.currentNote && '${escapedNotePath}' !== window.$root.currentImage) this.style.backgroundColor='var(--bg-hover)'"
onmouseout = "if('${escapedNotePath}' !== window.$root.currentNote && '${escapedNotePath}' !== window.$root.currentImage) this.style.backgroundColor='transparent'"
2025-11-08 11:28:31 +00:00
>
2025-11-20 13:10:17 +00:00
< span class = "truncate" style = "display: block; padding-right: 30px;" > $ { icon } $ { icon ? ' ' : '' } $ { note . name } < / s p a n >
2025-11-08 11:23:07 +00:00
< button
2025-12-09 11:08:12 +00:00
onclick = "${deleteHandler}"
2025-11-08 11:28:31 +00:00
class = "note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity"
style = "opacity: 0; color: var(--error);"
2025-11-20 13:10:17 +00:00
title = "${isImage ? 'Delete image' : 'Delete note'}"
2025-11-08 11:23:07 +00:00
>
< svg class = "w-4 h-4" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke - linecap = "round" stroke - linejoin = "round" stroke - width = "2" d = "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" > < / p a t h >
< / s v g >
< / b u t t o n >
< / d i v >
2025-11-08 11:28:31 +00:00
` ;
} ) ;
2025-11-08 11:23:07 +00:00
}
2025-11-08 11:28:31 +00:00
html += ` </div> ` ; // Close folder-contents
2025-11-08 11:23:07 +00:00
}
2025-11-08 11:28:31 +00:00
html += ` </div> ` ; // Close folder wrapper
2025-11-08 11:23:07 +00:00
return html ;
} ,
2025-11-05 16:48:41 +00:00
// Toggle folder expansion
toggleFolder ( folderPath ) {
if ( this . expandedFolders . has ( folderPath ) ) {
this . expandedFolders . delete ( folderPath ) ;
} else {
this . expandedFolders . add ( folderPath ) ;
}
2025-11-08 11:23:07 +00:00
// Force Alpine reactivity by creating new Set reference
this . expandedFolders = new Set ( this . expandedFolders ) ;
2025-11-05 16:48:41 +00:00
} ,
// Check if folder is expanded
isFolderExpanded ( folderPath ) {
return this . expandedFolders . has ( folderPath ) ;
} ,
// Expand all folders
expandAllFolders ( ) {
this . allFolders . forEach ( folder => {
this . expandedFolders . add ( folder ) ;
} ) ;
2025-11-24 09:04:20 +00:00
// Force Alpine reactivity
this . expandedFolders = new Set ( this . expandedFolders ) ;
2025-11-05 16:48:41 +00:00
} ,
// Collapse all folders
collapseAllFolders ( ) {
this . expandedFolders . clear ( ) ;
2025-11-24 09:04:20 +00:00
// Force Alpine reactivity
this . expandedFolders = new Set ( this . expandedFolders ) ;
2025-11-05 16:48:41 +00:00
} ,
// Expand folder tree to show a specific note
expandFolderForNote ( notePath ) {
const parts = notePath . split ( '/' ) ;
// If note is in root, no folders to expand
if ( parts . length <= 1 ) return ;
// Remove the note name (last part)
parts . pop ( ) ;
// Build and expand all parent folders
let currentPath = '' ;
parts . forEach ( ( part , index ) => {
currentPath = index === 0 ? part : ` ${ currentPath } / ${ part } ` ;
this . expandedFolders . add ( currentPath ) ;
} ) ;
2025-11-24 09:04:20 +00:00
// Force Alpine reactivity
this . expandedFolders = new Set ( this . expandedFolders ) ;
2025-11-05 16:48:41 +00:00
} ,
// Scroll note into view in the sidebar navigation
scrollNoteIntoView ( notePath ) {
// Find the note element in the sidebar
// Use a slight delay to ensure DOM is fully rendered with Alpine bindings applied
setTimeout ( ( ) => {
const sidebar = document . querySelector ( '.flex-1.overflow-y-auto.custom-scrollbar' ) ;
if ( ! sidebar ) return ;
2025-12-05 13:31:52 +00:00
const noteElements = sidebar . querySelectorAll ( '.note-item' ) ;
2025-11-05 16:48:41 +00:00
let targetElement = null ;
const noteName = notePath . split ( '/' ) . pop ( ) . replace ( '.md' , '' ) ;
// Find the element that corresponds to this note
noteElements . forEach ( el => {
// Check if this is a note element (not folder) by checking if it has the note name
if ( el . textContent . trim ( ) . startsWith ( noteName ) || el . textContent . includes ( noteName ) ) {
// Check computed style to see if it's highlighted
const computedStyle = window . getComputedStyle ( el ) ;
const bgColor = computedStyle . backgroundColor ;
// Check if background has the accent color (not transparent or default)
if ( bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' && ! bgColor . includes ( '255, 255, 255' ) ) {
targetElement = el ;
}
}
} ) ;
// If found, scroll it into view
if ( targetElement ) {
targetElement . scrollIntoView ( {
behavior : 'smooth' ,
block : 'center' ,
inline : 'nearest'
} ) ;
}
} , 200 ) ; // Increased delay to ensure Alpine has finished rendering
} ,
// Drag and drop handlers
onNoteDragStart ( notePath , event ) {
2025-11-20 13:10:17 +00:00
// Check if this is an image
const item = this . notes . find ( n => n . path === notePath ) ;
const isImage = item && item . type === 'image' ;
// Set unified drag state
this . draggedItem = {
path : notePath ,
type : isImage ? 'image' : 'note'
} ;
// For notes, also set legacy draggedNote for folder move logic
if ( ! isImage ) {
2025-11-05 16:48:41 +00:00
this . draggedNote = notePath ;
2025-11-18 09:03:47 +00:00
// Make drag image semi-transparent
if ( event . target ) {
event . target . style . opacity = '0.5' ;
}
2025-11-05 16:48:41 +00:00
}
2025-11-20 13:10:17 +00:00
event . dataTransfer . effectAllowed = 'all' ;
2025-11-05 16:48:41 +00:00
} ,
onNoteDragEnd ( ) {
this . draggedNote = null ;
2025-11-20 13:10:17 +00:00
this . draggedItem = null ;
this . dropTarget = null ;
2025-11-18 09:03:47 +00:00
this . dragOverFolder = null ;
// Reset opacity of all note items
2025-12-09 11:08:12 +00:00
document . querySelectorAll ( '.note-item' ) . forEach ( el => el . style . opacity = '1' ) ;
// Reset drag-over class (more efficient than querying all folder items)
document . querySelectorAll ( '.drag-over' ) . forEach ( el => el . classList . remove ( 'drag-over' ) ) ;
2025-11-05 16:48:41 +00:00
} ,
// Handle dragover on editor to show cursor position
onEditorDragOver ( event ) {
2025-11-20 13:10:17 +00:00
if ( ! this . draggedItem ) return ;
event . preventDefault ( ) ;
this . dropTarget = 'editor' ;
2025-11-05 16:48:41 +00:00
// Update cursor position as user drags over text
const textarea = event . target ;
const textLength = textarea . value . length ;
// Calculate approximate cursor position based on mouse position
// This gives a rough idea of where the link will be inserted
textarea . focus ( ) ;
// Try to set cursor at click position (works in most browsers)
if ( textarea . setSelectionRange && document . caretPositionFromPoint ) {
const pos = document . caretPositionFromPoint ( event . clientX , event . clientY ) ;
if ( pos && pos . offsetNode === textarea ) {
textarea . setSelectionRange ( pos . offset , pos . offset ) ;
}
}
} ,
// Handle dragenter on editor
onEditorDragEnter ( event ) {
2025-11-20 13:10:17 +00:00
if ( ! this . draggedItem ) return ;
2025-11-05 16:48:41 +00:00
event . preventDefault ( ) ;
2025-11-20 13:10:17 +00:00
this . dropTarget = 'editor' ;
2025-11-05 16:48:41 +00:00
} ,
// Handle dragleave on editor
onEditorDragLeave ( event ) {
2025-11-20 13:10:17 +00:00
// Only clear dropTarget if we're actually leaving the editor
// (not just moving between child elements)
if ( event . target . tagName === 'TEXTAREA' ) {
this . dropTarget = null ;
}
2025-11-05 16:48:41 +00:00
} ,
2025-11-20 13:10:17 +00:00
// Handle drop into editor to create internal link or upload image
async onEditorDrop ( event ) {
2025-11-05 16:48:41 +00:00
event . preventDefault ( ) ;
2025-11-20 13:10:17 +00:00
this . dropTarget = null ;
2025-11-05 16:48:41 +00:00
2025-11-20 13:10:17 +00:00
// Check if files are being dropped (images from file system)
if ( event . dataTransfer && event . dataTransfer . files && event . dataTransfer . files . length > 0 ) {
await this . handleImageDrop ( event ) ;
return ;
}
2025-11-05 16:48:41 +00:00
2025-11-20 13:10:17 +00:00
// Otherwise, handle note/image link drop from sidebar
if ( ! this . draggedItem ) return ;
2025-11-05 16:48:41 +00:00
2025-11-20 13:10:17 +00:00
const notePath = this . draggedItem . path ;
const isImage = this . draggedItem . type === 'image' ;
2025-11-05 16:48:41 +00:00
2025-11-20 13:10:17 +00:00
let link ;
if ( isImage ) {
// For images, insert image markdown
const filename = notePath . split ( '/' ) . pop ( ) . replace ( /\.[^/.]+$/ , '' ) ; // Remove extension
2025-12-11 10:39:12 +00:00
// Use relative path (not /api/images/) for portability
link = `  ` ;
2025-11-20 13:10:17 +00:00
} else {
// For notes, insert note link
const noteName = notePath . split ( '/' ) . pop ( ) . replace ( '.md' , '' ) ;
const encodedPath = notePath . split ( '/' ) . map ( segment => encodeURIComponent ( segment ) ) . join ( '/' ) ;
link = ` [ ${ noteName } ]( ${ encodedPath } ) ` ;
}
2025-11-05 16:48:41 +00:00
// Insert at cursor position
const textarea = event . target ;
const cursorPos = textarea . selectionStart || 0 ;
const textBefore = this . noteContent . substring ( 0 , cursorPos ) ;
const textAfter = this . noteContent . substring ( cursorPos ) ;
this . noteContent = textBefore + link + textAfter ;
// Move cursor after the link
this . $nextTick ( ( ) => {
textarea . selectionStart = textarea . selectionEnd = cursorPos + link . length ;
textarea . focus ( ) ;
} ) ;
// Trigger autosave
this . autoSave ( ) ;
2025-11-20 13:10:17 +00:00
this . draggedItem = null ;
} ,
// Handle image files dropped into editor
async handleImageDrop ( event ) {
if ( ! this . currentNote ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'notes.open_first' ) ) ;
2025-11-20 13:10:17 +00:00
return ;
}
const files = Array . from ( event . dataTransfer . files ) ;
const imageFiles = files . filter ( file => {
const type = file . type . toLowerCase ( ) ;
return type . startsWith ( 'image/' ) &&
[ 'image/jpeg' , 'image/jpg' , 'image/png' , 'image/gif' , 'image/webp' ] . includes ( type ) ;
} ) ;
if ( imageFiles . length === 0 ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'images.no_valid_files' ) ) ;
2025-11-20 13:10:17 +00:00
return ;
}
const textarea = event . target ;
const cursorPos = textarea . selectionStart || 0 ;
// Upload each image
for ( const file of imageFiles ) {
try {
const imagePath = await this . uploadImage ( file , this . currentNote ) ;
if ( imagePath ) {
this . insertImageMarkdown ( imagePath , file . name , cursorPos ) ;
}
} catch ( error ) {
ErrorHandler . handle ( ` upload image ${ file . name } ` , error ) ;
}
}
} ,
// Upload an image file
async uploadImage ( file , notePath ) {
const formData = new FormData ( ) ;
formData . append ( 'file' , file ) ;
formData . append ( 'note_path' , notePath ) ;
try {
const response = await fetch ( '/api/upload-image' , {
method : 'POST' ,
body : formData
} ) ;
if ( ! response . ok ) {
const error = await response . json ( ) ;
throw new Error ( error . detail || 'Upload failed' ) ;
}
const data = await response . json ( ) ;
return data . path ;
} catch ( error ) {
throw error ;
}
} ,
// Insert image markdown at cursor position
insertImageMarkdown ( imagePath , altText , cursorPos ) {
const filename = altText . replace ( /\.[^/.]+$/ , '' ) ; // Remove extension
2025-12-11 10:39:12 +00:00
// Use relative path (not /api/images/) for portability
const markdown = `  ` ;
2025-11-20 13:10:17 +00:00
const textBefore = this . noteContent . substring ( 0 , cursorPos ) ;
const textAfter = this . noteContent . substring ( cursorPos ) ;
this . noteContent = textBefore + markdown + '\n' + textAfter ;
// Trigger autosave
this . autoSave ( ) ;
// Reload notes to show the new image in sidebar
this . loadNotes ( ) ;
} ,
// Handle paste event for clipboard images
async handlePaste ( event ) {
if ( ! this . currentNote ) return ;
const items = event . clipboardData ? . items ;
if ( ! items ) return ;
for ( const item of items ) {
if ( item . type . startsWith ( 'image/' ) ) {
event . preventDefault ( ) ;
const blob = item . getAsFile ( ) ;
if ( blob ) {
try {
const textarea = event . target ;
const cursorPos = textarea . selectionStart || 0 ;
2025-11-23 15:59:57 +00:00
// Create a simple filename - backend will add timestamp to prevent collisions
2025-11-20 13:10:17 +00:00
const ext = item . type . split ( '/' ) [ 1 ] || 'png' ;
2025-11-23 15:59:57 +00:00
const filename = ` pasted-image. ${ ext } ` ;
2025-11-20 13:10:17 +00:00
// Create a File from the blob
const file = new File ( [ blob ] , filename , { type : item . type } ) ;
const imagePath = await this . uploadImage ( file , this . currentNote ) ;
if ( imagePath ) {
this . insertImageMarkdown ( imagePath , filename , cursorPos ) ;
}
} catch ( error ) {
ErrorHandler . handle ( 'paste image' , error ) ;
}
}
break ; // Only handle first image
}
}
} ,
2025-12-04 17:05:41 +00:00
// Open a note or image (unified handler for sidebar/homepage clicks)
openItem ( path , type = 'note' , searchHighlight = '' ) {
this . showGraph = false ;
if ( type === 'image' || path . match ( /\.(png|jpg|jpeg|gif|webp)$/i ) ) {
this . viewImage ( path ) ;
} else {
this . loadNote ( path , true , searchHighlight ) ;
}
} ,
2025-11-20 13:10:17 +00:00
// View an image in the main pane
viewImage ( imagePath , updateHistory = true ) {
2025-12-04 17:05:41 +00:00
this . showGraph = false ; // Ensure graph is closed
2025-11-20 13:10:17 +00:00
this . currentNote = '' ;
this . currentNoteName = '' ;
this . noteContent = '' ;
this . currentImage = imagePath ;
this . viewMode = 'preview' ; // Use preview mode to show image
// Update browser URL
if ( updateHistory ) {
// Encode each path segment to handle special characters
const encodedPath = imagePath . split ( '/' ) . map ( segment => encodeURIComponent ( segment ) ) . join ( '/' ) ;
window . history . pushState (
{ imagePath : imagePath } ,
'' ,
` / ${ encodedPath } `
) ;
}
} ,
// Delete an image
async deleteImage ( imagePath ) {
const filename = imagePath . split ( '/' ) . pop ( ) ;
2025-12-14 17:02:57 +00:00
if ( ! confirm ( this . t ( 'images.confirm_delete' , { name : filename } ) ) ) return ;
2025-11-20 13:10:17 +00:00
try {
const response = await fetch ( ` /api/notes/ ${ encodeURIComponent ( imagePath ) } ` , {
method : 'DELETE'
} ) ;
if ( response . ok ) {
await this . loadNotes ( ) ; // Refresh tree
// Clear viewer if deleting currently viewed image
if ( this . currentImage === imagePath ) {
this . currentImage = '' ;
}
} else {
throw new Error ( 'Failed to delete image' ) ;
}
} catch ( error ) {
ErrorHandler . handle ( 'delete image' , error ) ;
}
2025-11-05 16:48:41 +00:00
} ,
// Handle clicks on internal links in preview
handleInternalLink ( event ) {
// Check if clicked element is a link
const link = event . target . closest ( 'a' ) ;
if ( ! link ) return ;
const href = link . getAttribute ( 'href' ) ;
if ( ! href ) return ;
// Check if it's an external link
if ( href . startsWith ( 'http://' ) || href . startsWith ( 'https://' ) || href . startsWith ( '//' ) || href . startsWith ( 'mailto:' ) ) {
return ; // Let external links work normally
}
// Prevent default navigation for internal links
event . preventDefault ( ) ;
// Remove any anchor from the href (e.g., "note.md#section" -> "note.md")
2025-11-18 16:24:46 +00:00
// Also decode URL encoding (e.g., "note%203.md" -> "note 3.md")
const notePath = decodeURIComponent ( href . split ( '#' ) [ 0 ] ) ;
2025-11-05 16:48:41 +00:00
// Skip if it's just an anchor link
if ( ! notePath ) return ;
2025-12-03 15:00:25 +00:00
// Find the note by path (try exact match first, then with .md extension)
const note = this . notes . find ( n =>
n . path === notePath ||
n . path === notePath + '.md'
) ;
2025-11-05 16:48:41 +00:00
if ( note ) {
2025-12-03 15:00:25 +00:00
this . loadNote ( note . path ) ;
2025-11-05 16:48:41 +00:00
} else {
2025-12-03 15:00:25 +00:00
// Try to find by name (in case link uses just the note name without path)
const noteByName = this . notes . find ( n =>
n . name === notePath ||
n . name === notePath + '.md' ||
// Also match by filename at end of path (case-insensitive)
n . name . toLowerCase ( ) === notePath . toLowerCase ( ) ||
n . name . toLowerCase ( ) === ( notePath + '.md' ) . toLowerCase ( )
) ;
2025-11-05 16:48:41 +00:00
if ( noteByName ) {
this . loadNote ( noteByName . path ) ;
} else {
2025-12-03 15:00:25 +00:00
// Last resort: case-insensitive path matching
const noteByPathCI = this . notes . find ( n =>
n . path . toLowerCase ( ) === notePath . toLowerCase ( ) ||
n . path . toLowerCase ( ) === ( notePath + '.md' ) . toLowerCase ( )
) ;
if ( noteByPathCI ) {
this . loadNote ( noteByPathCI . path ) ;
2025-12-04 17:05:41 +00:00
} else {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'notes.not_found' , { path : notePath } ) ) ;
2025-12-03 15:00:25 +00:00
}
2025-11-05 16:48:41 +00:00
}
}
} ,
// Folder drag handlers
2025-11-18 09:03:47 +00:00
onFolderDragStart ( folderPath , event ) {
2025-11-05 16:48:41 +00:00
this . draggedFolder = folderPath ;
2025-11-18 09:03:47 +00:00
// Make drag image semi-transparent
if ( event && event . target ) {
event . target . style . opacity = '0.5' ;
}
2025-11-05 16:48:41 +00:00
} ,
onFolderDragEnd ( ) {
this . draggedFolder = null ;
2025-11-20 13:10:17 +00:00
this . dropTarget = null ;
2025-11-18 09:03:47 +00:00
this . dragOverFolder = null ;
2025-12-09 11:08:12 +00:00
// Reset styles - only query elements with drag-over class (more efficient)
document . querySelectorAll ( '.folder-item' ) . forEach ( el => el . style . opacity = '1' ) ;
document . querySelectorAll ( '.drag-over' ) . forEach ( el => el . classList . remove ( 'drag-over' ) ) ;
} ,
cancelDrag ( ) {
// Cancel any active drag operation (triggered by ESC key)
this . draggedNote = null ;
this . draggedFolder = null ;
this . draggedItem = null ;
this . dropTarget = null ;
this . dragOverFolder = null ;
// Reset styles - only query elements with drag-over class (more efficient)
document . querySelectorAll ( '.folder-item' ) . forEach ( el => el . style . opacity = '1' ) ;
document . querySelectorAll ( '.note-item' ) . forEach ( el => el . style . opacity = '1' ) ;
document . querySelectorAll ( '.drag-over' ) . forEach ( el => el . classList . remove ( 'drag-over' ) ) ;
2025-11-05 16:48:41 +00:00
} ,
async onFolderDrop ( targetFolderPath ) {
2025-11-20 13:10:17 +00:00
// Ignore if we're dropping into the editor
if ( this . dropTarget === 'editor' ) {
return ;
}
2025-12-15 11:18:14 +00:00
// IMPORTANT: Capture dragged item info immediately before async operations
// because ondragend may fire and clear these values
const draggedNotePath = this . draggedNote || ( this . draggedItem ? . type === 'note' ? this . draggedItem . path : null ) ;
const draggedFolderPath = this . draggedFolder ;
2025-11-05 16:48:41 +00:00
// Handle note drop into folder
2025-12-15 11:18:14 +00:00
if ( draggedNotePath ) {
const note = this . notes . find ( n => n . path === draggedNotePath ) ;
2025-11-05 16:48:41 +00:00
if ( ! note ) return ;
2025-11-20 13:10:17 +00:00
// Don't allow moving images to folders
if ( note . type === 'image' ) {
return ;
}
2025-11-05 16:48:41 +00:00
// Get note filename
const filename = note . path . split ( '/' ) . pop ( ) ;
const newPath = targetFolderPath ? ` ${ targetFolderPath } / ${ filename } ` : filename ;
2025-12-15 11:18:14 +00:00
if ( newPath === draggedNotePath ) {
2025-11-05 16:48:41 +00:00
return ;
}
2025-12-15 11:18:14 +00:00
// Check if this note is favorited BEFORE the async call
const wasFavorited = this . favoritesSet . has ( draggedNotePath ) ;
2025-11-05 16:48:41 +00:00
try {
const response = await fetch ( '/api/notes/move' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
2025-12-15 11:18:14 +00:00
oldPath : draggedNotePath ,
2025-11-05 16:48:41 +00:00
newPath : newPath
} )
} ) ;
if ( response . ok ) {
2025-12-15 11:18:14 +00:00
// Update favorites if the moved note was favorited
if ( wasFavorited ) {
// Update Array (create new array for reactivity)
const newFavorites = this . favorites . map ( f => f === draggedNotePath ? newPath : f ) ;
this . favorites = newFavorites ;
// Recreate Set from array for consistency
this . favoritesSet = new Set ( newFavorites ) ;
this . saveFavorites ( ) ;
}
2025-11-05 16:48:41 +00:00
// Keep current note open if it was the moved note
2025-12-15 11:18:14 +00:00
const wasCurrentNote = this . currentNote === draggedNotePath ;
await this . loadNotes ( ) ;
if ( wasCurrentNote ) {
2025-11-05 16:48:41 +00:00
this . currentNote = newPath ;
}
} else {
2025-12-15 11:18:14 +00:00
const errorData = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
2025-12-15 11:35:33 +00:00
alert ( errorData . detail || this . t ( 'move.failed_note' ) ) ;
2025-11-05 16:48:41 +00:00
}
} catch ( error ) {
console . error ( 'Failed to move note:' , error ) ;
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'move.failed_note' ) ) ;
2025-11-05 16:48:41 +00:00
}
return ;
}
// Handle folder drop into folder
2025-12-15 11:18:14 +00:00
if ( draggedFolderPath ) {
2025-11-05 16:48:41 +00:00
// Prevent dropping folder into itself or its subfolders
2025-12-15 11:18:14 +00:00
if ( targetFolderPath === draggedFolderPath ||
targetFolderPath . startsWith ( draggedFolderPath + '/' ) ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'folders.cannot_move_into_self' ) ) ;
2025-11-05 16:48:41 +00:00
return ;
}
2025-12-15 11:18:14 +00:00
const folderName = draggedFolderPath . split ( '/' ) . pop ( ) ;
2025-11-05 16:48:41 +00:00
const newPath = targetFolderPath ? ` ${ targetFolderPath } / ${ folderName } ` : folderName ;
2025-12-15 11:18:14 +00:00
if ( newPath === draggedFolderPath ) {
2025-11-05 16:48:41 +00:00
return ;
}
2025-12-15 11:18:14 +00:00
// Capture favorites info before async call
const oldPrefix = draggedFolderPath + '/' ;
const newPrefix = newPath + '/' ;
2025-11-05 16:48:41 +00:00
try {
const response = await fetch ( '/api/folders/move' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
2025-12-15 11:18:14 +00:00
oldPath : draggedFolderPath ,
2025-11-05 16:48:41 +00:00
newPath : newPath
} )
} ) ;
if ( response . ok ) {
2025-12-15 11:18:14 +00:00
// Update favorites that were in the moved folder
const newFavorites = this . favorites . map ( f => {
if ( f . startsWith ( oldPrefix ) ) {
return f . replace ( oldPrefix , newPrefix ) ;
}
return f ;
} ) ;
// Check if anything changed
if ( JSON . stringify ( newFavorites ) !== JSON . stringify ( this . favorites ) ) {
this . favorites = newFavorites ;
this . favoritesSet = new Set ( newFavorites ) ;
this . saveFavorites ( ) ;
}
2025-11-05 16:48:41 +00:00
await this . loadNotes ( ) ;
// Update current note path if it was in the moved folder
2025-12-15 11:18:14 +00:00
if ( this . currentNote && this . currentNote . startsWith ( oldPrefix ) ) {
this . currentNote = this . currentNote . replace ( oldPrefix , newPrefix ) ;
2025-11-05 16:48:41 +00:00
}
} else {
2025-12-15 11:18:14 +00:00
const errorData = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
2025-12-15 11:35:33 +00:00
alert ( errorData . detail || this . t ( 'move.failed_folder' ) ) ;
2025-11-05 16:48:41 +00:00
}
} catch ( error ) {
console . error ( 'Failed to move folder:' , error ) ;
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'move.failed_folder' ) ) ;
2025-11-05 16:48:41 +00:00
}
2025-11-20 13:10:17 +00:00
this . dropTarget = null ;
2025-11-05 16:48:41 +00:00
}
} ,
// Load a specific note
async loadNote ( notePath , updateHistory = true , searchQuery = '' ) {
try {
2025-11-13 14:09:26 +00:00
// Close mobile sidebar when a note is selected
this . mobileSidebarOpen = false ;
2025-11-05 16:48:41 +00:00
const response = await fetch ( ` /api/notes/ ${ notePath } ` ) ;
// Check if note exists
if ( ! response . ok ) {
if ( response . status === 404 ) {
// Note not found - silently redirect to home
2025-11-20 03:01:27 +00:00
window . history . replaceState ( { homepageFolder : this . selectedHomepageFolder || '' } , '' , '/' ) ;
2025-11-05 16:48:41 +00:00
this . currentNote = '' ;
this . noteContent = '' ;
2025-11-20 13:10:17 +00:00
this . currentImage = '' ;
2025-11-05 16:48:41 +00:00
return ;
}
throw new Error ( ` HTTP error! status: ${ response . status } ` ) ;
}
const data = await response . json ( ) ;
this . currentNote = notePath ;
2025-12-12 17:11:29 +00:00
this . _lastRenderedContent = '' ; // Clear render cache for new note
this . _cachedRenderedHTML = '' ;
2025-11-05 16:48:41 +00:00
this . noteContent = data . content ;
this . currentNoteName = notePath . split ( '/' ) . pop ( ) . replace ( '.md' , '' ) ;
2025-11-20 13:10:17 +00:00
this . currentImage = '' ; // Clear image viewer when loading a note
2025-11-05 16:48:41 +00:00
this . lastSaved = false ;
2025-12-14 14:16:06 +00:00
// Initialize undo/redo history for this note (with cursor at start)
this . undoHistory = [ { content : data . content , cursorPos : 0 } ] ;
2025-11-05 16:48:41 +00:00
this . redoHistory = [ ] ;
// Update browser URL and history
if ( updateHistory ) {
// Encode the path properly (spaces become %20, etc.)
const pathWithoutExtension = notePath . replace ( '.md' , '' ) ;
// Encode each path segment to handle special characters
const encodedPath = pathWithoutExtension . split ( '/' ) . map ( segment => encodeURIComponent ( segment ) ) . join ( '/' ) ;
let url = ` / ${ encodedPath } ` ;
// Add search query parameter if present
if ( searchQuery ) {
url += ` ?search= ${ encodeURIComponent ( searchQuery ) } ` ;
}
window . history . pushState (
2025-11-20 03:01:27 +00:00
{
notePath : notePath ,
searchQuery : searchQuery ,
homepageFolder : this . selectedHomepageFolder || '' // Save current folder state
} ,
2025-11-05 16:48:41 +00:00
'' ,
url
) ;
}
// Calculate stats if plugin enabled
if ( this . statsPluginEnabled ) {
this . calculateStats ( ) ;
}
2025-12-04 09:07:07 +00:00
// Parse frontmatter metadata
this . parseMetadata ( ) ;
2025-11-05 16:48:41 +00:00
// Store search query for highlighting
if ( searchQuery ) {
this . currentSearchHighlight = searchQuery ;
} else {
// Clear highlights if no search query
this . currentSearchHighlight = '' ;
}
// Expand folder tree to show the loaded note
this . expandFolderForNote ( notePath ) ;
// Use $nextTick twice to ensure Alpine.js has time to:
// 1. First tick: expand folders and update DOM
// 2. Second tick: highlight the note and setup everything else
this . $nextTick ( ( ) => {
this . $nextTick ( ( ) => {
this . refreshDOMCache ( ) ;
this . setupScrollSync ( ) ;
this . scrollToTop ( ) ;
// Apply or clear search highlighting
if ( searchQuery ) {
// Pass true to focus editor when loading from search result
this . highlightSearchTerm ( searchQuery , true ) ;
} else {
this . clearSearchHighlights ( ) ;
}
// Scroll note into view in sidebar if needed
this . scrollNoteIntoView ( notePath ) ;
} ) ;
} ) ;
} catch ( error ) {
ErrorHandler . handle ( 'load note' , error ) ;
}
} ,
// Load note from URL path
loadNoteFromURL ( ) {
// Get path from URL (e.g., /folder/note or /note)
2025-12-10 08:17:25 +00:00
let path = window . location . pathname ;
// Strip .md extension if present (for MKdocs/Zensical integration)
if ( path . toLowerCase ( ) . endsWith ( '.md' ) ) {
path = path . slice ( 0 , - 3 ) ;
// Update URL bar to show clean path without .md
window . history . replaceState ( null , '' , path ) ;
}
2025-11-05 16:48:41 +00:00
// Skip if root path or static assets
if ( path === '/' || path . startsWith ( '/static/' ) || path . startsWith ( '/api/' ) ) {
return ;
}
2025-11-20 13:10:17 +00:00
// Remove leading slash and decode URL encoding (e.g., %20 -> space)
2025-11-05 16:48:41 +00:00
const decodedPath = decodeURIComponent ( path . substring ( 1 ) ) ;
2025-11-20 13:10:17 +00:00
// Check if this is an image path (check if it exists in notes list as an image)
const matchedItem = this . notes . find ( n => n . path === decodedPath ) ;
2025-11-05 16:48:41 +00:00
2025-11-20 13:10:17 +00:00
if ( matchedItem && matchedItem . type === 'image' ) {
// It's an image, view it
this . viewImage ( decodedPath , false ) ; // false = don't update history (we're already at this URL)
} else {
// It's a note, add .md extension and load it
const notePath = decodedPath + '.md' ;
// Parse query string for search parameter
const urlParams = new URLSearchParams ( window . location . search ) ;
const searchParam = urlParams . get ( 'search' ) ;
// Try to load the note directly - the backend will handle 404 if it doesn't exist
// This is more robust than checking the frontend notes list
this . loadNote ( notePath , false , searchParam || '' ) ;
// If there's a search parameter, populate the search box and trigger search
if ( searchParam ) {
this . searchQuery = searchParam ;
// Trigger search to populate results list
this . searchNotes ( ) ;
}
2025-11-05 16:48:41 +00:00
}
} ,
// Highlight search term in editor and preview
highlightSearchTerm ( query , focusEditor = false ) {
if ( ! query || ! query . trim ( ) ) {
this . clearSearchHighlights ( ) ;
return ;
}
const searchTerm = query . trim ( ) ;
// Highlight in editor (textarea)
this . highlightInEditor ( searchTerm , focusEditor ) ;
// Highlight in preview (rendered HTML)
this . highlightInPreview ( searchTerm ) ;
} ,
// Highlight search term in the editor textarea
highlightInEditor ( searchTerm , shouldFocus = false ) {
const editor = this . _domCache . editor || document . getElementById ( 'editor' ) ;
if ( ! editor ) return ;
// For textarea, we can't directly highlight text, but we can scroll to first match
const content = editor . value ;
const lowerContent = content . toLowerCase ( ) ;
const lowerTerm = searchTerm . toLowerCase ( ) ;
const index = lowerContent . indexOf ( lowerTerm ) ;
if ( index !== - 1 ) {
// Calculate line number to scroll to
const textBefore = content . substring ( 0 , index ) ;
const lineNumber = textBefore . split ( '\n' ) . length ;
// Scroll to approximate position
const lineHeight = 20 ; // Approximate line height in pixels
editor . scrollTop = ( lineNumber - 5 ) * lineHeight ; // Scroll a bit above to show context
// Only focus and select if explicitly requested (e.g., from search result click)
if ( shouldFocus ) {
editor . focus ( ) ;
editor . setSelectionRange ( index , index + searchTerm . length ) ;
// Blur immediately so the selection stays visible but editor isn't focused
setTimeout ( ( ) => editor . blur ( ) , 100 ) ;
}
}
} ,
// Highlight search term in the preview pane
highlightInPreview ( searchTerm ) {
const preview = document . querySelector ( '.markdown-preview' ) ;
if ( ! preview ) return ;
// Remove existing highlights
this . clearSearchHighlights ( ) ;
// Create a tree walker to find all text nodes
const walker = document . createTreeWalker (
preview ,
NodeFilter . SHOW _TEXT ,
null ,
false
) ;
const textNodes = [ ] ;
let node ;
while ( node = walker . nextNode ( ) ) {
// Skip code blocks and pre tags
if ( node . parentElement . tagName === 'CODE' ||
node . parentElement . tagName === 'PRE' ) {
continue ;
}
textNodes . push ( node ) ;
}
const lowerTerm = searchTerm . toLowerCase ( ) ;
let matchIndex = 0 ;
// Highlight matches in text nodes
textNodes . forEach ( textNode => {
const text = textNode . textContent ;
const lowerText = text . toLowerCase ( ) ;
if ( lowerText . includes ( lowerTerm ) ) {
const fragment = document . createDocumentFragment ( ) ;
let lastIndex = 0 ;
let index ;
while ( ( index = lowerText . indexOf ( lowerTerm , lastIndex ) ) !== - 1 ) {
// Add text before match
if ( index > lastIndex ) {
fragment . appendChild (
document . createTextNode ( text . substring ( lastIndex , index ) )
) ;
}
// Add highlighted match
const mark = document . createElement ( 'mark' ) ;
mark . className = 'search-highlight' ;
mark . setAttribute ( 'data-match-index' , matchIndex ) ;
mark . textContent = text . substring ( index , index + searchTerm . length ) ;
2025-12-05 11:00:53 +00:00
// First match is active (styled via CSS)
2025-11-05 16:48:41 +00:00
if ( matchIndex === 0 ) {
mark . classList . add ( 'active-match' ) ;
}
fragment . appendChild ( mark ) ;
matchIndex ++ ;
lastIndex = index + searchTerm . length ;
}
// Add remaining text
if ( lastIndex < text . length ) {
fragment . appendChild (
document . createTextNode ( text . substring ( lastIndex ) )
) ;
}
// Replace text node with highlighted fragment
textNode . parentNode . replaceChild ( fragment , textNode ) ;
}
} ) ;
// Update total matches and reset current index
this . totalMatches = matchIndex ;
this . currentMatchIndex = matchIndex > 0 ? 0 : - 1 ;
// Scroll to first match
if ( this . totalMatches > 0 ) {
this . scrollToMatch ( 0 ) ;
}
} ,
// Navigate to next search match
nextMatch ( ) {
if ( this . totalMatches === 0 ) return ;
this . currentMatchIndex = ( this . currentMatchIndex + 1 ) % this . totalMatches ;
this . scrollToMatch ( this . currentMatchIndex ) ;
} ,
// Navigate to previous search match
previousMatch ( ) {
if ( this . totalMatches === 0 ) return ;
this . currentMatchIndex = ( this . currentMatchIndex - 1 + this . totalMatches ) % this . totalMatches ;
this . scrollToMatch ( this . currentMatchIndex ) ;
} ,
// Scroll to a specific match index
scrollToMatch ( index ) {
const preview = document . querySelector ( '.markdown-preview' ) ;
if ( ! preview ) return ;
const allMatches = preview . querySelectorAll ( 'mark.search-highlight' ) ;
if ( index < 0 || index >= allMatches . length ) return ;
2025-12-05 11:00:53 +00:00
// Update styling - make current match prominent (via CSS class)
2025-11-05 16:48:41 +00:00
allMatches . forEach ( ( mark , i ) => {
2025-12-05 11:00:53 +00:00
mark . classList . toggle ( 'active-match' , i === index ) ;
2025-11-05 16:48:41 +00:00
} ) ;
// Scroll to the match
const targetMatch = allMatches [ index ] ;
const previewContainer = this . _domCache . previewContainer ;
if ( previewContainer && targetMatch ) {
const elementTop = targetMatch . offsetTop ;
previewContainer . scrollTop = elementTop - 100 ; // Scroll with some offset
}
} ,
// Clear search highlights
clearSearchHighlights ( ) {
const preview = document . querySelector ( '.markdown-preview' ) ;
if ( ! preview ) return ;
const highlights = preview . querySelectorAll ( 'mark.search-highlight' ) ;
highlights . forEach ( mark => {
const text = document . createTextNode ( mark . textContent ) ;
mark . parentNode . replaceChild ( text , mark ) ;
} ) ;
// Normalize text nodes to merge adjacent text nodes
preview . normalize ( ) ;
// Reset match counters
this . totalMatches = 0 ;
this . currentMatchIndex = - 1 ;
} ,
2025-11-17 13:20:57 +00:00
// =====================================================
// DROPDOWN MENU SYSTEM
// =====================================================
2025-11-26 13:38:47 +00:00
toggleNewDropdown ( event ) {
this . showNewDropdown = true ; // Always open (or keep open)
if ( event && event . target ) {
const rect = event . target . getBoundingClientRect ( ) ;
// Position dropdown next to the clicked element
let top = rect . bottom + 4 ; // 4px spacing
let left = rect . left ;
// Keep dropdown on screen
const dropdownWidth = 200 ;
const dropdownHeight = 150 ;
if ( left + dropdownWidth > window . innerWidth ) {
left = rect . right - dropdownWidth ;
}
if ( top + dropdownHeight > window . innerHeight ) {
top = rect . top - dropdownHeight - 4 ;
}
this . dropdownPosition = { top , left } ;
2025-11-26 11:43:37 +00:00
}
2025-11-17 13:20:57 +00:00
} ,
closeDropdown ( ) {
this . showNewDropdown = false ;
2025-11-27 13:13:39 +00:00
this . dropdownTargetFolder = null ; // Reset folder context
2025-11-17 13:20:57 +00:00
} ,
// =====================================================
// UNIFIED CREATION FUNCTIONS (reusable from anywhere)
// =====================================================
2025-11-26 11:43:37 +00:00
async createNote ( folderPath = null ) {
2025-11-27 10:17:06 +00:00
// Use provided folder path, or dropdown target folder context, or homepage folder
2025-11-27 13:13:39 +00:00
// Note: Check dropdownTargetFolder !== null to distinguish between '' (root) and not set
let targetFolder ;
if ( folderPath !== null ) {
targetFolder = folderPath ;
} else if ( this . dropdownTargetFolder !== null && this . dropdownTargetFolder !== undefined ) {
targetFolder = this . dropdownTargetFolder ; // Can be '' for root or a folder path
} else {
targetFolder = this . selectedHomepageFolder || '' ;
}
2025-11-17 13:20:57 +00:00
this . closeDropdown ( ) ;
2025-11-26 11:43:37 +00:00
const promptText = targetFolder
2025-12-14 17:02:57 +00:00
? this . t ( 'notes.prompt_name_in_folder' , { folder : targetFolder } )
: this . t ( 'notes.prompt_name_with_path' ) ;
2025-11-17 13:20:57 +00:00
const noteName = prompt ( promptText ) ;
2025-11-05 16:48:41 +00:00
if ( ! noteName ) return ;
const sanitizedName = noteName . trim ( ) . replace ( /[^a-zA-Z0-9-_\s\/]/g , '' ) ;
if ( ! sanitizedName ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'notes.invalid_name' ) ) ;
2025-11-05 16:48:41 +00:00
return ;
}
2025-11-17 13:20:57 +00:00
let notePath ;
2025-11-26 11:43:37 +00:00
if ( targetFolder ) {
notePath = ` ${ targetFolder } / ${ sanitizedName } .md ` ;
2025-11-17 13:20:57 +00:00
} else {
notePath = sanitizedName . endsWith ( '.md' ) ? sanitizedName : ` ${ sanitizedName } .md ` ;
}
// CRITICAL: Check if note already exists
const existingNote = this . notes . find ( note => note . path === notePath ) ;
if ( existingNote ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'notes.already_exists' , { name : sanitizedName } ) ) ;
2025-11-17 13:20:57 +00:00
return ;
}
2025-11-05 16:48:41 +00:00
try {
const response = await fetch ( ` /api/notes/ ${ notePath } ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { content : '' } )
} ) ;
if ( response . ok ) {
2025-11-26 11:43:37 +00:00
if ( targetFolder ) {
this . expandedFolders . add ( targetFolder ) ;
2025-11-17 13:20:57 +00:00
}
2025-11-05 16:48:41 +00:00
await this . loadNotes ( ) ;
await this . loadNote ( notePath ) ;
} else {
ErrorHandler . handle ( 'create note' , new Error ( 'Server returned error' ) ) ;
}
} catch ( error ) {
ErrorHandler . handle ( 'create note' , error ) ;
}
} ,
2025-11-26 11:43:37 +00:00
async createFolder ( parentPath = null ) {
2025-11-27 10:17:06 +00:00
// Use provided parent path, or dropdown target folder context, or homepage folder
2025-11-27 13:13:39 +00:00
// Note: Check dropdownTargetFolder !== null to distinguish between '' (root) and not set
let targetFolder ;
if ( parentPath !== null ) {
targetFolder = parentPath ;
} else if ( this . dropdownTargetFolder !== null && this . dropdownTargetFolder !== undefined ) {
targetFolder = this . dropdownTargetFolder ; // Can be '' for root or a folder path
} else {
targetFolder = this . selectedHomepageFolder || '' ;
}
2025-11-17 13:20:57 +00:00
this . closeDropdown ( ) ;
2025-11-26 11:43:37 +00:00
const promptText = targetFolder
2025-12-14 17:02:57 +00:00
? this . t ( 'folders.prompt_name_in_folder' , { folder : targetFolder } )
: this . t ( 'folders.prompt_name_with_path' ) ;
2025-11-05 16:48:41 +00:00
2025-11-17 13:20:57 +00:00
const folderName = prompt ( promptText ) ;
2025-11-05 16:48:41 +00:00
if ( ! folderName ) return ;
const sanitizedName = folderName . trim ( ) . replace ( /[^a-zA-Z0-9-_\s\/]/g , '' ) ;
if ( ! sanitizedName ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'folders.invalid_name' ) ) ;
2025-11-05 16:48:41 +00:00
return ;
}
2025-11-26 11:43:37 +00:00
const folderPath = targetFolder ? ` ${ targetFolder } / ${ sanitizedName } ` : sanitizedName ;
2025-11-05 16:48:41 +00:00
2025-11-17 13:20:57 +00:00
// Check if folder already exists
const existingFolder = this . allFolders . find ( folder => folder === folderPath ) ;
if ( existingFolder ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'folders.already_exists' , { name : sanitizedName } ) ) ;
2025-11-17 13:20:57 +00:00
return ;
}
2025-11-05 16:48:41 +00:00
try {
const response = await fetch ( '/api/folders' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { path : folderPath } )
} ) ;
if ( response . ok ) {
2025-11-26 11:43:37 +00:00
if ( targetFolder ) {
this . expandedFolders . add ( targetFolder ) ;
2025-11-05 16:48:41 +00:00
}
2025-11-17 13:20:57 +00:00
this . expandedFolders . add ( folderPath ) ;
2025-11-05 16:48:41 +00:00
await this . loadNotes ( ) ;
2025-11-27 10:30:49 +00:00
// Navigate to the newly created folder on the homepage
this . goToHomepageFolder ( folderPath ) ;
2025-11-05 16:48:41 +00:00
} else {
ErrorHandler . handle ( 'create folder' , new Error ( 'Server returned error' ) ) ;
}
} catch ( error ) {
ErrorHandler . handle ( 'create folder' , error ) ;
}
} ,
// Rename a folder
async renameFolder ( folderPath , currentName ) {
const newName = prompt ( ` Rename folder " ${ currentName } " to: ` , currentName ) ;
if ( ! newName || newName === currentName ) return ;
const sanitizedName = newName . trim ( ) . replace ( /[^a-zA-Z0-9-_\s]/g , '' ) ;
if ( ! sanitizedName ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'folders.invalid_name' ) ) ;
2025-11-05 16:48:41 +00:00
return ;
}
// Calculate new path
const pathParts = folderPath . split ( '/' ) ;
pathParts [ pathParts . length - 1 ] = sanitizedName ;
const newPath = pathParts . join ( '/' ) ;
try {
const response = await fetch ( '/api/folders/rename' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
oldPath : folderPath ,
newPath : newPath
} )
} ) ;
if ( response . ok ) {
// Update expanded folders state
if ( this . expandedFolders . has ( folderPath ) ) {
this . expandedFolders . delete ( folderPath ) ;
this . expandedFolders . add ( newPath ) ;
}
2025-12-15 11:18:14 +00:00
// Update favorites that were in the renamed folder
const folderPrefix = folderPath + '/' ;
const newFolderPrefix = newPath + '/' ;
const newFavorites = this . favorites . map ( f => {
if ( f . startsWith ( folderPrefix ) ) {
return f . replace ( folderPrefix , newFolderPrefix ) ;
}
return f ;
} ) ;
// Check if anything changed
if ( JSON . stringify ( newFavorites ) !== JSON . stringify ( this . favorites ) ) {
this . favorites = newFavorites ;
this . favoritesSet = new Set ( newFavorites ) ;
this . saveFavorites ( ) ;
}
2025-11-05 16:48:41 +00:00
// Update current note path if it's in the renamed folder
2025-12-15 11:18:14 +00:00
if ( this . currentNote && this . currentNote . startsWith ( folderPrefix ) ) {
this . currentNote = this . currentNote . replace ( folderPrefix , newFolderPrefix ) ;
2025-11-05 16:48:41 +00:00
}
await this . loadNotes ( ) ;
} else {
ErrorHandler . handle ( 'rename folder' , new Error ( 'Server returned error' ) ) ;
}
} catch ( error ) {
ErrorHandler . handle ( 'rename folder' , error ) ;
}
} ,
// Delete folder
async deleteFolder ( folderPath , folderName ) {
2025-12-14 17:02:57 +00:00
const confirmation = confirm ( this . t ( 'folders.confirm_delete' , { name : folderName } ) ) ;
2025-11-05 16:48:41 +00:00
if ( ! confirmation ) return ;
try {
const response = await fetch ( ` /api/folders/ ${ encodeURIComponent ( folderPath ) } ` , {
method : 'DELETE' ,
headers : { 'Content-Type' : 'application/json' }
} ) ;
if ( response . ok ) {
// Remove from expanded folders
this . expandedFolders . delete ( folderPath ) ;
2025-12-15 11:18:14 +00:00
// Remove any favorites that were in the deleted folder
const folderPrefix = folderPath + '/' ;
const newFavorites = this . favorites . filter ( f => ! f . startsWith ( folderPrefix ) ) ;
if ( newFavorites . length !== this . favorites . length ) {
this . favorites = newFavorites ;
this . favoritesSet = new Set ( newFavorites ) ;
this . saveFavorites ( ) ;
}
2025-11-05 16:48:41 +00:00
// Clear current note if it was in the deleted folder
2025-12-15 11:18:14 +00:00
if ( this . currentNote && this . currentNote . startsWith ( folderPrefix ) ) {
2025-11-05 16:48:41 +00:00
this . currentNote = '' ;
this . noteContent = '' ;
}
await this . loadNotes ( ) ;
} else {
ErrorHandler . handle ( 'delete folder' , new Error ( 'Server returned error' ) ) ;
}
} catch ( error ) {
ErrorHandler . handle ( 'delete folder' , error ) ;
}
} ,
// Auto-save with debounce
autoSave ( ) {
if ( this . saveTimeout ) {
clearTimeout ( this . saveTimeout ) ;
}
this . lastSaved = false ;
// Push to undo history (but not during undo/redo operations)
if ( ! this . isUndoRedo ) {
this . pushToHistory ( ) ;
}
// Calculate stats in real-time if plugin enabled
if ( this . statsPluginEnabled ) {
this . calculateStats ( ) ;
}
2025-12-04 09:07:07 +00:00
// Parse metadata in real-time
this . parseMetadata ( ) ;
2025-11-05 16:48:41 +00:00
this . saveTimeout = setTimeout ( ( ) => {
this . saveNote ( ) ;
} , CONFIG . AUTOSAVE _DELAY ) ;
} ,
2025-12-14 14:16:06 +00:00
// Push current content to undo history (with cursor position)
2025-11-05 16:48:41 +00:00
pushToHistory ( ) {
2025-12-14 14:16:06 +00:00
const editor = document . getElementById ( 'note-editor' ) ;
const cursorPos = editor ? editor . selectionStart : 0 ;
2025-11-05 16:48:41 +00:00
// Only push if content actually changed
if ( this . undoHistory . length > 0 &&
2025-12-14 14:16:06 +00:00
this . undoHistory [ this . undoHistory . length - 1 ] . content === this . noteContent ) {
2025-11-05 16:48:41 +00:00
return ;
}
2025-12-14 14:16:06 +00:00
this . undoHistory . push ( { content : this . noteContent , cursorPos } ) ;
2025-11-05 16:48:41 +00:00
// Limit history size
if ( this . undoHistory . length > this . maxHistorySize ) {
this . undoHistory . shift ( ) ;
}
// Clear redo history when new change is made
this . redoHistory = [ ] ;
} ,
// Undo last change
undo ( ) {
if ( ! this . currentNote || this . undoHistory . length <= 1 ) return ;
2025-12-14 14:16:06 +00:00
const editor = document . getElementById ( 'note-editor' ) ;
2025-11-05 16:48:41 +00:00
// Pop current state to redo history
2025-12-14 14:16:06 +00:00
const currentState = this . undoHistory . pop ( ) ;
this . redoHistory . push ( currentState ) ;
2025-11-05 16:48:41 +00:00
// Get previous state
2025-12-14 14:16:06 +00:00
const previousState = this . undoHistory [ this . undoHistory . length - 1 ] ;
2025-11-05 16:48:41 +00:00
// Apply previous state
this . isUndoRedo = true ;
2025-12-14 14:16:06 +00:00
this . noteContent = previousState . content ;
2025-11-05 16:48:41 +00:00
// Recalculate stats with new content
if ( this . statsPluginEnabled ) {
this . calculateStats ( ) ;
}
2025-12-14 14:16:06 +00:00
// Restore cursor position from the state we're going back to
2025-11-05 16:48:41 +00:00
this . $nextTick ( ( ) => {
this . saveNote ( ) ;
this . isUndoRedo = false ;
2025-12-14 14:16:06 +00:00
if ( editor ) {
setTimeout ( ( ) => {
const newPos = Math . min ( previousState . cursorPos , this . noteContent . length ) ;
editor . setSelectionRange ( newPos , newPos ) ;
editor . focus ( ) ;
} , 0 ) ;
}
2025-11-05 16:48:41 +00:00
} ) ;
} ,
// Redo last undone change
redo ( ) {
if ( ! this . currentNote || this . redoHistory . length === 0 ) return ;
2025-12-14 14:16:06 +00:00
const editor = document . getElementById ( 'note-editor' ) ;
2025-11-05 16:48:41 +00:00
// Pop from redo history
2025-12-14 14:16:06 +00:00
const nextState = this . redoHistory . pop ( ) ;
2025-11-05 16:48:41 +00:00
// Push to undo history
2025-12-14 14:16:06 +00:00
this . undoHistory . push ( nextState ) ;
2025-11-05 16:48:41 +00:00
// Apply next state
this . isUndoRedo = true ;
2025-12-14 14:16:06 +00:00
this . noteContent = nextState . content ;
2025-11-05 16:48:41 +00:00
// Recalculate stats with new content
if ( this . statsPluginEnabled ) {
this . calculateStats ( ) ;
}
2025-12-14 14:16:06 +00:00
// Restore cursor position from the state we're going forward to
2025-11-05 16:48:41 +00:00
this . $nextTick ( ( ) => {
this . saveNote ( ) ;
this . isUndoRedo = false ;
2025-12-14 14:16:06 +00:00
if ( editor ) {
setTimeout ( ( ) => {
const newPos = Math . min ( nextState . cursorPos , this . noteContent . length ) ;
editor . setSelectionRange ( newPos , newPos ) ;
editor . focus ( ) ;
} , 0 ) ;
}
2025-11-05 16:48:41 +00:00
} ) ;
} ,
2025-11-24 09:17:21 +00:00
// Markdown formatting helpers
wrapSelection ( before , after , placeholder ) {
const editor = document . getElementById ( 'note-editor' ) ;
if ( ! editor ) return ;
const start = editor . selectionStart ;
const end = editor . selectionEnd ;
const selectedText = this . noteContent . substring ( start , end ) ;
const textToWrap = selectedText || placeholder ;
// Build the new text
const newText = before + textToWrap + after ;
// Update content
this . noteContent = this . noteContent . substring ( 0 , start ) + newText + this . noteContent . substring ( end ) ;
// Set cursor position (select the wrapped text or placeholder)
this . $nextTick ( ( ) => {
if ( selectedText ) {
// If text was selected, keep it selected (inside the wrapper)
editor . setSelectionRange ( start + before . length , start + before . length + selectedText . length ) ;
} else {
// If no text selected, select the placeholder
editor . setSelectionRange ( start + before . length , start + before . length + placeholder . length ) ;
}
editor . focus ( ) ;
} ) ;
// Trigger autosave
this . autoSave ( ) ;
} ,
insertLink ( ) {
const editor = document . getElementById ( 'note-editor' ) ;
if ( ! editor ) return ;
const start = editor . selectionStart ;
const end = editor . selectionEnd ;
const selectedText = this . noteContent . substring ( start , end ) ;
// If text is selected, use it as link text; otherwise use placeholder
const linkText = selectedText || 'link text' ;
const linkUrl = 'url' ;
// Build the markdown link
const newText = ` [ ${ linkText } ]( ${ linkUrl } ) ` ;
// Update content
this . noteContent = this . noteContent . substring ( 0 , start ) + newText + this . noteContent . substring ( end ) ;
// Set cursor position to select the URL part for easy editing
this . $nextTick ( ( ) => {
const urlStart = start + linkText . length + 3 ; // After "[linkText]("
const urlEnd = urlStart + linkUrl . length ;
editor . setSelectionRange ( urlStart , urlEnd ) ;
editor . focus ( ) ;
} ) ;
// Trigger autosave
this . autoSave ( ) ;
} ,
2025-12-11 17:11:25 +00:00
// Insert a markdown table placeholder
insertTable ( ) {
const editor = document . getElementById ( 'note-editor' ) ;
if ( ! editor ) return ;
const cursorPos = editor . selectionStart ;
// Basic 3x3 table placeholder
const table = ` | Header 1 | Header 2 | Header 3 |
| -- -- -- -- -- | -- -- -- -- -- | -- -- -- -- -- |
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
` ;
// Add newline before if not at start of line
const textBefore = this . noteContent . substring ( 0 , cursorPos ) ;
const needsNewlineBefore = textBefore . length > 0 && ! textBefore . endsWith ( '\n' ) ;
const prefix = needsNewlineBefore ? '\n\n' : '' ;
// Insert the table
this . noteContent = textBefore + prefix + table + this . noteContent . substring ( cursorPos ) ;
// Position cursor at first header for easy editing
this . $nextTick ( ( ) => {
const newPos = cursorPos + prefix . length + 2 ; // After "| "
editor . setSelectionRange ( newPos , newPos + 8 ) ; // Select "Header 1"
editor . focus ( ) ;
} ) ;
// Trigger autosave
this . autoSave ( ) ;
} ,
2025-11-05 16:48:41 +00:00
// Save current note
async saveNote ( ) {
if ( ! this . currentNote ) return ;
this . isSaving = true ;
try {
const response = await fetch ( ` /api/notes/ ${ this . currentNote } ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { content : this . noteContent } )
} ) ;
if ( response . ok ) {
this . lastSaved = true ;
// Update only the modified timestamp for the current note (no full reload needed)
const note = this . notes . find ( n => n . path === this . currentNote ) ;
if ( note ) {
note . modified = new Date ( ) . toISOString ( ) ;
2025-11-20 14:31:36 +00:00
note . size = new Blob ( [ this . noteContent ] ) . size ;
2025-11-24 16:11:22 +00:00
// Parse tags from content
note . tags = this . parseTagsFromContent ( this . noteContent ) ;
}
// Reload tags to update sidebar counts (debounced to prevent spam)
this . loadTagsDebounced ( ) ;
// Rebuild folder tree if tag filters are active
if ( this . selectedTags . length > 0 ) {
this . buildFolderTree ( ) ;
2025-11-05 16:48:41 +00:00
}
// Hide "saved" indicator
setTimeout ( ( ) => {
this . lastSaved = false ;
} , CONFIG . SAVE _INDICATOR _DURATION ) ;
} else {
ErrorHandler . handle ( 'save note' , new Error ( 'Server returned error' ) ) ;
}
} catch ( error ) {
ErrorHandler . handle ( 'save note' , error ) ;
} finally {
this . isSaving = false ;
}
} ,
// Rename current note
async renameNote ( ) {
if ( ! this . currentNote ) return ;
const oldPath = this . currentNote ;
const newName = this . currentNoteName . trim ( ) ;
if ( ! newName ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'notes.empty_name' ) ) ;
2025-11-05 16:48:41 +00:00
return ;
}
const folder = oldPath . split ( '/' ) . slice ( 0 , - 1 ) . join ( '/' ) ;
const newPath = folder ? ` ${ folder } / ${ newName } .md ` : ` ${ newName } .md ` ;
if ( oldPath === newPath ) return ;
2025-12-11 10:00:21 +00:00
// Check if a note with the new name already exists
const existingNote = this . notes . find ( n => n . path . toLowerCase ( ) === newPath . toLowerCase ( ) ) ;
if ( existingNote ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'notes.already_exists' , { name : newName } ) ) ;
2025-12-11 10:00:21 +00:00
// Reset the name in the UI
this . currentNoteName = oldPath . split ( '/' ) . pop ( ) . replace ( '.md' , '' ) ;
return ;
}
2025-11-05 16:48:41 +00:00
// Create new note with same content
try {
const response = await fetch ( ` /api/notes/ ${ newPath } ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { content : this . noteContent } )
} ) ;
if ( response . ok ) {
// Delete old note
await fetch ( ` /api/notes/ ${ oldPath } ` , { method : 'DELETE' } ) ;
2025-12-15 11:18:14 +00:00
// Update favorites if the renamed note was favorited
if ( this . favoritesSet . has ( oldPath ) ) {
const newFavorites = this . favorites . map ( f => f === oldPath ? newPath : f ) ;
this . favorites = newFavorites ;
this . favoritesSet = new Set ( newFavorites ) ;
this . saveFavorites ( ) ;
}
2025-11-05 16:48:41 +00:00
this . currentNote = newPath ;
await this . loadNotes ( ) ;
} else {
ErrorHandler . handle ( 'rename note' , new Error ( 'Server returned error' ) ) ;
}
} catch ( error ) {
ErrorHandler . handle ( 'rename note' , error ) ;
}
} ,
// Delete current note
async deleteCurrentNote ( ) {
if ( ! this . currentNote ) return ;
2025-11-20 13:40:14 +00:00
// Just call deleteNote with current note details
await this . deleteNote ( this . currentNote , this . currentNoteName ) ;
2025-11-05 16:48:41 +00:00
} ,
// Delete any note from sidebar
async deleteNote ( notePath , noteName ) {
2025-12-14 17:02:57 +00:00
if ( ! confirm ( this . t ( 'notes.confirm_delete' , { name : noteName } ) ) ) return ;
2025-11-05 16:48:41 +00:00
try {
const response = await fetch ( ` /api/notes/ ${ notePath } ` , {
method : 'DELETE'
} ) ;
if ( response . ok ) {
2025-12-15 11:18:14 +00:00
// Remove from favorites if it was favorited
if ( this . favoritesSet . has ( notePath ) ) {
const newFavorites = this . favorites . filter ( f => f !== notePath ) ;
this . favorites = newFavorites ;
this . favoritesSet = new Set ( newFavorites ) ;
this . saveFavorites ( ) ;
}
2025-11-05 16:48:41 +00:00
// If the deleted note is currently open, clear it
if ( this . currentNote === notePath ) {
this . currentNote = '' ;
this . noteContent = '' ;
this . currentNoteName = '' ;
2025-12-12 17:11:29 +00:00
this . _lastRenderedContent = '' ; // Clear render cache
this . _cachedRenderedHTML = '' ;
2025-11-05 16:48:41 +00:00
// Redirect to root
window . history . replaceState ( { } , '' , '/' ) ;
}
await this . loadNotes ( ) ;
} else {
ErrorHandler . handle ( 'delete note' , new Error ( 'Server returned error' ) ) ;
}
} catch ( error ) {
ErrorHandler . handle ( 'delete note' , error ) ;
}
} ,
// Search notes
2025-11-24 16:11:22 +00:00
// Search notes by text (calls unified filter logic)
2025-11-05 16:48:41 +00:00
async searchNotes ( ) {
2025-11-24 16:11:22 +00:00
await this . applyFilters ( ) ;
2025-11-05 16:48:41 +00:00
} ,
2025-11-11 13:51:55 +00:00
// Trigger MathJax typesetting after DOM update
typesetMath ( ) {
if ( typeof MathJax !== 'undefined' && MathJax . typesetPromise ) {
// Use a small delay to ensure DOM is updated
setTimeout ( ( ) => {
const previewContent = document . querySelector ( '.markdown-preview' ) ;
if ( previewContent ) {
MathJax . typesetPromise ( [ previewContent ] ) . catch ( ( err ) => {
console . error ( 'MathJax typesetting failed:' , err ) ;
} ) ;
}
} , 10 ) ;
}
} ,
2025-11-17 16:29:00 +00:00
// Render Mermaid diagrams
async renderMermaid ( ) {
if ( typeof window . mermaid === 'undefined' ) {
console . warn ( 'Mermaid not loaded yet' ) ;
return ;
}
// Use requestAnimationFrame for better performance than setTimeout
requestAnimationFrame ( async ( ) => {
const previewContent = document . querySelector ( '.markdown-preview' ) ;
if ( ! previewContent ) return ;
// Get the appropriate theme based on current app theme
const themeType = this . getThemeType ( ) ;
const mermaidTheme = themeType === 'light' ? 'default' : 'dark' ;
// Only reinitialize if theme changed (performance optimization)
if ( this . lastMermaidTheme !== mermaidTheme ) {
window . mermaid . initialize ( {
startOnLoad : false ,
theme : mermaidTheme ,
securityLevel : 'strict' , // Use strict for better security
2025-12-06 12:06:23 +00:00
fontFamily : 'inherit' ,
// v11 changed useMaxWidth defaults - restore responsive behavior
flowchart : { useMaxWidth : true } ,
sequence : { useMaxWidth : true } ,
gantt : { useMaxWidth : true } ,
journey : { useMaxWidth : true } ,
timeline : { useMaxWidth : true } ,
class : { useMaxWidth : true } ,
state : { useMaxWidth : true } ,
er : { useMaxWidth : true } ,
pie : { useMaxWidth : true } ,
quadrantChart : { useMaxWidth : true } ,
requirement : { useMaxWidth : true } ,
mindmap : { useMaxWidth : true } ,
gitGraph : { useMaxWidth : true }
2025-11-17 16:29:00 +00:00
} ) ;
this . lastMermaidTheme = mermaidTheme ;
}
// Find all code blocks with language 'mermaid'
const mermaidBlocks = previewContent . querySelectorAll ( 'pre code.language-mermaid' ) ;
// Early return if no diagrams to render
if ( mermaidBlocks . length === 0 ) return ;
for ( let i = 0 ; i < mermaidBlocks . length ; i ++ ) {
const block = mermaidBlocks [ i ] ;
const pre = block . parentElement ;
// Skip if already rendered (performance optimization)
if ( pre . querySelector ( '.mermaid-rendered' ) ) continue ;
try {
const code = block . textContent ;
const id = ` mermaid-diagram- ${ Date . now ( ) } - ${ i } ` ;
// Render the diagram
const { svg } = await window . mermaid . render ( id , code ) ;
// Create a container for the rendered diagram
const container = document . createElement ( 'div' ) ;
container . className = 'mermaid-rendered' ;
container . style . cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;' ;
container . innerHTML = svg ;
// Store original code for theme re-rendering
container . dataset . originalCode = code ;
// Replace the code block with the rendered diagram
pre . parentElement . replaceChild ( container , pre ) ;
} catch ( error ) {
console . error ( 'Mermaid rendering error:' , error ) ;
// Add error indicator to the code block
const errorMsg = document . createElement ( 'div' ) ;
errorMsg . style . cssText = 'color: var(--error); padding: 10px; border-left: 3px solid var(--error); margin-top: 10px;' ;
errorMsg . textContent = ` ⚠️ Mermaid diagram error: ${ error . message } ` ;
pre . parentElement . insertBefore ( errorMsg , pre . nextSibling ) ;
}
}
} ) ;
} ,
// Get current theme type (light or dark)
// Returns: 'light' or 'dark'
// Used by features that need to adapt to theme brightness (e.g., Mermaid diagrams, Chart.js)
getThemeType ( ) {
// Handle system theme
if ( this . currentTheme === 'system' ) {
const isDark = window . matchMedia && window . matchMedia ( '(prefers-color-scheme: dark)' ) . matches ;
return isDark ? 'dark' : 'light' ;
}
// Try to get theme type from loaded theme metadata
const currentThemeData = this . availableThemes . find ( t => t . id === this . currentTheme ) ;
if ( currentThemeData && currentThemeData . type ) {
// Use metadata from theme file (light or dark)
return currentThemeData . type ; // Already 'light' or 'dark'
}
// Backward compatibility: fallback to hardcoded map if metadata not available
const fallbackMap = {
'light' : 'light' ,
'vs-blue' : 'light'
} ;
return fallbackMap [ this . currentTheme ] || 'dark' ;
} ,
2025-11-05 16:48:41 +00:00
// Computed property for rendered markdown
get renderedMarkdown ( ) {
if ( ! this . noteContent ) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>' ;
2025-12-12 17:11:29 +00:00
// Performance: Return cached HTML if content hasn't changed
if ( this . noteContent === this . _lastRenderedContent && this . _cachedRenderedHTML ) {
return this . _cachedRenderedHTML ;
}
2025-11-24 16:11:22 +00:00
// Strip YAML frontmatter from content before rendering
let contentToRender = this . noteContent ;
if ( contentToRender . trim ( ) . startsWith ( '---' ) ) {
const lines = contentToRender . split ( '\n' ) ;
if ( lines [ 0 ] . trim ( ) === '---' ) {
// Find closing ---
let endIdx = - 1 ;
for ( let i = 1 ; i < lines . length ; i ++ ) {
if ( lines [ i ] . trim ( ) === '---' ) {
endIdx = i ;
break ;
}
}
if ( endIdx !== - 1 ) {
// Remove frontmatter (including the closing ---) and any empty lines after it
contentToRender = lines . slice ( endIdx + 1 ) . join ( '\n' ) . trim ( ) ;
}
}
}
2025-12-03 15:00:25 +00:00
// Convert Obsidian-style wikilinks: [[note]] or [[note|display text]]
// Must be done before marked.parse() to avoid conflicts with markdown syntax
2025-12-12 17:11:29 +00:00
const self = this ; // Reference for closure
2025-12-03 15:00:25 +00:00
contentToRender = contentToRender . replace (
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g ,
( match , target , displayText ) => {
const linkTarget = target . trim ( ) ;
const linkText = displayText ? displayText . trim ( ) : linkTarget ;
2025-12-12 17:11:29 +00:00
// Fast O(1) check using pre-built lookup maps
// Handle section anchors: extract base note path
const hashIndex = linkTarget . indexOf ( '#' ) ;
const basePath = hashIndex !== - 1 ? linkTarget . substring ( 0 , hashIndex ) : linkTarget ;
const noteExists = basePath === '' || self . wikiLinkExists ( basePath ) ;
2025-12-03 15:00:25 +00:00
// Escape special chars: href needs quote escaping, text needs HTML escaping
const safeHref = linkTarget . replace ( /"/g , '%22' ) ;
const safeText = linkText . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
// Return link with data attribute for styling broken links
const brokenClass = noteExists ? '' : ' class="wikilink-broken"' ;
return ` <a href=" ${ safeHref } " ${ brokenClass } data-wikilink="true"> ${ safeText } </a> ` ;
}
) ;
2025-11-05 16:48:41 +00:00
// Configure marked with syntax highlighting
marked . setOptions ( {
breaks : true ,
gfm : true ,
highlight : function ( code , lang ) {
if ( lang && hljs . getLanguage ( lang ) ) {
try {
return hljs . highlight ( code , { language : lang } ) . value ;
} catch ( err ) {
console . error ( 'Highlight error:' , err ) ;
}
}
return hljs . highlightAuto ( code ) . value ;
}
} ) ;
// Parse markdown
2025-11-24 16:11:22 +00:00
let html = marked . parse ( contentToRender ) ;
2025-11-05 16:48:41 +00:00
2025-11-23 15:59:57 +00:00
// Post-process: Add target="_blank" to external links and title attributes to images
2025-11-05 16:48:41 +00:00
// Parse as DOM to safely manipulate
const tempDiv = document . createElement ( 'div' ) ;
tempDiv . innerHTML = html ;
// Find all links
const links = tempDiv . querySelectorAll ( 'a' ) ;
links . forEach ( link => {
const href = link . getAttribute ( 'href' ) ;
if ( href && typeof href === 'string' ) {
// Check if it's an external link
const isExternal = href . indexOf ( 'http://' ) === 0 ||
href . indexOf ( 'https://' ) === 0 ||
href . indexOf ( '//' ) === 0 ;
if ( isExternal ) {
link . setAttribute ( 'target' , '_blank' ) ;
link . setAttribute ( 'rel' , 'noopener noreferrer' ) ;
}
}
} ) ;
2025-12-11 10:39:12 +00:00
// Find all images and transform paths for display
2025-11-23 15:59:57 +00:00
const images = tempDiv . querySelectorAll ( 'img' ) ;
images . forEach ( img => {
2025-12-11 10:39:12 +00:00
const src = img . getAttribute ( 'src' ) ;
if ( src ) {
// Transform relative paths to /api/images/ for serving
// Skip external URLs and already-transformed paths
if ( ! src . startsWith ( 'http://' ) && ! src . startsWith ( 'https://' ) &&
! src . startsWith ( '//' ) && ! src . startsWith ( '/api/images/' ) &&
! src . startsWith ( 'data:' ) ) {
// URL-encode path segments to handle spaces and special characters
const encodedPath = src . split ( '/' ) . map ( segment => encodeURIComponent ( segment ) ) . join ( '/' ) ;
img . setAttribute ( 'src' , ` /api/images/ ${ encodedPath } ` ) ;
}
}
2025-11-23 15:59:57 +00:00
const altText = img . getAttribute ( 'alt' ) ;
if ( altText ) {
// Set title attribute to show alt text on hover
img . setAttribute ( 'title' , altText ) ;
}
} ) ;
2025-11-05 16:48:41 +00:00
html = tempDiv . innerHTML ;
2025-12-12 17:11:29 +00:00
// Debounced MathJax rendering (avoid re-running on every keystroke)
if ( this . _mathDebounceTimeout ) clearTimeout ( this . _mathDebounceTimeout ) ;
this . _mathDebounceTimeout = setTimeout ( ( ) => this . typesetMath ( ) , 300 ) ;
2025-11-11 13:51:55 +00:00
2025-12-12 17:11:29 +00:00
// Debounced Mermaid rendering
if ( this . _mermaidDebounceTimeout ) clearTimeout ( this . _mermaidDebounceTimeout ) ;
this . _mermaidDebounceTimeout = setTimeout ( ( ) => this . renderMermaid ( ) , 300 ) ;
2025-11-17 16:29:00 +00:00
2025-11-05 16:48:41 +00:00
// Apply syntax highlighting and add copy buttons to code blocks
setTimeout ( ( ) => {
// Use cached reference if available, otherwise query
const previewEl = this . _domCache . previewContent || document . querySelector ( '.markdown-preview' ) ;
if ( previewEl ) {
2025-11-17 16:29:00 +00:00
// Exclude code blocks that are rendered by other tools (e.g., Mermaid diagrams)
// Note: MathJax uses $$...$$ delimiters (not code blocks) so no exclusion needed
previewEl . querySelectorAll ( 'pre code:not(.language-mermaid)' ) . forEach ( ( block ) => {
2025-11-05 16:48:41 +00:00
// Apply syntax highlighting
if ( ! block . classList . contains ( 'hljs' ) ) {
hljs . highlightElement ( block ) ;
}
// Add copy button if not already present
const pre = block . parentElement ;
if ( pre && ! pre . querySelector ( '.copy-code-button' ) ) {
this . addCopyButtonToCodeBlock ( pre ) ;
}
} ) ;
}
} , 0 ) ;
2025-12-12 17:11:29 +00:00
// Cache the result for performance
this . _lastRenderedContent = this . noteContent ;
this . _cachedRenderedHTML = html ;
2025-11-05 16:48:41 +00:00
return html ;
} ,
// Refresh DOM element cache
refreshDOMCache ( ) {
this . _domCache . editor = document . querySelector ( '.editor-textarea' ) ;
this . _domCache . previewContent = document . querySelector ( '.markdown-preview' ) ;
this . _domCache . previewContainer = this . _domCache . previewContent ? this . _domCache . previewContent . parentElement : null ;
} ,
// Add copy button to code block
addCopyButtonToCodeBlock ( preElement ) {
// Create copy button
const button = document . createElement ( 'button' ) ;
button . className = 'copy-code-button' ;
button . innerHTML = `
< svg width = "16" height = "16" 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" ry = "2" > < / r e c t >
< path d = "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" > < / p a t h >
< / s v g >
` ;
button . title = 'Copy to clipboard' ;
// Style the button
button . style . position = 'absolute' ;
button . style . top = '8px' ;
button . style . right = '8px' ;
button . style . padding = '6px' ;
button . style . backgroundColor = 'rgba(0, 0, 0, 0.6)' ;
button . style . border = 'none' ;
button . style . borderRadius = '4px' ;
button . style . cursor = 'pointer' ;
button . style . opacity = '0' ;
button . style . transition = 'opacity 0.2s, background-color 0.2s' ;
button . style . color = 'white' ;
button . style . display = 'flex' ;
button . style . alignItems = 'center' ;
button . style . justifyContent = 'center' ;
button . style . zIndex = '10' ;
// Style the pre element to be relative
preElement . style . position = 'relative' ;
// Show button on hover
preElement . addEventListener ( 'mouseenter' , ( ) => {
button . style . opacity = '1' ;
} ) ;
preElement . addEventListener ( 'mouseleave' , ( ) => {
button . style . opacity = '0' ;
} ) ;
// Copy to clipboard on click
button . addEventListener ( 'click' , async ( e ) => {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
const codeElement = preElement . querySelector ( 'code' ) ;
if ( ! codeElement ) return ;
const code = codeElement . textContent ;
try {
await navigator . clipboard . writeText ( code ) ;
// Visual feedback - change icon to checkmark
button . innerHTML = `
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" >
< polyline points = "20 6 9 17 4 12" > < / p o l y l i n e >
< / s v g >
` ;
button . style . backgroundColor = 'rgba(34, 197, 94, 0.8)' ;
button . title = 'Copied!' ;
// Reset after 2 seconds
setTimeout ( ( ) => {
button . innerHTML = `
< svg width = "16" height = "16" 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" ry = "2" > < / r e c t >
< path d = "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" > < / p a t h >
< / s v g >
` ;
button . style . backgroundColor = 'rgba(0, 0, 0, 0.6)' ;
button . title = 'Copy to clipboard' ;
} , 2000 ) ;
} catch ( err ) {
console . error ( 'Failed to copy code:' , err ) ;
// Fallback for older browsers
const textArea = document . createElement ( 'textarea' ) ;
textArea . value = code ;
textArea . style . position = 'fixed' ;
textArea . style . left = '-999999px' ;
document . body . appendChild ( textArea ) ;
textArea . select ( ) ;
try {
document . execCommand ( 'copy' ) ;
button . innerHTML = `
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" >
< polyline points = "20 6 9 17 4 12" > < / p o l y l i n e >
< / s v g >
` ;
button . style . backgroundColor = 'rgba(34, 197, 94, 0.8)' ;
setTimeout ( ( ) => {
button . innerHTML = `
< svg width = "16" height = "16" 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" ry = "2" > < / r e c t >
< path d = "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" > < / p a t h >
< / s v g >
` ;
button . style . backgroundColor = 'rgba(0, 0, 0, 0.6)' ;
} , 2000 ) ;
} catch ( fallbackErr ) {
console . error ( 'Fallback copy failed:' , fallbackErr ) ;
}
document . body . removeChild ( textArea ) ;
}
} ) ;
// Add button to pre element
preElement . appendChild ( button ) ;
} ,
// Setup scroll synchronization
setupScrollSync ( ) {
// Use cached references (refresh if not available)
if ( ! this . _domCache . editor || ! this . _domCache . previewContainer ) {
this . refreshDOMCache ( ) ;
}
const editor = this . _domCache . editor ;
const preview = this . _domCache . previewContainer ;
if ( ! editor || ! preview ) {
// If elements don't exist yet, retry with limit
if ( ! this . _setupScrollSyncRetries ) this . _setupScrollSyncRetries = 0 ;
if ( this . _setupScrollSyncRetries < CONFIG . SCROLL _SYNC _MAX _RETRIES ) {
this . _setupScrollSyncRetries ++ ;
setTimeout ( ( ) => this . setupScrollSync ( ) , CONFIG . SCROLL _SYNC _RETRY _INTERVAL ) ;
} else {
console . warn ( ` setupScrollSync: Failed to find editor/preview elements after ${ CONFIG . SCROLL _SYNC _MAX _RETRIES } retries ` ) ;
}
return ;
}
// Reset retry counter on success
this . _setupScrollSyncRetries = 0 ;
// Remove old listeners if they exist
if ( this . _editorScrollHandler ) {
editor . removeEventListener ( 'scroll' , this . _editorScrollHandler ) ;
}
if ( this . _previewScrollHandler ) {
preview . removeEventListener ( 'scroll' , this . _previewScrollHandler ) ;
}
// Create new scroll handlers
this . _editorScrollHandler = ( ) => {
if ( this . isScrolling ) {
this . isScrolling = false ;
return ;
}
const scrollableHeight = editor . scrollHeight - editor . clientHeight ;
if ( scrollableHeight <= 0 ) return ; // No scrolling needed
const scrollPercentage = editor . scrollTop / scrollableHeight ;
const previewScrollableHeight = preview . scrollHeight - preview . clientHeight ;
if ( previewScrollableHeight > 0 ) {
this . isScrolling = true ;
preview . scrollTop = scrollPercentage * previewScrollableHeight ;
}
} ;
this . _previewScrollHandler = ( ) => {
if ( this . isScrolling ) {
this . isScrolling = false ;
return ;
}
const scrollableHeight = preview . scrollHeight - preview . clientHeight ;
if ( scrollableHeight <= 0 ) return ; // No scrolling needed
const scrollPercentage = preview . scrollTop / scrollableHeight ;
const editorScrollableHeight = editor . scrollHeight - editor . clientHeight ;
if ( editorScrollableHeight > 0 ) {
this . isScrolling = true ;
editor . scrollTop = scrollPercentage * editorScrollableHeight ;
}
} ;
// Attach new listeners
editor . addEventListener ( 'scroll' , this . _editorScrollHandler ) ;
preview . addEventListener ( 'scroll' , this . _previewScrollHandler ) ;
} ,
// Check if stats plugin is enabled
async checkStatsPlugin ( ) {
try {
const response = await fetch ( '/api/plugins' ) ;
const data = await response . json ( ) ;
const statsPlugin = data . plugins . find ( p => p . id === 'note_stats' ) ;
this . statsPluginEnabled = statsPlugin && statsPlugin . enabled ;
// Calculate stats for current note if enabled
if ( this . statsPluginEnabled && this . noteContent ) {
this . calculateStats ( ) ;
}
} catch ( error ) {
console . error ( 'Failed to check stats plugin:' , error ) ;
this . statsPluginEnabled = false ;
}
} ,
// Calculate note statistics (client-side)
calculateStats ( ) {
if ( ! this . statsPluginEnabled || ! this . noteContent ) {
this . noteStats = null ;
return ;
}
const content = this . noteContent ;
// Word count
const words = ( content . match ( /\S+/g ) || [ ] ) . length ;
// Character count
const chars = content . replace ( /\s/g , '' ) . length ;
const totalChars = content . length ;
// Reading time (200 words per minute)
const readingTime = Math . max ( 1 , Math . round ( words / 200 ) ) ;
// Line count
const lines = content . split ( '\n' ) . length ;
// Paragraph count
const paragraphs = content . split ( '\n\n' ) . filter ( p => p . trim ( ) ) . length ;
2025-12-01 12:51:38 +00:00
// Sentences: punctuation [.!?]+ followed by space or end-of-string
const sentences = ( content . match ( /[.!?]+(?:\s|$)/g ) || [ ] ) . length ;
// List items: lines starting with -, *, + or a number (e.g. 1., 10.), excluding tasks [-]
const listItems = ( content . match ( /^\s*(?:[-*+]|\d+\.)\s+(?!\[)/gm ) || [ ] ) . length ;
2025-12-02 11:00:39 +00:00
// Tables: markdown table separator rows (| --- | --- |)
const tables = ( content . match ( /^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm ) || [ ] ) . length ;
2025-12-01 12:51:38 +00:00
2025-12-03 15:00:25 +00:00
// Link count (standard markdown links)
const markdownLinkMatches = content . match ( /\[([^\]]+)\]\(([^\)]+)\)/g ) || [ ] ;
const markdownLinks = markdownLinkMatches . length ;
const markdownInternalLinks = markdownLinkMatches . filter ( l => l . includes ( '.md' ) ) . length ;
// Wikilink count ([[note]] or [[note|display text]] format)
const wikilinks = ( content . match ( /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g ) || [ ] ) . length ;
// Total links (markdown + wikilinks)
const links = markdownLinks + wikilinks ;
const internalLinks = markdownInternalLinks + wikilinks ; // All wikilinks are internal
2025-11-05 16:48:41 +00:00
// Code blocks
const codeBlocks = ( content . match ( /```[\s\S]*?```/g ) || [ ] ) . length ;
const inlineCode = ( content . match ( /`[^`]+`/g ) || [ ] ) . length ;
// Headings
const h1 = ( content . match ( /^# /gm ) || [ ] ) . length ;
const h2 = ( content . match ( /^## /gm ) || [ ] ) . length ;
const h3 = ( content . match ( /^### /gm ) || [ ] ) . length ;
// Tasks
const totalTasks = ( content . match ( /- \[[ x]\]/gi ) || [ ] ) . length ;
const completedTasks = ( content . match ( /- \[x\]/gi ) || [ ] ) . length ;
const pendingTasks = totalTasks - completedTasks ;
const completionRate = totalTasks > 0 ? Math . round ( ( completedTasks / totalTasks ) * 100 ) : 0 ;
// Images
const images = ( content . match ( /!\[([^\]]*)\]\(([^\)]+)\)/g ) || [ ] ) . length ;
// Blockquotes
const blockquotes = ( content . match ( /^> /gm ) || [ ] ) . length ;
this . noteStats = {
words ,
2025-12-01 12:51:38 +00:00
sentences ,
2025-11-05 16:48:41 +00:00
characters : chars ,
total _characters : totalChars ,
reading _time _minutes : readingTime ,
lines ,
paragraphs ,
2025-12-01 12:51:38 +00:00
list _items : listItems ,
tables ,
2025-11-05 16:48:41 +00:00
links ,
internal _links : internalLinks ,
external _links : links - internalLinks ,
2025-12-03 15:00:25 +00:00
wikilinks ,
2025-11-05 16:48:41 +00:00
code _blocks : codeBlocks ,
inline _code : inlineCode ,
headings : {
h1 ,
h2 ,
h3 ,
total : h1 + h2 + h3
} ,
tasks : {
total : totalTasks ,
completed : completedTasks ,
pending : pendingTasks ,
completion _rate : completionRate
} ,
images ,
blockquotes
} ;
} ,
2025-12-04 09:07:07 +00:00
// Parse YAML frontmatter metadata from note content
parseMetadata ( ) {
if ( ! this . noteContent ) {
this . noteMetadata = null ;
this . _lastFrontmatter = null ;
return ;
}
const content = this . noteContent ;
// Check if content starts with frontmatter
if ( ! content . trim ( ) . startsWith ( '---' ) ) {
this . noteMetadata = null ;
this . _lastFrontmatter = null ;
return ;
}
try {
const lines = content . split ( '\n' ) ;
if ( lines [ 0 ] . trim ( ) !== '---' ) {
this . noteMetadata = null ;
this . _lastFrontmatter = null ;
return ;
}
// Find closing ---
let endIdx = - 1 ;
for ( let i = 1 ; i < lines . length ; i ++ ) {
if ( lines [ i ] . trim ( ) === '---' ) {
endIdx = i ;
break ;
}
}
if ( endIdx === - 1 ) {
this . noteMetadata = null ;
this . _lastFrontmatter = null ;
return ;
}
// Performance optimization: skip parsing if frontmatter unchanged
const frontmatterRaw = lines . slice ( 0 , endIdx + 1 ) . join ( '\n' ) ;
if ( frontmatterRaw === this . _lastFrontmatter ) {
return ; // No change, keep existing metadata
}
this . _lastFrontmatter = frontmatterRaw ;
const frontmatterLines = lines . slice ( 1 , endIdx ) ;
const metadata = { } ;
let currentKey = null ;
let currentValue = [ ] ;
for ( const line of frontmatterLines ) {
// Check for new key: value pair (supports keys with hyphens/underscores)
const keyMatch = line . match ( /^([a-zA-Z_][\w-]*):\s*(.*)$/ ) ;
if ( keyMatch ) {
// Save previous key if exists
if ( currentKey ) {
metadata [ currentKey ] = this . parseYamlValue ( currentValue . join ( '\n' ) ) ;
}
currentKey = keyMatch [ 1 ] ;
const value = keyMatch [ 2 ] . trim ( ) ;
currentValue = [ value ] ;
} else if ( line . match ( /^\s+-\s+/ ) && currentKey ) {
// List item continuation (e.g., " - item")
currentValue . push ( line ) ;
} else if ( line . startsWith ( ' ' ) && currentKey ) {
// Indented content (multiline value)
currentValue . push ( line ) ;
}
}
// Save last key
if ( currentKey ) {
metadata [ currentKey ] = this . parseYamlValue ( currentValue . join ( '\n' ) ) ;
}
this . noteMetadata = Object . keys ( metadata ) . length > 0 ? metadata : null ;
} catch ( error ) {
console . error ( 'Failed to parse frontmatter:' , error ) ;
this . noteMetadata = null ;
this . _lastFrontmatter = null ;
}
} ,
// Parse a YAML value (handles arrays, strings, numbers, booleans)
parseYamlValue ( value ) {
if ( ! value || value . trim ( ) === '' ) return null ;
value = value . trim ( ) ;
// Check for inline array: [item1, item2]
if ( value . startsWith ( '[' ) && value . endsWith ( ']' ) ) {
const inner = value . slice ( 1 , - 1 ) ;
return inner . split ( ',' ) . map ( s => s . trim ( ) . replace ( /^["']|["']$/g , '' ) ) . filter ( s => s ) ;
}
// Check for YAML list format (multiple lines starting with -)
if ( value . includes ( '\n -' ) || value . startsWith ( ' -' ) ) {
const items = [ ] ;
const lines = value . split ( '\n' ) ;
for ( const line of lines ) {
const match = line . match ( /^\s*-\s*(.+)$/ ) ;
if ( match ) {
items . push ( match [ 1 ] . trim ( ) . replace ( /^["']|["']$/g , '' ) ) ;
}
}
return items . length > 0 ? items : value ;
}
// Check for boolean
if ( value . toLowerCase ( ) === 'true' ) return true ;
if ( value . toLowerCase ( ) === 'false' ) return false ;
// Check for number
if ( /^-?\d+(\.\d+)?$/ . test ( value ) ) {
return parseFloat ( value ) ;
}
// Return as string (remove quotes if present)
return value . replace ( /^["']|["']$/g , '' ) ;
} ,
// Check if a string is a URL
isUrl ( str ) {
if ( typeof str !== 'string' ) return false ;
return /^https?:\/\/\S+$/i . test ( str . trim ( ) ) ;
} ,
// Escape HTML to prevent XSS
escapeHtml ( str ) {
const div = document . createElement ( 'div' ) ;
div . textContent = str ;
return div . innerHTML ;
} ,
// Format metadata value for display
formatMetadataValue ( key , value ) {
if ( value === null || value === undefined ) return '' ;
// Arrays are handled separately in the template
if ( Array . isArray ( value ) ) return value ;
// Format dates nicely
if ( key === 'date' || key === 'created' || key === 'modified' || key === 'updated' ) {
2025-12-11 07:28:28 +00:00
let date ;
// Parse date-only strings (YYYY-MM-DD) as local dates to avoid timezone issues
if ( typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/ . test ( value ) ) {
const [ year , month , day ] = value . split ( '-' ) . map ( Number ) ;
date = new Date ( year , month - 1 , day ) ; // month is 0-indexed
} else {
date = new Date ( value ) ;
}
2025-12-04 09:07:07 +00:00
if ( ! isNaN ( date . getTime ( ) ) ) {
2025-12-14 17:02:57 +00:00
return date . toLocaleDateString ( this . currentLocale , {
2025-12-04 09:07:07 +00:00
year : 'numeric' ,
month : 'short' ,
day : 'numeric'
} ) ;
}
}
// Booleans
if ( typeof value === 'boolean' ) {
2025-12-14 17:02:57 +00:00
return value ? this . t ( 'common.yes' ) : this . t ( 'common.no' ) ;
2025-12-04 09:07:07 +00:00
}
return String ( value ) ;
} ,
// Format metadata value as HTML (for URL support)
formatMetadataValueHtml ( key , value ) {
const formatted = this . formatMetadataValue ( key , value ) ;
// Check if it's a URL
if ( this . isUrl ( formatted ) ) {
const escaped = this . escapeHtml ( formatted ) ;
// Truncate long URLs for display
const displayUrl = formatted . length > 40
? formatted . substring ( 0 , 37 ) + '...'
: formatted ;
return ` <a href=" ${ escaped } " target="_blank" rel="noopener noreferrer" class="metadata-link"> ${ this . escapeHtml ( displayUrl ) } </a> ` ;
}
return this . escapeHtml ( formatted ) ;
} ,
// Get priority metadata fields (shown in collapsed view)
getPriorityMetadataFields ( ) {
if ( ! this . noteMetadata ) return [ ] ;
// Fields to show in collapsed view, in order of priority
const priority = [ 'date' , 'created' , 'author' , 'status' , 'priority' , 'type' , 'category' ] ;
const fields = [ ] ;
for ( const key of priority ) {
if ( this . noteMetadata [ key ] !== undefined && ! Array . isArray ( this . noteMetadata [ key ] ) ) {
const formatted = this . formatMetadataValue ( key , this . noteMetadata [ key ] ) ;
const isUrl = this . isUrl ( formatted ) ;
fields . push ( {
key ,
value : formatted ,
valueHtml : isUrl ? this . formatMetadataValueHtml ( key , this . noteMetadata [ key ] ) : this . escapeHtml ( formatted ) ,
isUrl
} ) ;
}
}
return fields . slice ( 0 , 3 ) ; // Show max 3 fields in collapsed view
} ,
// Get all metadata fields except tags (for expanded view)
getAllMetadataFields ( ) {
if ( ! this . noteMetadata ) return [ ] ;
return Object . entries ( this . noteMetadata )
. filter ( ( [ key ] ) => key !== 'tags' ) // Tags shown separately
. map ( ( [ key , value ] ) => {
const isArray = Array . isArray ( value ) ;
const formatted = this . formatMetadataValue ( key , value ) ;
const isUrl = ! isArray && this . isUrl ( formatted ) ;
return {
key ,
value : formatted ,
valueHtml : isUrl ? this . formatMetadataValueHtml ( key , value ) : this . escapeHtml ( formatted ) ,
isArray ,
isUrl
} ;
} ) ;
} ,
// Check if note has any displayable metadata
getHasMetadata ( ) {
const has = this . noteMetadata && Object . keys ( this . noteMetadata ) . length > 0 ;
return has ;
} ,
// Get tags from metadata
getMetadataTags ( ) {
if ( ! this . noteMetadata || ! this . noteMetadata . tags ) return [ ] ;
return Array . isArray ( this . noteMetadata . tags ) ? this . noteMetadata . tags : [ this . noteMetadata . tags ] ;
} ,
2025-11-05 16:48:41 +00:00
// Load sidebar width from localStorage
loadSidebarWidth ( ) {
const saved = localStorage . getItem ( 'sidebarWidth' ) ;
if ( saved ) {
const width = parseInt ( saved , 10 ) ;
if ( width >= 200 && width <= 600 ) {
this . sidebarWidth = width ;
}
}
} ,
// Save sidebar width to localStorage
saveSidebarWidth ( ) {
localStorage . setItem ( 'sidebarWidth' , this . sidebarWidth . toString ( ) ) ;
} ,
// Load view mode from localStorage
loadViewMode ( ) {
try {
const saved = localStorage . getItem ( 'viewMode' ) ;
if ( saved && [ 'edit' , 'split' , 'preview' ] . includes ( saved ) ) {
this . viewMode = saved ;
}
} catch ( error ) {
console . error ( 'Error loading view mode:' , error ) ;
}
} ,
// Save view mode to localStorage
saveViewMode ( ) {
try {
localStorage . setItem ( 'viewMode' , this . viewMode ) ;
} catch ( error ) {
console . error ( 'Error saving view mode:' , error ) ;
}
} ,
2025-11-24 16:11:22 +00:00
loadTagsExpanded ( ) {
try {
const saved = localStorage . getItem ( 'tagsExpanded' ) ;
if ( saved !== null ) {
this . tagsExpanded = saved === 'true' ;
}
} catch ( error ) {
console . error ( 'Error loading tags expanded state:' , error ) ;
}
} ,
saveTagsExpanded ( ) {
try {
localStorage . setItem ( 'tagsExpanded' , this . tagsExpanded . toString ( ) ) ;
} catch ( error ) {
console . error ( 'Error saving tags expanded state:' , error ) ;
}
} ,
2025-11-05 16:48:41 +00:00
// Start resizing sidebar
startResize ( event ) {
this . isResizing = true ;
event . preventDefault ( ) ;
const resize = ( e ) => {
if ( ! this . isResizing ) return ;
// Calculate new width based on mouse position
const newWidth = e . clientX ;
// Clamp between min and max
if ( newWidth >= 200 && newWidth <= 600 ) {
this . sidebarWidth = newWidth ;
}
} ;
const stopResize = ( ) => {
if ( this . isResizing ) {
this . isResizing = false ;
this . saveSidebarWidth ( ) ;
document . removeEventListener ( 'mousemove' , resize ) ;
document . removeEventListener ( 'mouseup' , stopResize ) ;
}
} ;
document . addEventListener ( 'mousemove' , resize ) ;
document . addEventListener ( 'mouseup' , stopResize ) ;
} ,
2025-11-09 09:17:09 +00:00
// Start resizing split panes (editor/preview)
startSplitResize ( event ) {
this . isResizingSplit = true ;
event . preventDefault ( ) ;
const container = event . target . parentElement ;
const resize = ( e ) => {
if ( ! this . isResizingSplit ) return ;
const containerRect = container . getBoundingClientRect ( ) ;
const mouseX = e . clientX - containerRect . left ;
const percentage = ( mouseX / containerRect . width ) * 100 ;
// Clamp between 20% and 80%
if ( percentage >= 20 && percentage <= 80 ) {
this . editorWidth = percentage ;
}
} ;
const stopResize = ( ) => {
if ( this . isResizingSplit ) {
this . isResizingSplit = false ;
this . saveEditorWidth ( ) ;
document . removeEventListener ( 'mousemove' , resize ) ;
document . removeEventListener ( 'mouseup' , stopResize ) ;
}
} ;
document . addEventListener ( 'mousemove' , resize ) ;
document . addEventListener ( 'mouseup' , stopResize ) ;
} ,
2025-11-13 14:09:26 +00:00
// Setup mobile view mode handler (auto-switch from split to edit on mobile)
setupMobileViewMode ( ) {
const MOBILE _BREAKPOINT = 768 ; // Match CSS breakpoint
let previousWidth = window . innerWidth ;
const handleResize = ( ) => {
const currentWidth = window . innerWidth ;
const wasMobile = previousWidth <= MOBILE _BREAKPOINT ;
const isMobile = currentWidth <= MOBILE _BREAKPOINT ;
// If switching from desktop to mobile and in split mode
if ( ! wasMobile && isMobile && this . viewMode === 'split' ) {
this . viewMode = 'edit' ;
}
previousWidth = currentWidth ;
} ;
// Listen for window resize
window . addEventListener ( 'resize' , handleResize ) ;
// Check initial state
if ( window . innerWidth <= MOBILE _BREAKPOINT && this . viewMode === 'split' ) {
this . viewMode = 'edit' ;
}
} ,
2025-11-09 09:17:09 +00:00
// Load editor width from localStorage
loadEditorWidth ( ) {
const saved = localStorage . getItem ( 'editorWidth' ) ;
if ( saved ) {
const width = parseFloat ( saved ) ;
if ( width >= 20 && width <= 80 ) {
this . editorWidth = width ;
}
}
} ,
// Save editor width to localStorage
saveEditorWidth ( ) {
localStorage . setItem ( 'editorWidth' , this . editorWidth . toString ( ) ) ;
} ,
2025-11-05 16:48:41 +00:00
// Scroll to top of editor and preview
scrollToTop ( ) {
// Disable scroll sync temporarily to prevent interference
this . isScrolling = true ;
// Use cached references (refresh if not available)
if ( ! this . _domCache . editor || ! this . _domCache . previewContainer ) {
this . refreshDOMCache ( ) ;
}
// Only scroll the visible panes based on viewMode
if ( this . viewMode === 'edit' || this . viewMode === 'split' ) {
if ( this . _domCache . editor ) {
this . _domCache . editor . scrollTop = 0 ;
}
}
if ( this . viewMode === 'preview' || this . viewMode === 'split' ) {
// Scroll the preview container (parent of .markdown-preview)
if ( this . _domCache . previewContainer ) {
this . _domCache . previewContainer . scrollTop = 0 ;
}
}
// Re-enable scroll sync after a short delay
setTimeout ( ( ) => {
this . isScrolling = false ;
} , CONFIG . SCROLL _SYNC _DELAY ) ;
2025-11-12 10:16:12 +00:00
} ,
// Export current note as HTML
async exportToHTML ( ) {
if ( ! this . currentNote || ! this . noteContent ) {
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'notes.no_content' ) ) ;
2025-11-12 10:16:12 +00:00
return ;
}
try {
// Get the note name without extension
const noteName = this . currentNoteName || 'note' ;
// Get current rendered HTML (this already has markdown converted and will have LaTeX delimiters)
2025-12-11 10:39:12 +00:00
let renderedHTML = this . renderedMarkdown ;
// Embed local images as base64 for fully self-contained HTML
const imgRegex = /src="\/api\/images\/([^"]+)"/g ;
const imgMatches = [ ... renderedHTML . matchAll ( imgRegex ) ] ;
for ( const match of imgMatches ) {
const encodedPath = match [ 1 ] ;
try {
// Fetch the image
const imgResponse = await fetch ( ` /api/images/ ${ encodedPath } ` ) ;
if ( imgResponse . ok ) {
const blob = await imgResponse . blob ( ) ;
// Convert to base64 data URL
const base64 = await new Promise ( ( resolve ) => {
const reader = new FileReader ( ) ;
reader . onloadend = ( ) => resolve ( reader . result ) ;
reader . readAsDataURL ( blob ) ;
} ) ;
// Replace the src with base64 data URL
renderedHTML = renderedHTML . replace ( match [ 0 ] , ` src=" ${ base64 } " ` ) ;
}
} catch ( e ) {
console . warn ( ` Failed to embed image: ${ encodedPath } ` , e ) ;
// Fall back to relative path
const decodedPath = decodeURIComponent ( encodedPath ) ;
renderedHTML = renderedHTML . replace ( match [ 0 ] , ` src=" ${ decodedPath } " ` ) ;
}
}
2025-11-12 10:16:12 +00:00
// Get current theme CSS
const currentTheme = this . currentTheme || 'light' ;
const themeResponse = await fetch ( ` /api/themes/ ${ currentTheme } ` ) ;
const themeText = await themeResponse . text ( ) ;
// Check if response is JSON or plain CSS
let themeCss ;
try {
const themeJson = JSON . parse ( themeText ) ;
// If it's JSON, extract the css field
themeCss = themeJson . css || themeText ;
} catch ( e ) {
// If it's not JSON, use it as-is
themeCss = themeText ;
}
// Theme CSS uses :root[data-theme="..."] selector, but we need plain :root for export
// Strip the data-theme attribute selector so variables apply globally
themeCss = themeCss . replace ( /:root\[data-theme="[^"]+"\]/g , ':root' ) ;
// Get highlight.js theme URL from current page
const highlightLinkElement = document . getElementById ( 'highlight-theme' ) ;
if ( ! highlightLinkElement || ! highlightLinkElement . href ) {
console . warn ( 'Could not detect highlight.js theme, export may not match preview exactly' ) ;
}
const highlightTheme = highlightLinkElement ? highlightLinkElement . href : '' ;
// Extract all markdown preview styles from current page
let markdownStyles = '' ;
const styleSheets = Array . from ( document . styleSheets ) ;
for ( const sheet of styleSheets ) {
try {
2025-11-17 16:40:52 +00:00
// Skip external stylesheets (CDN resources) to avoid CORS errors
// We link them directly in the exported HTML anyway
if ( sheet . href && ( sheet . href . startsWith ( 'http://' ) || sheet . href . startsWith ( 'https://' ) ) ) {
const currentOrigin = window . location . origin ;
const sheetURL = new URL ( sheet . href ) ;
if ( sheetURL . origin !== currentOrigin ) {
// Skip cross-origin stylesheets (they're linked directly in export)
continue ;
}
}
2025-11-12 10:16:12 +00:00
const rules = Array . from ( sheet . cssRules || [ ] ) ;
for ( const rule of rules ) {
const cssText = rule . cssText ;
2025-11-17 16:29:00 +00:00
// Include rules that target markdown-preview, mjx-container, or mermaid-rendered
2025-11-12 10:16:12 +00:00
if ( cssText . includes ( '.markdown-preview' ) ||
cssText . includes ( 'mjx-container' ) ||
2025-11-17 16:29:00 +00:00
cssText . includes ( '.MathJax' ) ||
cssText . includes ( '.mermaid-rendered' ) ) {
2025-11-12 10:16:12 +00:00
markdownStyles += cssText + '\n' ;
}
}
} catch ( e ) {
2025-11-17 16:40:52 +00:00
// Gracefully skip stylesheets that can't be accessed
// (This should rarely happen now that we skip external stylesheets)
console . debug ( 'Skipping stylesheet:' , sheet . href ) ;
2025-11-12 10:16:12 +00:00
}
}
// Create standalone HTML document with MathJax
const htmlDocument = ` <!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > $ { noteName } < / t i t l e >
2025-11-18 16:24:46 +00:00
<!-- Highlight . js for code syntax highlighting ( v11 . 9.0 ) -- >
2025-11-12 10:16:12 +00:00
$ { highlightTheme ? ` <link rel="stylesheet" href=" ${ highlightTheme } "> ` : '<!-- No highlight.js theme detected -->' }
< script src = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" > < / s c r i p t >
2025-11-18 16:24:46 +00:00
<!-- MathJax for LaTeX math rendering ( v3 . 2.2 ) -- >
2025-11-12 10:16:12 +00:00
< script >
MathJax = {
tex : {
inlineMath : [ [ '$' , '$' ] ] ,
displayMath : [ [ '$$' , '$$' ] ] ,
processEscapes : true ,
processEnvironments : true
} ,
options : {
skipHtmlTags : [ 'script' , 'noscript' , 'style' , 'textarea' , 'pre' , 'code' ]
} ,
startup : {
pageReady : ( ) => {
return MathJax . startup . defaultPageReady ( ) . then ( ( ) => {
2025-11-17 16:29:00 +00:00
// Highlight code blocks after MathJax is done (exclude diagram renderers)
document . querySelectorAll ( 'pre code:not(.language-mermaid)' ) . forEach ( ( block ) => {
2025-11-12 10:16:12 +00:00
hljs . highlightElement ( block ) ;
} ) ;
} ) ;
}
}
} ;
< / s c r i p t >
2025-11-18 16:24:46 +00:00
< script src = "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js" > < / s c r i p t >
2025-11-12 10:16:12 +00:00
2025-12-06 12:06:23 +00:00
<!-- Mermaid . js for diagrams -- >
2025-11-17 16:29:00 +00:00
< script type = "module" >
2025-12-06 12:06:23 +00:00
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.esm.min.mjs' ;
2025-11-17 16:29:00 +00:00
const isDark = $ { this . getThemeType ( ) === 'dark' } ;
mermaid . initialize ( {
startOnLoad : false ,
theme : isDark ? 'dark' : 'default' ,
securityLevel : 'strict' ,
2025-12-06 12:06:23 +00:00
fontFamily : 'inherit' ,
flowchart : { useMaxWidth : true } ,
sequence : { useMaxWidth : true } ,
gantt : { useMaxWidth : true } ,
state : { useMaxWidth : true } ,
er : { useMaxWidth : true } ,
pie : { useMaxWidth : true } ,
mindmap : { useMaxWidth : true } ,
gitGraph : { useMaxWidth : true }
2025-11-17 16:29:00 +00:00
} ) ;
// Render any Mermaid code blocks
document . addEventListener ( 'DOMContentLoaded' , async ( ) => {
const mermaidBlocks = document . querySelectorAll ( 'pre code.language-mermaid' ) ;
for ( let i = 0 ; i < mermaidBlocks . length ; i ++ ) {
const block = mermaidBlocks [ i ] ;
const pre = block . parentElement ;
try {
const code = block . textContent ;
const id = 'mermaid-diagram-' + i ;
const { svg } = await mermaid . render ( id , code ) ;
const container = document . createElement ( 'div' ) ;
container . className = 'mermaid-rendered' ;
container . style . cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;' ;
container . innerHTML = svg ;
pre . parentElement . replaceChild ( container , pre ) ;
} catch ( error ) {
console . error ( 'Mermaid rendering error:' , error ) ;
}
}
} ) ;
< / s c r i p t >
2025-11-12 10:16:12 +00:00
< style >
/* Theme CSS */
$ { themeCss }
/* Base styles */
* {
box - sizing : border - box ;
}
body {
font - family : - apple - system , BlinkMacSystemFont , "Segoe UI" , Roboto , Helvetica , Arial , sans - serif ;
margin : 0 ;
padding : 2 rem ;
max - width : 900 px ;
margin - left : auto ;
margin - right : auto ;
background - color : var ( -- bg - primary ) ;
color : var ( -- text - primary ) ;
}
/* Markdown preview styles extracted from current page */
$ { markdownStyles }
@ media ( max - width : 768 px ) {
body {
padding : 1 rem ;
}
}
@ media print {
body {
padding : 0.5 in ;
max - width : none ;
}
}
< / s t y l e >
< / h e a d >
< body >
< div class = "markdown-preview" >
$ { renderedHTML }
< / d i v >
< / b o d y >
< / h t m l > ` ;
// Create blob and download
const blob = new Blob ( [ htmlDocument ] , { type : 'text/html;charset=utf-8' } ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
a . download = ` ${ noteName } .html ` ;
document . body . appendChild ( a ) ;
a . click ( ) ;
// Cleanup
URL . revokeObjectURL ( url ) ;
document . body . removeChild ( a ) ;
} catch ( error ) {
console . error ( 'HTML export failed:' , error ) ;
2025-12-14 17:02:57 +00:00
alert ( this . t ( 'export.failed' , { error : error . message } ) ) ;
2025-11-12 10:16:12 +00:00
}
2025-11-20 03:01:27 +00:00
} ,
2025-12-12 15:32:29 +00:00
// Copy current note link to clipboard
async copyNoteLink ( ) {
2025-12-12 15:42:14 +00:00
if ( ! this . currentNote ) return ;
2025-12-12 15:32:29 +00:00
// Build the full URL
const pathWithoutExtension = this . currentNote . replace ( '.md' , '' ) ;
const encodedPath = pathWithoutExtension . split ( '/' ) . map ( segment => encodeURIComponent ( segment ) ) . join ( '/' ) ;
const url = ` ${ window . location . origin } / ${ encodedPath } ` ;
try {
await navigator . clipboard . writeText ( url ) ;
} catch ( error ) {
// Fallback for older browsers
const textArea = document . createElement ( 'textarea' ) ;
textArea . value = url ;
document . body . appendChild ( textArea ) ;
textArea . select ( ) ;
document . execCommand ( 'copy' ) ;
document . body . removeChild ( textArea ) ;
}
2025-12-12 15:42:14 +00:00
// Show brief "Copied!" feedback
this . linkCopied = true ;
setTimeout ( ( ) => {
this . linkCopied = false ;
} , 1500 ) ;
2025-12-12 15:32:29 +00:00
} ,
2025-12-12 16:18:14 +00:00
// Toggle Zen Mode (full immersive writing experience)
async toggleZenMode ( ) {
if ( ! this . zenMode ) {
// Entering Zen Mode
this . previousViewMode = this . viewMode ;
this . viewMode = 'edit' ;
this . mobileSidebarOpen = false ;
this . zenMode = true ;
// Request fullscreen
try {
const elem = document . documentElement ;
if ( elem . requestFullscreen ) {
await elem . requestFullscreen ( ) ;
} else if ( elem . webkitRequestFullscreen ) {
await elem . webkitRequestFullscreen ( ) ;
} else if ( elem . msRequestFullscreen ) {
await elem . msRequestFullscreen ( ) ;
}
} catch ( e ) {
// Fullscreen not supported or denied, continue anyway
console . log ( 'Fullscreen not available:' , e ) ;
}
// Focus editor after transition
setTimeout ( ( ) => {
const editor = document . getElementById ( 'note-editor' ) ;
if ( editor ) editor . focus ( ) ;
} , 300 ) ;
} else {
// Exiting Zen Mode
this . zenMode = false ;
this . viewMode = this . previousViewMode ;
// Exit fullscreen
try {
if ( document . exitFullscreen ) {
await document . exitFullscreen ( ) ;
} else if ( document . webkitExitFullscreen ) {
await document . webkitExitFullscreen ( ) ;
} else if ( document . msExitFullscreen ) {
await document . msExitFullscreen ( ) ;
}
} catch ( e ) {
console . log ( 'Exit fullscreen error:' , e ) ;
}
}
} ,
2025-11-20 03:01:27 +00:00
// Homepage folder navigation methods
goToHomepageFolder ( folderPath ) {
2025-12-04 17:05:41 +00:00
this . showGraph = false ; // Close graph when navigating
2025-11-20 03:01:27 +00:00
this . selectedHomepageFolder = folderPath || '' ;
2025-11-27 10:30:49 +00:00
// Clear editor state to show landing page
this . currentNote = '' ;
this . currentNoteName = '' ;
this . noteContent = '' ;
this . currentImage = '' ;
2025-11-24 09:04:20 +00:00
// Invalidate cache to force recalculation
this . _homepageCache = {
folderPath : null ,
notes : null ,
folders : null ,
breadcrumb : null
} ;
window . history . pushState ( { homepageFolder : folderPath || '' } , '' , '/' ) ;
} ,
// Navigate to homepage root and clear all editor state
goHome ( ) {
2025-12-04 17:05:41 +00:00
this . showGraph = false ; // Close graph when going home
2025-11-24 09:04:20 +00:00
this . selectedHomepageFolder = '' ;
this . currentNote = '' ;
this . currentNoteName = '' ;
this . noteContent = '' ;
this . currentImage = '' ;
this . mobileSidebarOpen = false ;
// Invalidate cache to force recalculation
this . _homepageCache = {
folderPath : null ,
notes : null ,
folders : null ,
breadcrumb : null
} ;
window . history . pushState ( { homepageFolder : '' } , '' , '/' ) ;
2025-12-04 17:05:41 +00:00
} ,
// ==================== GRAPH VIEW ====================
// Initialize the graph visualization
async initGraph ( ) {
// Check if vis is loaded
if ( typeof vis === 'undefined' ) {
console . error ( 'vis-network library not loaded' ) ;
return ;
}
this . graphLoaded = false ;
try {
// Fetch graph data from API
const response = await fetch ( '/api/graph' ) ;
if ( ! response . ok ) throw new Error ( 'Failed to fetch graph data' ) ;
const data = await response . json ( ) ;
this . graphData = data ;
// Get container
const container = document . getElementById ( 'graph-overlay' ) ;
if ( ! container ) return ;
// Get theme colors (force reflow to ensure CSS is applied)
document . body . offsetHeight ; // Force reflow
const style = getComputedStyle ( document . documentElement ) ;
// Helper to get CSS variable with fallback
const getCssVar = ( name , fallback ) => {
const value = style . getPropertyValue ( name ) . trim ( ) ;
return value || fallback ;
} ;
const accentPrimary = getCssVar ( '--accent-primary' , '#7c3aed' ) ;
const accentSecondary = getCssVar ( '--accent-secondary' , '#a78bfa' ) ;
const textPrimary = getCssVar ( '--text-primary' , '#111827' ) ;
const textSecondary = getCssVar ( '--text-secondary' , '#6b7280' ) ;
const bgPrimary = getCssVar ( '--bg-primary' , '#ffffff' ) ;
const bgSecondary = getCssVar ( '--bg-secondary' , '#f3f4f6' ) ;
const borderColor = getCssVar ( '--border-primary' , '#e5e7eb' ) ;
// Prepare nodes with styling - all nodes same base color
const nodes = new vis . DataSet ( data . nodes . map ( n => ( {
id : n . id ,
label : n . label ,
title : n . id , // Tooltip shows full path
color : {
background : accentPrimary ,
border : accentPrimary ,
highlight : {
background : accentPrimary ,
border : textPrimary // Darker border when selected
} ,
hover : {
background : accentSecondary ,
border : accentPrimary
}
} ,
font : {
color : textPrimary ,
size : 12 ,
face : 'system-ui, -apple-system, sans-serif'
} ,
borderWidth : this . currentNote === n . id ? 4 : 2 ,
chosen : {
node : ( values ) => {
values . size = 22 ;
values . borderWidth = 4 ;
values . borderColor = textPrimary ;
}
}
} ) ) ) ;
// Prepare edges with styling based on type
const edges = new vis . DataSet ( data . edges . map ( ( e , i ) => ( {
id : i ,
from : e . source ,
to : e . target ,
color : {
color : e . type === 'wikilink' ? accentPrimary : borderColor ,
highlight : accentPrimary ,
hover : accentSecondary ,
opacity : 0.8
} ,
width : e . type === 'wikilink' ? 2 : 1 ,
smooth : {
type : 'continuous' ,
roundness : 0.5
} ,
chosen : {
edge : ( values ) => {
values . width = 3 ;
values . color = accentPrimary ;
}
}
} ) ) ) ;
// Network options
const options = {
nodes : {
shape : 'dot' ,
size : 16 ,
borderWidth : 2 ,
shadow : {
enabled : true ,
color : 'rgba(0,0,0,0.1)' ,
size : 5 ,
x : 2 ,
y : 2
}
} ,
edges : {
arrows : {
to : {
enabled : true ,
scaleFactor : 0.5 ,
type : 'arrow'
}
}
} ,
physics : {
enabled : true ,
solver : 'forceAtlas2Based' ,
forceAtlas2Based : {
gravitationalConstant : - 50 ,
centralGravity : 0.01 ,
springLength : 100 ,
springConstant : 0.08 ,
damping : 0.4 ,
avoidOverlap : 0.5
} ,
stabilization : {
enabled : true ,
iterations : 200 ,
updateInterval : 25
}
} ,
interaction : {
hover : true ,
tooltipDelay : 200 ,
navigationButtons : false , // Using custom buttons instead
keyboard : {
enabled : true ,
bindToWindow : false
} ,
zoomView : true ,
dragView : true
} ,
layout : {
improvedLayout : true ,
randomSeed : 42
}
} ;
// Destroy existing instance if any
if ( this . graphInstance ) {
this . graphInstance . destroy ( ) ;
this . graphInstance = null ;
}
// Clear container to ensure clean state
const graphCanvas = container . querySelector ( 'canvas' ) ;
if ( graphCanvas ) graphCanvas . remove ( ) ;
const visElements = container . querySelectorAll ( '.vis-network, .vis-navigation' ) ;
visElements . forEach ( el => el . remove ( ) ) ;
// Create the network
this . graphInstance = new vis . Network ( container , { nodes , edges } , options ) ;
// Store reference for callbacks
const graphRef = this . graphInstance ;
const currentNoteRef = this . currentNote ;
// Wait for stabilization
this . graphInstance . once ( 'stabilizationIterationsDone' , ( ) => {
graphRef . setOptions ( { physics : { enabled : false } } ) ;
this . graphLoaded = true ;
// Focus and select current note if one is loaded
if ( currentNoteRef ) {
setTimeout ( ( ) => {
try {
if ( graphRef && this . showGraph ) {
const nodeIds = graphRef . body . data . nodes . getIds ( ) ;
if ( nodeIds . includes ( currentNoteRef ) ) {
// Focus on the node
graphRef . focus ( currentNoteRef , {
scale : 1.2 ,
animation : {
duration : 500 ,
easingFunction : 'easeInOutQuad'
}
} ) ;
// Select the node to highlight it
graphRef . selectNodes ( [ currentNoteRef ] ) ;
}
}
} catch ( e ) {
// Ignore - graph may have been destroyed
}
} , 150 ) ;
}
} ) ;
// Click event - open note
this . graphInstance . on ( 'click' , ( params ) => {
if ( params . nodes . length > 0 ) {
const noteId = params . nodes [ 0 ] ;
this . loadNote ( noteId ) ;
// Node is already selected by vis-network on click, no need to call selectNodes
}
} ) ;
// Double-click event - open note and close graph
this . graphInstance . on ( 'doubleClick' , ( params ) => {
if ( params . nodes . length > 0 ) {
const noteId = params . nodes [ 0 ] ;
// Close graph and load note
this . showGraph = false ;
this . loadNote ( noteId ) ;
}
} ) ;
// Hover event - highlight connections
this . graphInstance . on ( 'hoverNode' , ( params ) => {
const nodeId = params . node ;
const connectedNodes = this . graphInstance . getConnectedNodes ( nodeId ) ;
const connectedEdges = this . graphInstance . getConnectedEdges ( nodeId ) ;
// Dim all nodes except hovered and connected
const allNodes = nodes . getIds ( ) ;
const updates = allNodes . map ( id => ( {
id ,
opacity : ( id === nodeId || connectedNodes . includes ( id ) ) ? 1 : 0.2
} ) ) ;
nodes . update ( updates ) ;
} ) ;
this . graphInstance . on ( 'blurNode' , ( ) => {
// Reset all nodes to full opacity
const allNodes = nodes . getIds ( ) ;
const updates = allNodes . map ( id => ( { id , opacity : 1 } ) ) ;
nodes . update ( updates ) ;
} ) ;
// Add legend to container
this . addGraphLegend ( container , accentPrimary , borderColor , textSecondary ) ;
} catch ( error ) {
console . error ( 'Failed to initialize graph:' , error ) ;
this . graphLoaded = true ; // Stop loading indicator
}
} ,
// Add legend to graph container
addGraphLegend ( container , wikiColor , mdColor , textColor ) {
// Remove existing legend if any
const existingLegend = container . querySelector ( '.graph-legend' ) ;
if ( existingLegend ) existingLegend . remove ( ) ;
const legend = document . createElement ( 'div' ) ;
legend . className = 'graph-legend' ;
legend . innerHTML = `
< div class = "graph-legend-item" >
< span class = "graph-legend-dot" style = "background: ${wikiColor};" > < / s p a n >
< span style = "color: ${textColor};" > Wikilinks < / s p a n >
< / d i v >
< div class = "graph-legend-item" >
< span class = "graph-legend-dot" style = "background: ${mdColor};" > < / s p a n >
2025-12-14 17:02:57 +00:00
< span style = "color: ${textColor};" > $ { this . t ( 'graph.markdown_links' ) } < / s p a n >
2025-12-04 17:05:41 +00:00
< / d i v >
< div style = "margin-top: 8px; font-size: 10px; color: ${textColor}; opacity: 0.7;" >
2025-12-14 17:02:57 +00:00
$ { this . t ( 'graph.click_hint' ) }
2025-12-04 17:05:41 +00:00
< / d i v >
` ;
container . appendChild ( legend ) ;
} ,
// Refresh graph when theme changes
refreshGraph ( ) {
if ( this . viewMode === 'graph' && this . graphInstance ) {
this . initGraph ( ) ;
}
2025-11-05 16:48:41 +00:00
}
}
}