2025-11-05 16:48:41 +00:00
// NoteDiscovery Frontend Application
// Configuration constants
const CONFIG = {
2026-06-08 09:02:05 +00:00
AUTOSAVE _DELAY : 1000 , // ms - Fallback only; runtime value lives on this.autosaveDelayMs (hydrated from /api/config). Used by autoSave() and _drawingScheduleAutosave().
2026-04-21 15:39:22 +00:00
/** Must match drawingRedraw() fill and eraser stroke color (opaque “whiteboard”). */
DRAWING _BACKGROUND : '#ffffff' ,
2026-04-24 13:29:00 +00:00
/ * *
* Drawing document size ( intrinsic resolution ) . The display canvas may render
* smaller on small screens , but pointer events , ops and the saved PNG always
* live in this fixed coordinate space . A future "new drawing" dialog can
* override the dimensions per drawing without any architectural change .
* /
2026-04-24 14:29:29 +00:00
DRAWING _DEFAULT _DOC _W : 1200 ,
DRAWING _DEFAULT _DOC _H : 800 ,
2026-04-24 13:29:00 +00:00
/** Hard bounds applied when (re)loading or creating a drawing. */
DRAWING _MIN _DOC _DIM : 64 ,
DRAWING _MAX _DOC _DIM : 4096 ,
2026-02-23 16:51:11 +00:00
SEARCH _DEBOUNCE _DELAY : 500 , // ms - Delay before running note search while typing
2025-11-05 16:48:41 +00:00
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)
2026-04-16 16:17:34 +00:00
TOAST _MAX _VISIBLE : 4 , // Max stacked toasts; oldest dropped when exceeded
TOAST _DURATION _ERROR _MS : 7000 ,
TOAST _DURATION _WARNING _MS : 5500 ,
TOAST _DURATION _INFO _MS : 4500 ,
TOAST _DURATION _SUCCESS _MS : 3500 ,
2025-11-05 16:48:41 +00:00
} ;
2026-04-22 13:38:16 +00:00
/** Heroicons outline "share" (same d= as shared-note icon in the file tree) */
const SHARE _ICON _PATH = 'M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z' ;
2026-01-26 09:22:00 +00:00
// localStorage settings configuration - centralized definition of all persisted settings
const LOCAL _SETTINGS = {
// Boolean settings
syntaxHighlightEnabled : { key : 'syntaxHighlightEnabled' , type : 'boolean' , default : false } ,
readableLineLength : { key : 'readableLineLength' , type : 'boolean' , default : true } ,
favoritesExpanded : { key : 'favoritesExpanded' , type : 'boolean' , default : true } ,
tagsExpanded : { key : 'tagsExpanded' , type : 'boolean' , default : false } ,
2026-02-23 09:59:58 +00:00
hideUnderscoreFolders : { key : 'hideUnderscoreFolders' , type : 'boolean' , default : false } ,
2026-04-06 09:25:21 +00:00
tabInsertsTab : { key : 'tabInsertsTab' , type : 'boolean' , default : false } ,
2026-05-29 10:30:23 +00:00
sidebarPanelCollapsed : { key : 'sidebarPanelCollapsed' , type : 'boolean' , default : false } ,
2026-06-04 11:21:49 +00:00
autoFillNoteTitle : { key : 'autoFillNoteTitle' , type : 'boolean' , default : false } ,
2026-04-06 14:00:00 +00:00
// String settings
sortMode : { key : 'sortMode' , type : 'string' , default : 'a-z' } ,
2026-06-04 11:21:49 +00:00
newButtonAction : {
key : 'newButtonAction' , type : 'string' , default : 'chooser' ,
valid : [ 'chooser' , 'note' , 'folder' , 'template' , 'drawing' ]
} ,
lastUsedTemplate : { key : 'lastUsedTemplate' , type : 'string' , default : '' } ,
2026-01-26 09:22:00 +00:00
// Number settings with validation
sidebarWidth : { key : 'sidebarWidth' , type : 'number' , default : CONFIG . DEFAULT _SIDEBAR _WIDTH , min : 200 , max : 600 } ,
editorWidth : { key : 'editorWidth' , type : 'number' , default : 50 , min : 20 , max : 80 } ,
// String settings with validation
viewMode : { key : 'viewMode' , type : 'string' , default : 'split' , valid : [ 'edit' , 'split' , 'preview' ] } ,
// JSON settings
favorites : { key : 'noteFavorites' , type : 'json' , default : [ ] } ,
} ;
2025-11-05 16:48:41 +00:00
// 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
2026-04-16 16:17:34 +00:00
* @ param { boolean } notify - Whether to show a toast ( listens on notediscovery : toast )
2025-11-05 16:48:41 +00:00
* /
2026-04-16 16:17:34 +00:00
handle ( operation , error , notify = true ) {
2025-11-05 16:48:41 +00:00
console . error ( ` Failed to ${ operation } : ` , error ) ;
2026-04-16 16:17:34 +00:00
if ( notify ) {
const message = ` Failed to ${ operation } . Please try again. ` ;
window . dispatchEvent ( new CustomEvent ( 'notediscovery:toast' , {
detail : { message , type : 'error' }
} ) ) ;
2025-11-05 16:48:41 +00:00
}
}
} ;
2025-12-17 09:03:40 +00:00
/ * *
* Centralized filename validation
* Supports Unicode characters ( international text ) but blocks dangerous filesystem characters .
* Does NOT silently modify filenames - validates and returns status .
* /
const FilenameValidator = {
// Characters that are forbidden in filenames across Windows/macOS/Linux
// Windows: \ / : * ? " < > |
// macOS: / :
// Linux: / \0
// Common set to block (including control characters)
FORBIDDEN _CHARS : /[\\/:*?"<>|\x00-\x1f]/ ,
// For display purposes - human readable list
FORBIDDEN _CHARS _DISPLAY : '\\ / : * ? " < > |' ,
/ * *
* Validate a filename ( single segment , no path separators )
* @ param { string } name - The filename to validate
* @ returns { { valid : boolean , error ? : string , sanitized ? : string } }
* /
validateFilename ( name ) {
if ( ! name || typeof name !== 'string' ) {
return { valid : false , error : 'empty' } ;
}
const trimmed = name . trim ( ) ;
if ( ! trimmed ) {
return { valid : false , error : 'empty' } ;
}
// Check for forbidden characters
if ( this . FORBIDDEN _CHARS . test ( trimmed ) ) {
return {
valid : false ,
error : 'forbidden_chars' ,
forbiddenChars : this . FORBIDDEN _CHARS _DISPLAY
} ;
}
// Check for reserved Windows names (case-insensitive)
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i ;
if ( reservedNames . test ( trimmed ) ) {
return { valid : false , error : 'reserved_name' } ;
}
// Check for names starting/ending with dots or spaces (problematic on some systems)
if ( trimmed . startsWith ( '.' ) && trimmed . length === 1 ) {
return { valid : false , error : 'invalid_dot' } ;
}
if ( trimmed . endsWith ( '.' ) || trimmed . endsWith ( ' ' ) ) {
return { valid : false , error : 'trailing_dot_space' } ;
}
return { valid : true , sanitized : trimmed } ;
} ,
/ * *
* Validate a path ( may contain forward slashes for folder separators )
* @ param { string } path - The path to validate
* @ returns { { valid : boolean , error ? : string , sanitized ? : string } }
* /
validatePath ( path ) {
if ( ! path || typeof path !== 'string' ) {
return { valid : false , error : 'empty' } ;
}
const trimmed = path . trim ( ) ;
if ( ! trimmed ) {
return { valid : false , error : 'empty' } ;
}
// Split by forward slash and validate each segment
const segments = trimmed . split ( '/' ) . filter ( s => s . length > 0 ) ;
if ( segments . length === 0 ) {
return { valid : false , error : 'empty' } ;
}
for ( const segment of segments ) {
const result = this . validateFilename ( segment ) ;
if ( ! result . valid ) {
return result ;
}
}
// Rebuild path without empty segments
return { valid : true , sanitized : segments . join ( '/' ) } ;
}
} ;
2026-05-04 17:44:16 +00:00
/** Max leading spaces Shift+Tab removes per line when matching one “tab indent” vs \t inserts. */
const EDITOR _TAB _OUTDENT _MAX _SPACES = 4 ;
function editorIndentRemoveLen ( lineSegment ) {
if ( ! lineSegment || lineSegment . length === 0 ) return 0 ;
if ( lineSegment . charCodeAt ( 0 ) === 9 /* \t */ ) return 1 ;
let i = 0 ;
const max = Math . min ( EDITOR _TAB _OUTDENT _MAX _SPACES , lineSegment . length ) ;
while ( i < max && lineSegment [ i ] === ' ' ) i ++ ;
return i ;
}
function editorOutdentChunk ( chunk ) {
if ( chunk . length === 0 ) return { newChunk : chunk , changed : false } ;
const lines = chunk . split ( '\n' ) ;
const newLines = lines . map ( ( line ) => {
const n = editorIndentRemoveLen ( line ) ;
return n > 0 ? line . slice ( n ) : line ;
} ) ;
const newChunk = newLines . join ( '\n' ) ;
return { newChunk , changed : newChunk !== chunk } ;
}
function editorRemovedBeforeInChunk ( chunk , rel ) {
const target = Math . min ( Math . max ( rel , 0 ) , chunk . length ) ;
let off = 0 ;
let removed = 0 ;
while ( off < chunk . length && off < target ) {
const nl = chunk . indexOf ( '\n' , off ) ;
const lineEnd = nl === - 1 ? chunk . length : nl ;
const lineSlice = chunk . slice ( off , lineEnd ) ;
const n = editorIndentRemoveLen ( lineSlice ) ;
if ( target >= lineEnd ) {
removed += n ;
if ( nl === - 1 ) break ;
off = lineEnd + 1 ;
} else {
const col = target - off ;
removed += Math . min ( col , n ) ;
break ;
}
}
return removed ;
}
function editorMapCaretAfterOutdent ( chunk , newChunk , rel ) {
const r = Math . max ( 0 , rel ) ;
if ( r >= chunk . length ) return newChunk . length ;
return r - editorRemovedBeforeInChunk ( chunk , r ) ;
}
/ * *
* Shift + Tab outdent while “ tab inserts tab ” is enabled : remove one leading \ t or up to 4 spaces per affected line .
* @ returns { { changed : boolean , text : string , selStart : number , selEnd : number } }
* /
function applyEditorOutdent ( text , selStart , selEnd ) {
const a = Math . min ( selStart , selEnd ) ;
const b = Math . max ( selStart , selEnd ) ;
const blockStart = text . lastIndexOf ( '\n' , Math . max ( 0 , a - 1 ) ) + 1 ;
const ref = b > a ? b - 1 : a ;
let blockEnd = text . indexOf ( '\n' , ref ) ;
if ( blockEnd === - 1 ) blockEnd = text . length ;
const chunk = text . slice ( blockStart , blockEnd ) ;
const { newChunk , changed } = editorOutdentChunk ( chunk ) ;
if ( ! changed ) {
return { changed : false , text , selStart , selEnd } ;
}
const newText = text . slice ( 0 , blockStart ) + newChunk + text . slice ( blockEnd ) ;
const anchorRel = a - blockStart ;
const focusRel = b - blockStart ;
const newA = blockStart + editorMapCaretAfterOutdent ( chunk , newChunk , anchorRel ) ;
const newB = blockStart + editorMapCaretAfterOutdent ( chunk , newChunk , focusRel ) ;
return {
changed : true ,
text : newText ,
selStart : Math . min ( newA , newB ) ,
selEnd : Math . max ( newA , newB ) ,
} ;
}
2025-11-05 16:48:41 +00:00
function noteApp ( ) {
return {
// App state
appName : 'NoteDiscovery' ,
2025-11-23 15:45:47 +00:00
appVersion : '0.0.0' ,
2025-12-06 17:40:28 +00:00
authEnabled : false ,
2025-12-17 09:32:02 +00:00
demoMode : false ,
2026-01-22 09:43:47 +00:00
alreadyDonated : false ,
2026-06-08 09:02:05 +00:00
autosaveDelayMs : CONFIG . AUTOSAVE _DELAY , // hydrated from /api/config in loadConfig()
2025-11-05 16:48:41 +00:00
notes : [ ] ,
2026-06-30 10:26:32 +00:00
// True while /api/notes is in flight. Drives the "Loading your vault…"
// placeholder + the delayed overlay (notesLoadingShowOverlay).
notesLoading : true ,
notesLoadingShowOverlay : false ,
_notesLoadingOverlayTimer : null ,
2025-11-05 16:48:41 +00:00
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 ,
2026-05-29 10:30:23 +00:00
// Hides only the .sidebar-panel content (files / search / tags / outline / etc.) while
// keeping the icon rail visible — a lighter "focus mode" than zenMode. Persisted via
// LOCAL_SETTINGS so it survives reload; hydrated by loadLocalSettings() at app init,
// which is why the initial value here matches the LOCAL_SETTINGS default.
sidebarPanelCollapsed : false ,
2025-12-12 16:18:14 +00:00
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
} ,
2026-01-18 15:00:01 +00:00
// Media lookup map for O(1) media wikilink resolution (built on loadNotes)
// Maps media filename (case-insensitive) -> full path
_mediaLookup : new Map ( ) ,
2026-01-05 11:51:24 +00:00
2025-12-12 17:11:29 +00:00
// Preview rendering debounce
_previewDebounceTimeout : null ,
_lastRenderedContent : '' ,
2026-04-13 14:11:55 +00:00
_lastRenderedNote : '' ,
2025-12-12 17:11:29 +00:00
_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
2025-12-15 13:39:59 +00:00
currentLocale : localStorage . getItem ( 'locale' ) || 'en-US' ,
2025-12-14 17:02:57 +00:00
availableLocales : [ ] ,
2025-12-15 13:39:59 +00:00
// Translations loaded from backend (preloaded before Alpine init via window.__preloadedTranslations)
translations : window . _ _preloadedTranslations || { } ,
2025-12-14 17:02:57 +00:00
2025-12-09 14:33:49 +00:00
// Syntax highlighting
syntaxHighlightEnabled : false ,
syntaxHighlightTimeout : null ,
2026-01-26 09:22:00 +00:00
// Readable line length (preview max-width)
readableLineLength : true ,
2026-02-23 09:59:58 +00:00
// Hide underscore-prefixed folders (_attachments, _templates) from sidebar
2026-02-23 16:51:11 +00:00
// Read synchronously to prevent flash on initial render
hideUnderscoreFolders : localStorage . getItem ( 'hideUnderscoreFolders' ) === 'true' ,
2026-04-06 09:25:21 +00:00
// Tab key inserts tab character instead of changing focus
tabInsertsTab : localStorage . getItem ( 'tabInsertsTab' ) === 'true' ,
2026-04-06 14:00:00 +00:00
// Note sorting mode (a-z, z-a, newest, oldest, largest, smallest)
sortMode : localStorage . getItem ( 'sortMode' ) || 'a-z' ,
2025-12-06 17:06:07 +00:00
// Icon rail / panel state
2026-04-22 13:38:16 +00:00
activePanel : 'files' , // 'files', 'search', 'tags', 'outline', 'backlinks', 'shared', 'settings'
2025-12-06 17:06:07 +00:00
2025-11-05 16:48:41 +00:00
// Folder state
folderTree : [ ] ,
allFolders : [ ] ,
expandedFolders : new Set ( ) ,
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
2026-02-20 07:26:52 +00:00
// Search state
searchDebounceTimeout : null ,
isSearching : false ,
2025-11-24 16:11:22 +00:00
2025-12-16 15:55:05 +00:00
// Outline (TOC) state
outline : [ ] , // [{level: 1, text: 'Heading', slug: 'heading'}, ...]
2026-03-24 11:02:34 +00:00
// Backlinks state
backlinks : [ ] , // [{path: 'note.md', name: 'Note', references: [{line_number: 5, context: '...', type: 'wikilink'}]}]
2025-11-05 16:48:41 +00:00
// Scroll sync state
isScrolling : false ,
2026-01-19 08:42:32 +00:00
// Unified drag state for notes, folders, and media
draggedItem : null , // { path: string, type: 'note' | 'folder' | 'image' | 'audio' | 'video' | 'document' }
2025-11-20 13:10:17 +00:00
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 ,
2025-12-18 08:50:08 +00:00
hasPendingHistoryChanges : false ,
2026-05-04 17:50:38 +00:00
// Per-note editor scroll position, keyed by note path. Captured when leaving a note in
// loadNote() and restored when returning to the same note in the same session. Stays
// in memory only — cleared on page reload, intentionally (matches typical editor UX).
noteScrollPositions : { } ,
2025-11-05 16:48:41 +00:00
// 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 : '' ,
2026-06-04 11:21:49 +00:00
newButtonAction : 'chooser' ,
autoFillNoteTitle : false ,
lastUsedTemplate : '' ,
2025-11-26 10:28:09 +00:00
2026-04-17 07:27:36 +00:00
// New note / folder name modal (replaces window.prompt)
showCreateNameModal : false ,
createNameModalKind : 'note' ,
createNameModalTargetFolder : '' ,
createNameModalInput : '' ,
// Rename folder modal (replaces window.prompt)
showRenameFolderModal : false ,
renameFolderPath : '' ,
renameFolderOldName : '' ,
renameFolderInput : '' ,
2026-04-17 07:39:52 +00:00
// Generic confirm dialog (replaces window.confirm)
showConfirmModal : false ,
confirmModalTitle : '' ,
confirmModalMessage : '' ,
confirmModalDanger : true ,
confirmModalConfirmLabel : '' ,
confirmModalCancelLabel : '' ,
_confirmModalResolve : null ,
2026-01-15 09:07:51 +00:00
// Share state
showShareModal : false ,
shareInfo : null ,
shareLoading : false ,
2026-02-09 17:55:55 +00:00
showShareQR : false ,
2026-01-15 09:07:51 +00:00
shareLinkCopied : false ,
_sharedNotePaths : new Set ( ) , // O(1) lookup for shared note indicators
2026-04-22 13:38:16 +00:00
_sharedNotePathsList : [ ] , // sorted paths, mirrors Set for reactive sidebar panel
2026-01-15 09:07:51 +00:00
2026-01-15 15:32:35 +00:00
// Quick Switcher state (Ctrl+Alt+P)
showQuickSwitcher : false ,
quickSwitcherQuery : '' ,
quickSwitcherIndex : 0 ,
quickSwitcherResults : [ ] ,
2026-04-16 16:17:34 +00:00
// Non-blocking notifications (replaces window.alert)
toasts : [ ] ,
_toastIdSeq : 0 ,
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 ,
2026-01-18 15:00:01 +00:00
// Media viewer state
currentMedia : '' , // Path to current media file (kept as 'currentMedia' for compatibility)
2026-04-21 13:22:09 +00:00
currentMediaType : 'image' , // 'image', 'audio', 'video', 'document', 'drawing'
2026-04-24 13:29:00 +00:00
// Drawing canvas (drawing-*.png only) — ops are session-only until Save flattens to PNG.
// Coordinates in drawingOps[] are stored in DOCUMENT space (drawingDocW × drawingDocH),
// not display CSS pixels, so resizing the editor pane never moves strokes and exported
// PNGs are byte-deterministic regardless of screen size or device pixel ratio.
2026-04-21 13:22:09 +00:00
drawingTool : 'freehand' ,
drawingColor : '#1a1a1a' ,
drawingLineWidth : 4 ,
drawingOps : [ ] ,
2026-04-24 14:29:29 +00:00
/ * *
* Intrinsic drawing dimensions ( in document px ) . Initial values come from CONFIG so
* there is a single source of truth for the default ; per - drawing values get overwritten
* by createNewDrawing ( ) ( using its opts ) or initDrawingViewer ( ) ( using the PNG ' s natural
* size ) . To change the default , edit CONFIG . DRAWING _DEFAULT _DOC _W / _H only .
* /
drawingDocW : CONFIG . DRAWING _DEFAULT _DOC _W ,
drawingDocH : CONFIG . DRAWING _DEFAULT _DOC _H ,
2026-04-21 13:22:09 +00:00
drawingRedoStack : [ ] ,
drawingDraft : null ,
drawingIsPointerDown : false ,
2026-05-07 12:30:55 +00:00
// Text tool — pending input state. The HTML overlay <input> binds to drawingTextValue
// while drawingTextActive is true; on commit a 'text' op is pushed to drawingOps.
drawingTextActive : false ,
drawingTextDocX : 0 , // commit position in DOC space
drawingTextDocY : 0 ,
drawingTextCssX : 0 , // overlay <input> position in CSS pixels (canvas-relative)
drawingTextCssY : 0 ,
drawingTextValue : '' ,
drawingTextDocFontSize : 24 , // font size in DOC pixels (auto-scales to display)
2026-04-21 15:39:22 +00:00
/** True after the PNG from disk has been decoded into _drawingBaseImage; false after Clear. */
drawingHasRasterFromFile : false ,
2026-04-21 16:00:50 +00:00
/** Prevents overlapping drawingSave() runs (Ctrl+S + autosave + fast retries). */
_drawingSaveInFlight : false ,
/** If true, run drawingSave again after the current one finishes (coalesce). */
_drawingSaveQueued : false ,
2026-04-21 14:00:58 +00:00
_drawingAutosaveTimeout : null ,
2025-11-20 13:10:17 +00:00
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 ) => {
2026-01-19 08:42:32 +00:00
if ( e . key === 'Escape' && this . draggedItem ) {
2025-12-09 11:08:12 +00:00
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 ( ) ;
2025-12-15 13:39:59 +00:00
// Note: Translations are preloaded synchronously before Alpine init (see index.html)
// loadLocale() is only called when user changes language from settings
2025-11-05 16:48:41 +00:00
await this . loadNotes ( ) ;
2026-01-15 09:07:51 +00:00
await this . loadSharedNotePaths ( ) ;
2025-11-26 10:28:09 +00:00
await this . loadTemplates ( ) ;
2025-11-05 16:48:41 +00:00
await this . checkStatsPlugin ( ) ;
2026-01-26 09:22:00 +00:00
this . loadLocalSettings ( ) ;
2025-11-05 16:48:41 +00:00
// Parse URL and load specific note if provided
2026-01-19 09:14:37 +00:00
this . loadItemFromURL ( ) ;
2025-11-05 16:48:41 +00:00
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 : '' } , '' , '/' ) ;
2026-01-05 10:05:08 +00:00
document . title = this . appName ;
2025-11-20 03:01:27 +00:00
}
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 ( ) ;
}
2026-01-19 09:14:37 +00:00
} else if ( e . state && e . state . mediaPath ) {
// Navigating to a media file
this . viewMedia ( e . state . mediaPath , null , false ) ;
2025-11-20 03:01:27 +00:00
} else {
// Navigating back to homepage
this . currentNote = '' ;
this . noteContent = '' ;
this . currentNoteName = '' ;
2025-12-16 15:55:05 +00:00
this . outline = [ ] ;
2026-03-24 11:02:34 +00:00
this . backlinks = [ ] ;
2026-01-15 09:07:51 +00:00
this . shareInfo = null ; // Reset share info
2026-01-05 10:05:08 +00:00
document . title = this . appName ;
2025-11-20 03:01:27 +00:00
// 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 ( ) ;
2026-05-04 17:50:38 +00:00
// Re-apply the saved scroll position to whichever pane just became visible.
// x-show toggles display:none/block; an overflow:auto element resets its
// scrollTop on that transition, so the previously-hidden pane lands at 0
// unless we explicitly restore. $nextTick waits for Alpine to flush the
// x-show change, the rAF then waits for layout so scrollHeight is valid.
2025-11-05 16:48:41 +00:00
this . $nextTick ( ( ) => {
2026-05-04 17:50:38 +00:00
requestAnimationFrame ( ( ) => {
this . _restoreNoteScroll ( ) ;
} ) ;
2025-11-05 16:48:41 +00:00
} ) ;
} ) ;
// 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 ) => {
2026-02-23 09:16:52 +00:00
// Use e.key (not e.code) for letter keys to support non-QWERTY keyboard layouts
2025-12-14 14:33:53 +00:00
2026-04-21 13:22:09 +00:00
// Ctrl/Cmd + S to save (drawing saves PNG; notes save markdown)
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 's' ) {
2026-04-21 13:22:09 +00:00
if ( this . currentMedia && this . currentMediaType === 'drawing' ) {
e . preventDefault ( ) ;
this . drawingSave ( ) ;
} else {
e . preventDefault ( ) ;
this . saveNote ( ) ;
}
2025-11-05 16:48:41 +00:00
}
2026-01-15 15:32:35 +00:00
// Ctrl/Cmd + Alt + P for Quick Switcher
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . altKey && e . key . toLowerCase ( ) === 'p' ) {
2026-01-15 15:32:35 +00:00
e . preventDefault ( ) ;
this . openQuickSwitcher ( ) ;
return ;
}
2025-12-14 14:33:53 +00:00
// Ctrl/Cmd + Alt/Option + N for new note
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . altKey && e . key . toLowerCase ( ) === 'n' ) {
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
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . altKey && e . key . toLowerCase ( ) === 'f' ) {
2025-11-17 13:38:07 +00:00
e . preventDefault ( ) ;
this . createFolder ( ) ;
}
2026-04-21 13:22:09 +00:00
// Ctrl/Cmd + Z for undo (drawing vs note editor)
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && ! e . shiftKey && ! e . altKey && e . key . toLowerCase ( ) === 'z' ) {
2026-04-21 13:22:09 +00:00
if ( this . currentMedia && this . currentMediaType === 'drawing' ) {
e . preventDefault ( ) ;
this . drawingUndo ( ) ;
} else {
e . preventDefault ( ) ;
this . undo ( ) ;
}
2025-11-05 16:48:41 +00:00
}
2025-12-14 14:33:53 +00:00
// Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'y' ) {
2026-04-21 13:22:09 +00:00
if ( this . currentMedia && this . currentMediaType === 'drawing' ) {
e . preventDefault ( ) ;
this . drawingRedo ( ) ;
} else {
e . preventDefault ( ) ;
this . redo ( ) ;
}
2025-12-14 14:33:53 +00:00
}
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . shiftKey && ! e . altKey && e . key . toLowerCase ( ) === 'z' ) {
2026-04-21 13:22:09 +00:00
if ( this . currentMedia && this . currentMediaType === 'drawing' ) {
e . preventDefault ( ) ;
this . drawingRedo ( ) ;
} else {
e . preventDefault ( ) ;
this . redo ( ) ;
}
2025-11-05 16:48:41 +00:00
}
// 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
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'b' ) {
2025-11-24 09:17:21 +00:00
e . preventDefault ( ) ;
this . wrapSelection ( '**' , '**' , 'bold text' ) ;
}
// Ctrl/Cmd + I for italic
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'i' ) {
2025-11-24 09:17:21 +00:00
e . preventDefault ( ) ;
this . wrapSelection ( '*' , '*' , 'italic text' ) ;
}
// Ctrl/Cmd + K for link
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'k' ) {
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
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . altKey && e . key . toLowerCase ( ) === 't' ) {
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
2026-02-23 09:16:52 +00:00
if ( ( e . ctrlKey || e . metaKey ) && e . altKey && e . key . toLowerCase ( ) === 'z' ) {
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 ;
}
} ) ;
2026-04-16 16:17:34 +00:00
// Toasts from ErrorHandler and code paths without Alpine `this`
if ( ! window . _ _noteapp _toast _listener ) {
window . _ _noteapp _toast _listener = true ;
window . addEventListener ( 'notediscovery:toast' , ( e ) => {
const d = e . detail || { } ;
if ( typeof d . message !== 'string' || ! d . message ) return ;
this . toast ( d . message , {
type : d . type || 'error' ,
durationMs : d . durationMs
} ) ;
} ) ;
}
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 ;
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-12-17 09:32:02 +00:00
this . demoMode = config . demoMode || false ;
2026-01-22 09:43:47 +00:00
this . alreadyDonated = config . alreadyDonated || false ;
2026-06-08 09:02:05 +00:00
// Server already clamps autosaveDelayMs to a sane range; we just sanity-check the type.
if ( Number . isFinite ( config . autosaveDelayMs ) && config . autosaveDelayMs > 0 ) {
this . autosaveDelayMs = config . autosaveDelayMs ;
}
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 ( ) ;
}
} ,
2026-01-26 09:22:00 +00:00
// Load all localStorage settings at once using centralized config
loadLocalSettings ( ) {
for ( const [ prop , config ] of Object . entries ( LOCAL _SETTINGS ) ) {
try {
const saved = localStorage . getItem ( config . key ) ;
if ( saved === null ) {
// Use default value if not set
this [ prop ] = config . default ;
} else if ( config . type === 'boolean' ) {
this [ prop ] = saved === 'true' ;
} else if ( config . type === 'number' ) {
const num = parseFloat ( saved ) ;
// Validate range if specified
if ( ! isNaN ( num ) &&
( config . min === undefined || num >= config . min ) &&
( config . max === undefined || num <= config . max ) ) {
this [ prop ] = num ;
} else {
this [ prop ] = config . default ;
}
} else if ( config . type === 'string' ) {
// Validate against allowed values if specified
if ( ! config . valid || config . valid . includes ( saved ) ) {
this [ prop ] = saved ;
} else {
this [ prop ] = config . default ;
}
} else if ( config . type === 'json' ) {
this [ prop ] = JSON . parse ( saved ) ;
}
} catch ( error ) {
console . error ( ` Error loading setting ${ prop } : ` , error ) ;
this [ prop ] = config . default ;
}
2025-12-09 14:33:49 +00:00
}
2026-01-26 09:22:00 +00:00
// Special case: favorites also needs to update the Set for O(1) lookups
this . favoritesSet = new Set ( this . favorites ) ;
} ,
// Readable line length toggle (for preview max-width)
toggleReadableLineLength ( ) {
this . readableLineLength = ! this . readableLineLength ;
localStorage . setItem ( 'readableLineLength' , this . readableLineLength ) ;
2025-12-09 14:33:49 +00:00
} ,
2026-02-23 09:59:58 +00:00
// Hide underscore folders toggle (hides _attachments, _templates, etc. from sidebar)
toggleHideUnderscoreFolders ( ) {
this . hideUnderscoreFolders = ! this . hideUnderscoreFolders ;
localStorage . setItem ( 'hideUnderscoreFolders' , this . hideUnderscoreFolders ) ;
} ,
2026-04-06 09:25:21 +00:00
// Tab inserts tab toggle (Tab key inserts tab character instead of changing focus)
toggleTabInsertsTab ( ) {
this . tabInsertsTab = ! this . tabInsertsTab ;
localStorage . setItem ( 'tabInsertsTab' , this . tabInsertsTab ) ;
} ,
2026-05-29 10:30:23 +00:00
// Hide / show only the sidebar PANEL (files, search, outline, etc.); the icon rail
// stays put so users can still switch panels with one click after re-expanding.
// Width is animated via CSS; sidebarWidth itself is intentionally NOT touched, so a
// user's custom drag-resized width is restored automatically on expand.
toggleSidebarPanel ( ) {
this . sidebarPanelCollapsed = ! this . sidebarPanelCollapsed ;
localStorage . setItem ( 'sidebarPanelCollapsed' , this . sidebarPanelCollapsed ) ;
} ,
// Switch the active sidebar panel (Files / Search / Tags / Outline / Backlinks / Shared
// / Settings) with smart toggle behaviour for the icon rail:
//
// collapsed + click any icon X → expand + switch to X
// expanded, on Y + click icon X → switch to X (no collapse change)
// expanded, on X + click icon X → collapse (symmetric "click active to close")
//
// That third rule matches the VS Code / Cursor / JetBrains / Obsidian convention where
// re-clicking the active sidebar icon hides the panel. Without it, the only way to
// collapse would be the dedicated toggle button, which is fine but leaves an asymmetry.
//
// On mobile (≤768px) the desktop collapse is CSS-overridden, so the panel is always
// visible; we deliberately skip toggling in that case so a one-off mobile tap doesn't
// silently clear the user's persisted desktop preference.
openSidebarPanel ( panelName ) {
const isDesktop = window . innerWidth > 768 ;
if ( isDesktop && this . activePanel === panelName && ! this . sidebarPanelCollapsed ) {
this . toggleSidebarPanel ( ) ;
return ;
}
this . activePanel = panelName ;
if ( isDesktop && this . sidebarPanelCollapsed ) {
this . toggleSidebarPanel ( ) ;
}
} ,
2026-05-04 17:44:16 +00:00
// Handle Tab key in editor (inserts tab if setting enabled; Shift+Tab outdents matching lines)
2026-04-06 09:25:21 +00:00
handleTabKey ( event ) {
if ( ! this . tabInsertsTab ) return ;
2026-05-04 17:44:16 +00:00
2026-04-06 09:25:21 +00:00
const textarea = event . target ;
const start = textarea . selectionStart ;
const end = textarea . selectionEnd ;
2026-05-04 17:44:16 +00:00
if ( event . shiftKey ) {
const result = applyEditorOutdent ( this . noteContent , start , end ) ;
if ( ! result . changed ) return ;
event . preventDefault ( ) ;
this . noteContent = result . text ;
this . $nextTick ( ( ) => {
textarea . selectionStart = result . selStart ;
textarea . selectionEnd = result . selEnd ;
} ) ;
this . autoSave ( ) ;
return ;
}
event . preventDefault ( ) ;
2026-04-06 09:25:21 +00:00
this . noteContent = this . noteContent . substring ( 0 , start ) + '\t' + this . noteContent . substring ( end ) ;
this . $nextTick ( ( ) => {
textarea . selectionStart = textarea . selectionEnd = start + 1 ;
} ) ;
this . autoSave ( ) ;
} ,
2026-04-12 16:46:35 +00:00
/ * *
2026-04-13 15:12:04 +00:00
* Enter : continue blockquote / bullet / ordered list / task list ; second Enter on empty item exits ( see editor - markdown - continue . js ) .
2026-04-12 16:46:35 +00:00
* /
handleEditorEnterKey ( event ) {
if ( typeof EditorMarkdownContinue === 'undefined' ) return ;
const textarea = event . target ;
if ( ! textarea || textarea . id !== 'note-editor' ) return ;
const result = EditorMarkdownContinue . tryEnter (
this . noteContent ,
textarea . selectionStart ,
textarea . selectionEnd ,
event
) ;
if ( ! result . handled ) return ;
event . preventDefault ( ) ;
this . noteContent = result . text ;
this . $nextTick ( ( ) => {
textarea . selectionStart = textarea . selectionEnd = result . cursor ;
} ) ;
this . autoSave ( ) ;
this . updateSyntaxHighlight ( ) ;
} ,
2026-04-06 14:00:00 +00:00
// Sort mode configuration
sortModes : [ 'a-z' , 'z-a' , 'newest' , 'oldest' , 'largest' , 'smallest' ] ,
sortModeIcons : {
'a-z' : 'A↓' ,
'z-a' : 'Z↓' ,
'newest' : '🕐↓' ,
'oldest' : '🕐↑' ,
'largest' : '📄↓' ,
'smallest' : '📄↑'
} ,
// Cycle through sort modes
cycleSortMode ( ) {
const currentIndex = this . sortModes . indexOf ( this . sortMode ) ;
const nextIndex = ( currentIndex + 1 ) % this . sortModes . length ;
this . sortMode = this . sortModes [ nextIndex ] ;
localStorage . setItem ( 'sortMode' , this . sortMode ) ;
// Rebuild tree to apply new sort order
this . buildFolderTree ( ) ;
} ,
// Get current sort icon
getSortIcon ( ) {
return this . sortModeIcons [ this . sortMode ] || 'A↓' ;
} ,
// Get sort comparator based on current mode (for notes)
getSortComparator ( ) {
switch ( this . sortMode ) {
case 'z-a' :
return ( a , b ) => b . name . toLowerCase ( ) . localeCompare ( a . name . toLowerCase ( ) ) ;
case 'newest' :
return ( a , b ) => ( b . modified || '' ) . localeCompare ( a . modified || '' ) ;
case 'oldest' :
return ( a , b ) => ( a . modified || '' ) . localeCompare ( b . modified || '' ) ;
case 'largest' :
return ( a , b ) => ( b . size || 0 ) - ( a . size || 0 ) ;
case 'smallest' :
return ( a , b ) => ( a . size || 0 ) - ( b . size || 0 ) ;
case 'a-z' :
default :
return ( a , b ) => a . name . toLowerCase ( ) . localeCompare ( b . name . toLowerCase ( ) ) ;
}
} ,
// Get sort comparator for folders (only A-Z/Z-A, others default to A-Z)
getFolderSortComparator ( ) {
if ( this . sortMode === 'z-a' ) {
return ( a , b ) => b . name . toLowerCase ( ) . localeCompare ( a . name . toLowerCase ( ) ) ;
}
// Default: A-Z for all other modes
return ( a , b ) => a . name . toLowerCase ( ) . localeCompare ( b . name . toLowerCase ( ) ) ;
} ,
2025-12-09 14:33:49 +00:00
// 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
} ,
2026-05-04 17:50:38 +00:00
// Sync overlay scroll with textarea. Per-note scroll restoration is captured separately
// inside the _editorScrollHandler / _previewScrollHandler in setupScrollSync(), where
// the existing isScrolling guard ensures we only record user-driven scrolls (not the
// programmatic mirrors between panes), and where we have access to the pane the user
// actually scrolled — important because the textarea's scroll event never fires in
// preview-only mode (the textarea is display:none).
2025-12-09 14:33:49 +00:00
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
2026-02-02 17:28:30 +00:00
// Headings - capture the whitespace to preserve exact characters (tabs vs spaces)
// This prevents cursor/selection misalignment
html = html . replace ( /^(#{1,6})(\s)(.*)$/gm , '<span class="md-heading">$1$2$3</span>' ) ;
2025-12-09 14:33:49 +00:00
// 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>' ) ;
2026-02-02 17:28:30 +00:00
// Lists - use ([ \t]) to capture the space/tab and preserve exact characters
// IMPORTANT: Don't add any characters (like \u200B) that aren't in the original,
// as this breaks cursor/selection alignment between textarea and overlay
html = html . replace ( /^(\s*)([-*+])([ \t])(.*)$/gm , ( match , indent , bullet , space , rest ) => {
return ` ${ indent } <span class="md-list"> ${ bullet } </span> ${ space } ${ rest } ` ;
2026-01-09 09:57:34 +00:00
} ) ;
2026-02-02 17:28:30 +00:00
html = html . replace ( /^(\s*)(\d+\.)([ \t])(.*)$/gm , ( match , indent , bullet , space , rest ) => {
return ` ${ indent } <span class="md-list"> ${ bullet } </span> ${ space } ${ rest } ` ;
2026-01-09 09:57:34 +00:00
} ) ;
2025-12-09 14:33:49 +00:00
// 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 ) ;
}
2026-01-01 14:26:03 +00:00
// Update PWA theme-color meta tag to match current theme
const themeColorMeta = document . querySelector ( 'meta[name="theme-color"]' ) ;
if ( themeColorMeta ) {
// Get the accent color from CSS variables
const accentColor = getComputedStyle ( document . documentElement )
. getPropertyValue ( '--accent-primary' ) . trim ( ) || '#667eea' ;
themeColorMeta . setAttribute ( 'content' , accentColor ) ;
}
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 } }} ` ) ;
} ,
2026-04-16 16:17:34 +00:00
/ * *
* Non - blocking toast ( replaces window . alert ) . Types map to theme CSS variables .
* @ param { string } message
* @ param { { type ? : 'error' | 'warning' | 'info' | 'success' , durationMs ? : number } } [ options ]
* @ returns { number | undefined } toast id
* /
toast ( message , options = { } ) {
if ( typeof message !== 'string' || ! message ) return ;
const type = [ 'error' , 'warning' , 'info' , 'success' ] . includes ( options . type )
? options . type
: 'info' ;
let durationMs = options . durationMs ;
if ( durationMs == null || typeof durationMs !== 'number' || durationMs < 0 ) {
durationMs = type === 'error' ? CONFIG . TOAST _DURATION _ERROR _MS
: type === 'warning' ? CONFIG . TOAST _DURATION _WARNING _MS
: type === 'success' ? CONFIG . TOAST _DURATION _SUCCESS _MS
: CONFIG . TOAST _DURATION _INFO _MS ;
}
const id = ++ this . _toastIdSeq ;
const entry = { id , message , type , _tid : null } ;
this . toasts . push ( entry ) ;
while ( this . toasts . length > CONFIG . TOAST _MAX _VISIBLE ) {
const first = this . toasts . shift ( ) ;
if ( first && first . _tid ) clearTimeout ( first . _tid ) ;
}
entry . _tid = setTimeout ( ( ) => this . dismissToast ( id ) , durationMs ) ;
return id ;
} ,
dismissToast ( id ) {
const idx = this . toasts . findIndex ( ( t ) => t . id === id ) ;
if ( idx === - 1 ) return ;
const entry = this . toasts [ idx ] ;
if ( entry . _tid ) {
clearTimeout ( entry . _tid ) ;
entry . _tid = null ;
}
this . toasts . splice ( idx , 1 ) ;
} ,
2025-12-17 09:03:40 +00:00
/ * *
* Get localized error message from FilenameValidator result
* @ param { object } validation - The validation result from FilenameValidator
* @ param { string } type - 'note' or 'folder'
* @ returns { string } Localized error message
* /
getValidationErrorMessage ( validation , type = 'note' ) {
switch ( validation . error ) {
case 'empty' :
return type === 'note'
? this . t ( 'notes.empty_name' )
: this . t ( 'folders.invalid_name' ) ;
case 'forbidden_chars' :
return this . t ( 'validation.forbidden_chars' , {
chars : validation . forbiddenChars
} ) ;
case 'reserved_name' :
return this . t ( 'validation.reserved_name' ) ;
case 'invalid_dot' :
return this . t ( 'validation.invalid_dot' ) ;
case 'trailing_dot_space' :
return this . t ( 'validation.trailing_dot_space' ) ;
default :
return type === 'note'
? this . t ( 'notes.invalid_name' )
: this . t ( 'folders.invalid_name' ) ;
}
} ,
2025-12-14 17:02:57 +00:00
// 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 ( ) {
2026-06-30 10:26:32 +00:00
this . notesLoading = true ;
// Show the overlay only if we're still loading after 800ms AND
// the tree is currently empty (first load). Refreshes after a
// save typically resolve in <100ms and never trip this.
clearTimeout ( this . _notesLoadingOverlayTimer ) ;
this . _notesLoadingOverlayTimer = setTimeout ( ( ) => {
if ( this . notesLoading && this . notes . length === 0 && this . allFolders . length === 0 ) {
this . notesLoadingShowOverlay = true ;
}
} , 800 ) ;
2025-11-05 16:48:41 +00:00
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 ) ;
2026-06-30 10:26:32 +00:00
} finally {
clearTimeout ( this . _notesLoadingOverlayTimer ) ;
this . notesLoading = false ;
this . notesLoadingShowOverlay = false ;
2025-11-05 16:48:41 +00:00
}
} ,
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 ( ) ;
2026-01-18 15:00:01 +00:00
this . _mediaLookup . clear ( ) ;
2025-12-12 17:11:29 +00:00
for ( const note of this . notes ) {
const path = note . path ;
const pathLower = path . toLowerCase ( ) ;
const name = note . name ;
const nameLower = name . toLowerCase ( ) ;
2026-01-05 11:51:24 +00:00
2026-01-18 15:00:01 +00:00
// Handle media files separately - build media lookup map
if ( note . type !== 'note' ) {
2026-02-23 09:52:25 +00:00
// Map filename WITH extension (case-insensitive) to full path
// Use path to get filename with extension (note.name is stem without extension)
const filenameWithExt = path . split ( '/' ) . pop ( ) . toLowerCase ( ) ;
2026-01-05 11:51:24 +00:00
// First match wins if there are duplicates
2026-02-23 09:52:25 +00:00
if ( ! this . _mediaLookup . has ( filenameWithExt ) ) {
this . _mediaLookup . set ( filenameWithExt , path ) ;
2026-01-05 11:51:24 +00:00
}
continue ;
}
// Notes only from here
2025-12-12 17:11:29 +00:00
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' )
) ;
} ,
2026-01-18 15:00:01 +00:00
// Resolve media wikilink to full path (O(1) lookup)
2026-01-05 11:51:24 +00:00
// Returns the full path if found, null otherwise
2026-01-18 15:00:01 +00:00
resolveMediaWikilink ( mediaName ) {
const nameLower = mediaName . toLowerCase ( ) ;
return this . _mediaLookup . get ( nameLower ) || null ;
2026-01-05 11:51:24 +00:00
} ,
2026-04-13 14:11:55 +00:00
// Resolve a Markdown image path to a vault-relative path (forward slashes).
// Relative paths use the note file's directory as base (same rules as CommonMark / browsers).
// A leading "/" (not "//") means vault-root-relative.
resolveMarkdownMediaPathForNote ( noteVaultPath , rawSrc ) {
const pathPart = rawSrc . split ( '#' ) [ 0 ] . split ( '?' ) [ 0 ] ;
if ( ! pathPart ) return '' ;
if ( pathPart . startsWith ( '/' ) && ! pathPart . startsWith ( '//' ) ) {
return pathPart . replace ( /^\/+/ , '' ) ;
}
if ( ! noteVaultPath ) {
return pathPart ;
}
const dir = noteVaultPath . includes ( '/' )
? noteVaultPath . slice ( 0 , noteVaultPath . lastIndexOf ( '/' ) )
: '' ;
const base = ` https://vn.invalid/ ${ dir ? ` ${ dir } / ` : '' } ` ;
try {
const u = new URL ( pathPart , base ) ;
let p = u . pathname ;
if ( p . startsWith ( '/' ) ) p = p . slice ( 1 ) ;
return p . split ( '/' ) . filter ( seg => seg !== '' ) . map ( seg => {
try {
return decodeURIComponent ( seg ) ;
} catch ( e ) {
return seg ;
}
} ) . join ( '/' ) ;
} catch ( e ) {
return pathPart ;
}
} ,
encodeVaultRelativePathForMediaApi ( vaultRelativePath ) {
if ( ! vaultRelativePath ) return '' ;
return vaultRelativePath . split ( '/' ) . map ( segment => {
try {
return encodeURIComponent ( decodeURIComponent ( segment ) ) ;
} catch ( e ) {
return encodeURIComponent ( segment ) ;
}
} ) . join ( '/' ) ;
} ,
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 ) {
2026-04-16 16:17:34 +00:00
ErrorHandler . handle ( 'load tags' , error , false ) ; // Optional: no toast
2025-11-24 16:11:22 +00:00
}
} ,
// 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 ) {
2026-04-16 16:17:34 +00:00
ErrorHandler . handle ( 'load templates' , error , false ) ; // Optional: no toast
2025-11-26 10:28:09 +00:00
}
} ,
// Create a new note from a template
async createNoteFromTemplate ( ) {
if ( ! this . selectedTemplate || ! this . newTemplateNoteName . trim ( ) ) {
return ;
}
try {
2025-12-17 09:03:40 +00:00
// Validate the note name
const validation = FilenameValidator . validateFilename ( this . newTemplateNoteName ) ;
if ( ! validation . valid ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . getValidationErrorMessage ( validation , 'note' ) , { type : 'warning' } ) ;
2025-12-17 09:03:40 +00:00
return ;
}
2025-11-26 11:43:37 +00:00
// Determine the note path based on dropdown context
2025-12-17 09:03:40 +00:00
let notePath = validation . sanitized ;
2025-11-26 10:28:09 +00:00
if ( ! notePath . endsWith ( '.md' ) ) {
notePath += '.md' ;
}
2026-04-21 13:22:09 +00:00
const targetFolder = this . inferredNewItemTargetFolder ( ) ;
2025-11-27 13:13:39 +00:00
// 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 ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'notes.already_exists' , { name : validation . sanitized } ) , { type : 'warning' } ) ;
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 ( ) ;
2026-04-16 16:17:34 +00:00
this . toast ( error . detail || this . t ( 'templates.create_failed' ) , { type : 'error' } ) ;
2025-11-26 10:28:09 +00:00
return ;
}
const data = await response . json ( ) ;
2026-06-04 11:21:49 +00:00
this . lastUsedTemplate = this . selectedTemplate ;
try { localStorage . setItem ( 'lastUsedTemplate' , this . selectedTemplate ) ; } catch ( _ ) { }
2025-11-26 10:28:09 +00:00
// 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 ) ;
2026-02-09 17:17:12 +00:00
this . focusEditorForNewNote ( ) ;
2025-11-26 10:28:09 +00:00
} 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 ( ) ;
} ,
2025-12-16 15:55:05 +00:00
// ========================================================================
// Outline (TOC) Methods
// ========================================================================
// Extract headings from markdown content for the outline
extractOutline ( content ) {
if ( ! content ) {
this . outline = [ ] ;
2026-03-24 11:02:34 +00:00
this . backlinks = [ ] ;
2025-12-16 15:55:05 +00:00
return ;
}
const headings = [ ] ;
const lines = content . split ( '\n' ) ;
const slugCounts = { } ; // Track duplicate slugs
2025-12-18 11:01:01 +00:00
// Skip frontmatter and code blocks
2025-12-16 15:55:05 +00:00
let inFrontmatter = false ;
2025-12-18 11:01:01 +00:00
let inCodeBlock = false ;
2025-12-16 15:55:05 +00:00
for ( let i = 0 ; i < lines . length ; i ++ ) {
const line = lines [ i ] ;
// Handle frontmatter
if ( i === 0 && line . trim ( ) === '---' ) {
inFrontmatter = true ;
continue ;
}
if ( inFrontmatter ) {
if ( line . trim ( ) === '---' ) {
inFrontmatter = false ;
}
continue ;
}
2025-12-18 11:01:01 +00:00
// Handle fenced code blocks (``` or ~~~)
if ( line . trim ( ) . startsWith ( '```' ) || line . trim ( ) . startsWith ( '~~~' ) ) {
inCodeBlock = ! inCodeBlock ;
continue ;
}
if ( inCodeBlock ) {
continue ;
}
2025-12-16 15:55:05 +00:00
// Match heading lines (# to ######)
const match = line . match ( /^(#{1,6})\s+(.+)$/ ) ;
if ( match ) {
const level = match [ 1 ] . length ;
const text = match [ 2 ] . trim ( ) ;
// Generate slug (GitHub-style)
let slug = text
. toLowerCase ( )
. replace ( /[^\w\s-]/g , '' ) // Remove special chars
. replace ( /\s+/g , '-' ) // Spaces to dashes
. replace ( /-+/g , '-' ) ; // Multiple dashes to single
// Handle duplicate slugs
if ( slugCounts [ slug ] !== undefined ) {
slugCounts [ slug ] ++ ;
slug = ` ${ slug } - ${ slugCounts [ slug ] } ` ;
} else {
slugCounts [ slug ] = 0 ;
}
headings . push ( {
level ,
text ,
slug ,
line : i + 1 // 1-indexed line number
} ) ;
}
}
this . outline = headings ;
} ,
// Scroll to a heading in the editor or preview
scrollToHeading ( heading ) {
if ( this . viewMode === 'preview' || this . viewMode === 'split' ) {
// In preview/split mode, scroll the preview pane
const preview = document . querySelector ( '.markdown-preview' ) ;
if ( preview ) {
// Find the heading element by text content (more reliable than ID)
const headingElements = preview . querySelectorAll ( 'h1, h2, h3, h4, h5, h6' ) ;
for ( const el of headingElements ) {
if ( el . textContent . trim ( ) === heading . text ) {
el . scrollIntoView ( { behavior : 'smooth' , block : 'start' } ) ;
// Add a brief highlight effect
el . style . transition = 'background-color 0.3s' ;
el . style . backgroundColor = 'var(--accent-light)' ;
setTimeout ( ( ) => {
el . style . backgroundColor = '' ;
} , 1000 ) ;
return ;
}
}
}
}
if ( this . viewMode === 'edit' || this . viewMode === 'split' ) {
// In edit/split mode, scroll the editor to the line
const textarea = document . querySelector ( '.editor-textarea' ) ;
if ( textarea && heading . line ) {
const lines = textarea . value . split ( '\n' ) ;
let charPos = 0 ;
// Calculate character position of the heading line
for ( let i = 0 ; i < heading . line - 1 && i < lines . length ; i ++ ) {
charPos += lines [ i ] . length + 1 ; // +1 for newline
}
// Set cursor position and scroll
textarea . focus ( ) ;
textarea . setSelectionRange ( charPos , charPos ) ;
// Calculate scroll position (approximate)
const lineHeight = parseFloat ( getComputedStyle ( textarea ) . lineHeight ) || 24 ;
const scrollTop = ( heading . line - 1 ) * lineHeight - textarea . clientHeight / 3 ;
textarea . scrollTop = Math . max ( 0 , scrollTop ) ;
}
}
} ,
2026-03-24 11:02:34 +00:00
// Navigate to a backlink (note that links to current note)
navigateToBacklink ( backlinkPath ) {
this . loadNote ( backlinkPath ) ;
} ,
2025-12-16 15:55:05 +00:00
2025-11-24 16:11:22 +00:00
// 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 ) {
2026-02-20 07:26:52 +00:00
this . isSearching = false ;
2025-11-24 16:11:22 +00:00
this . searchResults = [ ] ;
this . currentSearchHighlight = '' ;
this . clearSearchHighlights ( ) ;
this . buildFolderTree ( ) ;
return ;
}
// Case 2: Only tag filter → convert to flat list of matching notes
if ( hasTagFilter && ! hasTextSearch ) {
2026-02-20 07:26:52 +00:00
this . isSearching = false ;
2025-11-24 16:11:22 +00:00
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 ) {
2026-02-20 07:26:52 +00:00
this . isSearching = true ;
2025-11-24 16:11:22 +00:00
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 ) ;
2026-02-20 07:26:52 +00:00
this . searchResults = [ ] ;
} finally {
this . isSearching = false ;
2025-11-24 16:11:22 +00:00
}
}
} ,
// 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 ====================
// 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)
} ,
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
2026-04-06 14:00:00 +00:00
obj . notes = [ ... obj . notes ] . sort ( this . getSortComparator ( ) ) ;
2025-11-05 16:48:41 +00:00
}
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 ) {
2026-04-06 14:00:00 +00:00
tree [ '__root__' ] . notes = [ ... tree [ '__root__' ] . notes ] . sort ( this . getSortComparator ( ) ) ;
2025-11-05 16:48:41 +00:00
}
// 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
} ,
2026-01-17 15:46:45 +00:00
// =====================================================================
// DATA-ATTRIBUTE BASED HANDLERS
// These read path/name/type from data-* attributes, avoiding JS escaping issues
// =====================================================================
// Escape strings for HTML attributes (simpler than JS escaping)
escapeHtmlAttr ( str ) {
if ( ! str ) return '' ;
return str
. replace ( /&/g , '&' )
. replace ( /"/g , '"' )
2026-01-18 09:43:20 +00:00
. replace ( /'/g , ''' )
2026-01-17 15:46:45 +00:00
. replace ( /</g , '<' )
. replace ( />/g , '>' ) ;
2026-01-17 15:34:33 +00:00
} ,
2026-01-17 15:46:45 +00:00
// Folder handlers - read from dataset
handleFolderClick ( el ) {
this . toggleFolder ( el . dataset . path ) ;
} ,
handleFolderDragOver ( el , event ) {
event . preventDefault ( ) ;
this . dragOverFolder = el . dataset . path ;
el . classList . add ( 'drag-over' ) ;
} ,
handleFolderDragLeave ( el ) {
this . dragOverFolder = null ;
el . classList . remove ( 'drag-over' ) ;
} ,
handleFolderDrop ( el , event ) {
event . stopPropagation ( ) ;
el . classList . remove ( 'drag-over' ) ;
this . onFolderDrop ( el . dataset . path ) ;
} ,
handleNewItemClick ( el , event ) {
event . stopPropagation ( ) ;
this . dropdownTargetFolder = el . dataset . path ;
this . toggleNewDropdown ( event ) ;
} ,
handleRenameFolderClick ( el , event ) {
event . stopPropagation ( ) ;
this . renameFolder ( el . dataset . path , el . dataset . name ) ;
} ,
handleDeleteFolderClick ( el , event ) {
event . stopPropagation ( ) ;
this . deleteFolder ( el . dataset . path , el . dataset . name ) ;
} ,
2026-01-19 08:58:04 +00:00
// Item (note/media) handlers - read from dataset
2026-01-17 15:46:45 +00:00
handleItemClick ( el ) {
this . openItem ( el . dataset . path , el . dataset . type ) ;
} ,
handleItemHover ( el , isEnter ) {
const path = el . dataset . path ;
2026-01-18 15:00:01 +00:00
if ( path !== this . currentNote && path !== this . currentMedia ) {
2026-01-17 15:46:45 +00:00
el . style . backgroundColor = isEnter ? 'var(--bg-hover)' : 'transparent' ;
}
} ,
handleDeleteItemClick ( el , event ) {
event . stopPropagation ( ) ;
2026-04-21 13:22:09 +00:00
if ( el . dataset . type !== 'note' ) {
2026-01-18 15:00:01 +00:00
this . deleteMedia ( el . dataset . path ) ;
2026-01-17 15:46:45 +00:00
} else {
this . deleteNote ( el . dataset . path , el . dataset . name ) ;
}
} ,
// =====================================================================
// FOLDER TREE RENDERING
// =====================================================================
2025-11-08 11:23:07 +00:00
// Render folder recursively (helper for deep nesting)
2026-01-17 15:46:45 +00:00
// Uses data-* attributes to store path/name, avoiding JS string escaping issues
2026-04-06 14:02:23 +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 ) ;
2026-01-17 15:46:45 +00:00
const esc = ( s ) => this . escapeHtmlAttr ( s ) ; // Shorthand for HTML escaping
2025-11-08 11:23:07 +00:00
2025-11-08 11:28:31 +00:00
// Render this folder's header
2026-01-17 15:46:45 +00:00
// Note: Using native event handlers with data-* attributes instead of Alpine directives
2025-12-09 11:08:12 +00:00
// because x-html doesn't process Alpine directives in dynamically generated content
2025-11-08 11:28:31 +00:00
html += `
< div >
< div
2026-01-17 15:46:45 +00:00
data - path = "${esc(folder.path)}"
data - name = "${esc(folder.name)}"
2025-11-08 11:28:31 +00:00
draggable = "true"
2026-01-19 08:58:04 +00:00
ondragstart = "window.$root.onItemDragStart(this.dataset.path, 'folder', event)"
2026-01-19 08:42:32 +00:00
ondragend = "window.$root.onItemDragEnd()"
2026-01-17 15:46:45 +00:00
ondragover = "window.$root.handleFolderDragOver(this, event)"
ondragenter = "window.$root.handleFolderDragOver(this, event)"
ondragleave = "window.$root.handleFolderDragLeave(this)"
ondrop = "window.$root.handleFolderDrop(this, event)"
onclick = "window.$root.handleFolderClick(this)"
2026-02-23 09:59:58 +00:00
class = "folder-item hover-accent 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 >
2026-01-18 17:04:32 +00:00
< span class = "flex items-center gap-1 flex-1" style = "overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; pointer-events: none;" title = "${esc(folder.name)}" >
2026-01-17 15:46:45 +00:00
< span > $ { esc ( folder . name ) } < / s p a n >
2025-12-15 14:45:10 +00:00
$ { folder . notes . length === 0 && ( ! folder . children || Object . keys ( folder . children ) . length === 0 ) ? ` <span class="text-xs" style="color: var(--text-tertiary); font-weight: 400;">( ${ this . t ( 'folders.empty' ) } )</span> ` : '' }
2025-11-08 11:28:31 +00:00
< / 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
2026-01-17 15:46:45 +00:00
data - path = "${esc(folder.path)}"
onclick = "window.$root.handleNewItemClick(this, 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);"
2026-04-20 10:17:15 +00:00
title = "${esc(this.t('sidebar.new_note'))}"
2025-11-26 11:43:37 +00:00
> + < / b u t t o n >
2025-11-08 11:28:31 +00:00
< button
2026-01-17 15:46:45 +00:00
data - path = "${esc(folder.path)}"
data - name = "${esc(folder.name)}"
onclick = "window.$root.handleRenameFolderClick(this, 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);"
2026-04-20 10:17:15 +00:00
title = "${esc(this.t('sidebar.rename_folder'))}"
2025-11-08 11:28:31 +00:00
> ✏ ️ < / b u t t o n >
< button
2026-01-17 15:46:45 +00:00
data - path = "${esc(folder.path)}"
data - name = "${esc(folder.name)}"
onclick = "window.$root.handleDeleteFolderClick(this, event)"
2025-11-08 11:28:31 +00:00
class = "px-1 py-0.5 text-xs rounded hover:brightness-110"
style = "color: var(--error);"
2026-04-20 10:17:15 +00:00
title = "${esc(this.t('sidebar.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 ) {
2026-02-23 09:59:58 +00:00
const children = Object . entries ( folder . children )
. filter ( ( [ k , v ] ) => ! this . hideUnderscoreFolders || ! v . name . startsWith ( '_' ) )
2026-04-06 14:00:00 +00:00
. sort ( ( a , b ) => this . getFolderSortComparator ( ) ( a [ 1 ] , b [ 1 ] ) ) ;
2025-11-08 11:28:31 +00:00
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 => {
2026-01-19 09:14:37 +00:00
html += this . renderNoteItem ( note ) ;
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 ;
} ,
2026-01-19 09:14:37 +00:00
// Render a single note/media item (used by both folders and root level)
renderNoteItem ( note ) {
const esc = ( s ) => this . escapeHtmlAttr ( s ) ;
const isMediaFile = note . type !== 'note' ;
const isCurrentNote = this . currentNote === note . path ;
const isCurrentMedia = this . currentMedia === note . path ;
const isCurrent = isMediaFile ? isCurrentMedia : isCurrentNote ;
// Share icon for shared notes
const isShared = ! isMediaFile && this . isNoteShared ( note . path ) ;
2026-04-22 13:38:16 +00:00
const shareIcon = isShared ? ` <svg aria-hidden="true" style="display: inline-block; width: 12px; height: 12px; vertical-align: middle; margin-right: 2px; opacity: 0.7;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d=" ${ SHARE _ICON _PATH } "></path></svg> ` : '' ;
2026-01-19 09:14:37 +00:00
const icon = this . getMediaIcon ( note . type ) ;
2026-04-21 13:22:09 +00:00
const deleteTitle = ! isMediaFile
? this . t ( 'toolbar.delete_note' )
: note . type === 'drawing'
? this . t ( 'toolbar.delete_drawing' )
: this . t ( 'toolbar.delete_image' ) ;
2026-01-19 09:14:37 +00:00
return `
< div
data - path = "${esc(note.path)}"
data - name = "${esc(note.name)}"
data - type = "${note.type}"
draggable = "true"
ondragstart = "window.$root.onItemDragStart(this.dataset.path, this.dataset.type || 'note', event)"
ondragend = "window.$root.onItemDragEnd()"
onclick = "window.$root.handleItemClick(this)"
class = "note-item px-2 py-1 text-sm relative"
style = "${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isMediaFile ? 'opacity: 0.85;' : ''} cursor: pointer;"
onmouseover = "window.$root.handleItemHover(this, true)"
onmouseout = "window.$root.handleItemHover(this, false)"
>
< span class = "truncate" style = "display: block; padding-right: 30px;" title = "${esc(note.name)}" > $ { shareIcon } $ { icon } $ { icon ? ' ' : '' } $ { esc ( note . name ) } < / s p a n >
< button
data - path = "${esc(note.path)}"
data - name = "${esc(note.name)}"
data - type = "${note.type}"
onclick = "window.$root.handleDeleteItemClick(this, event)"
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);"
2026-04-21 13:22:09 +00:00
title = "${esc(deleteTitle)}"
2026-01-19 09:14:37 +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 >
` ;
} ,
// Render root-level items (notes and media not in any folder)
2026-04-06 14:02:23 +00:00
renderRootItems ( ) {
2026-01-19 09:14:37 +00:00
const root = this . folderTree [ '__root__' ] ;
if ( ! root || ! root . notes || root . notes . length === 0 ) {
return '' ;
}
return root . notes . map ( note => this . renderNoteItem ( note ) ) . join ( '' ) ;
} ,
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
} ,
2026-01-19 08:42:32 +00:00
// Unified drag and drop handlers for notes, folders, and media
onItemDragStart ( itemPath , itemType , event ) {
2025-11-20 13:10:17 +00:00
// Set unified drag state
2026-01-19 08:42:32 +00:00
this . draggedItem = { path : itemPath , type : itemType } ;
2025-11-20 13:10:17 +00:00
2026-01-19 08:42:32 +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
} ,
2026-01-19 08:42:32 +00:00
onItemDragEnd ( ) {
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 ;
2026-01-19 08:42:32 +00:00
// Reset opacity of all draggable items
document . querySelectorAll ( '.note-item, .folder-header' ) . forEach ( el => el . style . opacity = '1' ) ;
// Reset drag-over class
2025-12-09 11:08:12 +00:00
document . querySelectorAll ( '.drag-over' ) . forEach ( el => el . classList . remove ( 'drag-over' ) ) ;
2025-11-05 16:48:41 +00:00
} ,
2026-01-19 08:42:32 +00:00
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
2026-01-15 17:19:02 +00:00
// Focus the textarea
2025-11-05 16:48:41 +00:00
const textarea = event . target ;
2026-01-15 17:19:02 +00:00
if ( textarea . tagName !== 'TEXTAREA' ) return ;
2025-11-05 16:48:41 +00:00
textarea . focus ( ) ;
2026-01-15 17:19:02 +00:00
// Calculate cursor position from mouse coordinates
const pos = this . getTextareaCursorFromPoint ( textarea , event . clientX , event . clientY ) ;
if ( pos >= 0 ) {
textarea . setSelectionRange ( pos , pos ) ;
2025-11-05 16:48:41 +00:00
}
} ,
2026-01-15 17:19:02 +00:00
// Calculate textarea cursor position from mouse coordinates
getTextareaCursorFromPoint ( textarea , x , y ) {
const rect = textarea . getBoundingClientRect ( ) ;
const style = window . getComputedStyle ( textarea ) ;
const lineHeight = parseFloat ( style . lineHeight ) || parseFloat ( style . fontSize ) * 1.2 ;
const paddingTop = parseFloat ( style . paddingTop ) || 0 ;
const paddingLeft = parseFloat ( style . paddingLeft ) || 0 ;
// Calculate which line we're on
const relativeY = y - rect . top - paddingTop + textarea . scrollTop ;
const lineIndex = Math . max ( 0 , Math . floor ( relativeY / lineHeight ) ) ;
// Split content into lines
const lines = textarea . value . split ( '\n' ) ;
// Find the character position at the start of this line
let charPos = 0 ;
for ( let i = 0 ; i < Math . min ( lineIndex , lines . length ) ; i ++ ) {
charPos += lines [ i ] . length + 1 ; // +1 for newline
}
// If we're beyond the last line, position at end
if ( lineIndex >= lines . length ) {
return textarea . value . length ;
}
// Approximate character position within the line based on X coordinate
const relativeX = x - rect . left - paddingLeft ;
const charWidth = parseFloat ( style . fontSize ) * 0.6 ; // Approximate for monospace
const charInLine = Math . max ( 0 , Math . floor ( relativeX / charWidth ) ) ;
const lineLength = lines [ lineIndex ] ? . length || 0 ;
return charPos + Math . min ( charInLine , lineLength ) ;
} ,
2025-11-05 16:48:41 +00:00
// 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
} ,
2026-01-18 15:00:01 +00:00
// Handle drop into editor to create internal link or upload media
2025-11-20 13:10:17 +00:00
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
2026-01-18 15:00:01 +00:00
// Check if files are being dropped (media from file system)
2025-11-20 13:10:17 +00:00
if ( event . dataTransfer && event . dataTransfer . files && event . dataTransfer . files . length > 0 ) {
2026-01-18 15:00:01 +00:00
await this . handleMediaDrop ( event ) ;
2025-11-20 13:10:17 +00:00
return ;
}
2025-11-05 16:48:41 +00:00
2026-01-18 15:00:01 +00:00
// Otherwise, handle note/media link drop from sidebar
2025-11-20 13:10:17 +00:00
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 ;
2026-01-18 15:00:01 +00:00
const isMediaFile = this . draggedItem . type !== 'note' ;
2025-11-05 16:48:41 +00:00
2025-11-20 13:10:17 +00:00
let link ;
2026-01-18 15:00:01 +00:00
if ( isMediaFile ) {
// For media files (images, audio, video, PDF), use wiki-style embed link
2026-01-05 11:51:24 +00:00
const filename = notePath . split ( '/' ) . pop ( ) ;
link = ` ![[ ${ filename } ]] ` ;
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
2026-01-15 17:19:02 +00:00
// Insert at drop position
2025-11-05 16:48:41 +00:00
const textarea = event . target ;
2026-01-15 17:19:02 +00:00
// Recalculate position from drop coordinates for accuracy
let cursorPos = this . getTextareaCursorFromPoint ( textarea , event . clientX , event . clientY ) ;
if ( cursorPos < 0 ) cursorPos = textarea . selectionStart || 0 ;
2025-11-05 16:48:41 +00:00
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 ;
} ,
2026-04-21 13:22:09 +00:00
/ * *
* Backend ` get_attachment_dir ` uses the parent folder of ` note_path ` .
* With no note open , infer a synthetic path from ` currentMedia ` so uploads go to the same
* ` _attachments ` folder as the file being viewed ( or vault root when appropriate ) .
* /
resolveUploadNotePath ( ) {
if ( this . currentNote ) return this . currentNote ;
if ( ! this . currentMedia ) return '' ;
const parts = this . currentMedia . split ( '/' ) . filter ( Boolean ) ;
const ai = parts . indexOf ( '_attachments' ) ;
if ( ai === - 1 || ai === 0 ) return '' ;
return ` ${ parts . slice ( 0 , ai ) . join ( '/' ) } /_.md ` ;
} ,
2026-01-18 15:00:01 +00:00
// Handle media files dropped into editor
async handleMediaDrop ( event ) {
2025-11-20 13:10:17 +00:00
const files = Array . from ( event . dataTransfer . files ) ;
2026-01-18 15:00:01 +00:00
// Filter for allowed media types
const allowedTypes = [
// Images
'image/jpeg' , 'image/jpg' , 'image/png' , 'image/gif' , 'image/webp' ,
// Audio
'audio/mpeg' , 'audio/mp3' , 'audio/wav' , 'audio/ogg' , 'audio/m4a' , 'audio/x-m4a' ,
// Video
'video/mp4' , 'video/webm' , 'video/quicktime' ,
// Documents
'application/pdf'
] ;
const mediaFiles = files . filter ( file => allowedTypes . includes ( file . type . toLowerCase ( ) ) ) ;
if ( mediaFiles . length === 0 ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'media.no_valid_files' ) , { type : 'warning' } ) ;
2025-11-20 13:10:17 +00:00
return ;
}
const textarea = event . target ;
2026-04-21 13:22:09 +00:00
const notePath = this . resolveUploadNotePath ( ) ;
// Calculate cursor position from drop coordinates (only meaningful when a note is open)
let cursorPos = 0 ;
if ( this . currentNote && textarea && textarea . tagName === 'TEXTAREA' ) {
cursorPos = this . getTextareaCursorFromPoint ( textarea , event . clientX , event . clientY ) ;
if ( cursorPos < 0 ) cursorPos = textarea . selectionStart || 0 ;
}
2025-11-20 13:10:17 +00:00
2026-04-21 13:22:09 +00:00
let uploaded = false ;
2026-01-18 15:00:01 +00:00
for ( const file of mediaFiles ) {
2025-11-20 13:10:17 +00:00
try {
2026-04-21 13:22:09 +00:00
const mediaPath = await this . uploadMedia ( file , notePath ) ;
2026-01-18 15:00:01 +00:00
if ( mediaPath ) {
2026-04-21 13:22:09 +00:00
uploaded = true ;
if ( this . currentNote ) {
await this . insertMediaMarkdown ( mediaPath , file . name , cursorPos ) ;
}
2025-11-20 13:10:17 +00:00
}
} catch ( error ) {
2026-01-18 15:00:01 +00:00
ErrorHandler . handle ( ` upload file ${ file . name } ` , error ) ;
2025-11-20 13:10:17 +00:00
}
}
2026-04-21 13:22:09 +00:00
if ( uploaded && ! this . currentNote ) {
await this . loadNotes ( ) ;
}
2025-11-20 13:10:17 +00:00
} ,
2026-01-18 15:00:01 +00:00
// Upload a media file (image, audio, video, PDF)
2026-04-21 14:00:58 +00:00
// Use options.nextToNotes + options.contentFolder to save a new drawing PNG next to .md files (not in _attachments).
async uploadMedia ( file , notePath , options = { } ) {
const { nextToNotes , contentFolder } = options ;
2025-11-20 13:10:17 +00:00
const formData = new FormData ( ) ;
formData . append ( 'file' , file ) ;
2026-04-21 14:00:58 +00:00
if ( nextToNotes ) {
formData . append ( 'next_to_notes' , '1' ) ;
formData . append ( 'content_folder' , contentFolder != null ? contentFolder : '' ) ;
formData . append ( 'note_path' , '' ) ;
} else {
formData . append ( 'note_path' , notePath || '' ) ;
}
2025-11-20 13:10:17 +00:00
try {
2026-01-18 15:00:01 +00:00
const response = await fetch ( '/api/upload-media' , {
2025-11-20 13:10:17 +00:00
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 ;
}
} ,
2026-01-18 15:00:01 +00:00
// Insert media markdown at cursor position using wiki-style syntax
// This ensures media links don't break when notes are moved
async insertMediaMarkdown ( mediaPath , altText , cursorPos ) {
2026-01-05 11:51:24 +00:00
// Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png")
2026-01-18 15:00:01 +00:00
const filename = mediaPath . split ( '/' ) . pop ( ) ;
2026-01-05 11:51:24 +00:00
2026-01-18 15:00:01 +00:00
// Use wiki-style embed link: ![[filename.png]] or ![[filename.png|alt text]]
2026-01-05 11:51:24 +00:00
// The alt text is optional - only add if different from filename
const filenameWithoutExt = filename . replace ( /\.[^/.]+$/ , '' ) ;
const altWithoutExt = altText . replace ( /\.[^/.]+$/ , '' ) ;
// If alt text is meaningful (not just "pasted-image"), include it
const markdown = ( altWithoutExt && altWithoutExt !== filenameWithoutExt && ! altWithoutExt . startsWith ( 'pasted-image' ) )
? ` ![[ ${ filename } | ${ altWithoutExt } ]] `
: ` ![[ ${ filename } ]] ` ;
2025-11-20 13:10:17 +00:00
2026-01-15 17:38:30 +00:00
// Reload notes FIRST to update image lookup maps before preview renders
await this . loadNotes ( ) ;
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 ( ) ;
} ,
2026-01-18 15:00:01 +00:00
// Handle paste event for clipboard media (images)
2025-11-20 13:10:17 +00:00
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 } ) ;
2026-01-18 15:00:01 +00:00
const mediaPath = await this . uploadMedia ( file , this . currentNote ) ;
if ( mediaPath ) {
await this . insertMediaMarkdown ( mediaPath , filename , cursorPos ) ;
2025-11-20 13:10:17 +00:00
}
} catch ( error ) {
2026-01-18 15:00:01 +00:00
ErrorHandler . handle ( 'paste media' , error ) ;
2025-11-20 13:10:17 +00:00
}
}
2026-01-18 15:00:01 +00:00
break ; // Only handle first media item
2025-11-20 13:10:17 +00:00
}
}
} ,
2026-04-21 13:22:09 +00:00
// Media type detection based on file extension (and drawing-*.png convention)
2026-01-18 15:00:01 +00:00
getMediaType ( filename ) {
if ( ! filename ) return null ;
2026-04-21 13:22:09 +00:00
const base = filename . split ( '/' ) . pop ( ) . toLowerCase ( ) ;
if ( base . startsWith ( 'drawing-' ) && base . endsWith ( '.png' ) ) {
return 'drawing' ;
}
2026-01-18 15:00:01 +00:00
const ext = filename . split ( '.' ) . pop ( ) . toLowerCase ( ) ;
const mediaTypes = {
image : [ 'jpg' , 'jpeg' , 'png' , 'gif' , 'webp' ] ,
audio : [ 'mp3' , 'wav' , 'ogg' , 'm4a' ] ,
video : [ 'mp4' , 'webm' , 'mov' , 'avi' ] ,
document : [ 'pdf' ] ,
} ;
for ( const [ type , extensions ] of Object . entries ( mediaTypes ) ) {
if ( extensions . includes ( ext ) ) return type ;
}
return null ;
} ,
// Get icon for media type
getMediaIcon ( type ) {
const icons = {
image : '🖼️' ,
2026-04-21 13:22:09 +00:00
drawing : '✏️' ,
2026-01-18 15:00:01 +00:00
audio : '🎵' ,
video : '🎬' ,
document : '📄' ,
} ;
return icons [ type ] || '' ;
} ,
// Open a note or media file (unified handler for sidebar/homepage clicks)
2025-12-04 17:05:41 +00:00
openItem ( path , type = 'note' , searchHighlight = '' ) {
this . showGraph = false ;
2026-01-18 15:00:01 +00:00
// Check if it's a media file by type or extension
const mediaType = type !== 'note' ? type : this . getMediaType ( path ) ;
if ( mediaType && mediaType !== 'note' ) {
this . viewMedia ( path , mediaType ) ;
2025-12-04 17:05:41 +00:00
} else {
this . loadNote ( path , true , searchHighlight ) ;
}
} ,
2026-01-18 15:00:01 +00:00
// View a media file (image, audio, video, PDF) in the main pane
viewMedia ( mediaPath , mediaType = null , updateHistory = true ) {
2026-04-21 13:22:09 +00:00
if ( this . currentMediaType === 'drawing' ) {
this . _drawingDisconnectResizeObserver ( ) ;
2026-04-21 14:00:58 +00:00
this . _drawingCancelAutosave ( ) ;
2026-04-21 13:22:09 +00:00
}
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 = '' ;
2026-01-18 15:00:01 +00:00
this . currentMedia = mediaPath ; // Reuse currentMedia for all media
this . currentMediaType = mediaType || this . getMediaType ( mediaPath ) || 'image' ;
2026-01-15 09:07:51 +00:00
this . shareInfo = null ; // Reset share info
2026-01-18 15:00:01 +00:00
this . viewMode = 'preview' ; // Use preview mode to show media
2025-11-20 13:10:17 +00:00
2026-01-18 15:00:01 +00:00
// Update browser tab title
const fileName = mediaPath . split ( '/' ) . pop ( ) ;
document . title = ` ${ fileName } - ${ this . appName } ` ;
2025-12-29 09:51:50 +00:00
2026-01-19 09:14:37 +00:00
// Expand folder tree to show the media file
this . expandFolderForNote ( mediaPath ) ;
2025-11-20 13:10:17 +00:00
// Update browser URL
if ( updateHistory ) {
// Encode each path segment to handle special characters
2026-01-18 15:00:01 +00:00
const encodedPath = mediaPath . split ( '/' ) . map ( segment => encodeURIComponent ( segment ) ) . join ( '/' ) ;
2025-11-20 13:10:17 +00:00
window . history . pushState (
2026-01-18 15:00:01 +00:00
{ mediaPath : mediaPath } ,
2025-11-20 13:10:17 +00:00
'' ,
` / ${ encodedPath } `
) ;
}
2026-04-21 13:22:09 +00:00
// Drawing: Alpine x-init on the canvas runs only on first mount; switching from one drawing
// to another keeps currentMediaType === 'drawing', so we must reload the PNG here.
if ( this . currentMediaType === 'drawing' ) {
this . $nextTick ( ( ) => {
this . initDrawingViewer ( ) ;
} ) ;
}
2025-11-20 13:10:17 +00:00
} ,
2026-01-18 15:00:01 +00:00
// Backward compatibility alias
viewImage ( mediaPath , updateHistory = true ) {
this . viewMedia ( mediaPath , 'image' , updateHistory ) ;
} ,
// Delete a media file (image, audio, video, PDF)
async deleteMedia ( mediaPath ) {
const filename = mediaPath . split ( '/' ) . pop ( ) ;
2026-04-17 07:39:52 +00:00
const ok = await this . confirmModalAsk ( {
message : this . t ( 'media.confirm_delete' , { name : filename } ) ,
} ) ;
if ( ! ok ) return ;
2025-11-20 13:10:17 +00:00
try {
2026-01-18 15:00:01 +00:00
const response = await fetch ( ` /api/notes/ ${ encodeURIComponent ( mediaPath ) } ` , {
2025-11-20 13:10:17 +00:00
method : 'DELETE'
} ) ;
if ( response . ok ) {
await this . loadNotes ( ) ; // Refresh tree
2026-01-18 15:00:01 +00:00
// Clear viewer if deleting currently viewed media
if ( this . currentMedia === mediaPath ) {
this . currentMedia = '' ;
2025-11-20 13:10:17 +00:00
}
} else {
2026-01-18 15:00:01 +00:00
throw new Error ( 'Failed to delete media file' ) ;
2025-11-20 13:10:17 +00:00
}
} catch ( error ) {
2026-01-18 15:00:01 +00:00
ErrorHandler . handle ( 'delete media' , error ) ;
2025-11-20 13:10:17 +00:00
}
2025-11-05 16:48:41 +00:00
} ,
2026-04-21 13:22:09 +00:00
/ * *
* Create a blank drawing PNG and open it for editing .
* Attachment folder matches "New note" / "New folder" from the same + menu ( root vs folder row vs homepage folder ) .
* /
2026-04-24 13:29:00 +00:00
/ * *
* Create a brand - new drawing PNG and open it in the editor .
* @ param { { docW ? : number , docH ? : number } } [ opts ]
* Optional intrinsic dimensions in document pixels . Phase - 2 : a "New drawing"
* dialog ( presets , A4 , custom ) only needs to pass these values here — no other
* plumbing is required because every downstream function reads doc size from
* either the loaded PNG or the in - memory drawingDocW / drawingDocH fields .
* /
async createNewDrawing ( opts = { } ) {
2026-04-21 13:22:09 +00:00
const targetFolder = this . inferredNewItemTargetFolder ( ) ;
this . closeDropdown ( ) ;
2026-04-24 13:29:00 +00:00
const w = this . _drawingClampDim ( opts . docW , CONFIG . DRAWING _DEFAULT _DOC _W ) ;
const h = this . _drawingClampDim ( opts . docH , CONFIG . DRAWING _DEFAULT _DOC _H ) ;
2026-04-21 13:22:09 +00:00
const canvas = document . createElement ( 'canvas' ) ;
canvas . width = w ;
canvas . height = h ;
const ctx = canvas . getContext ( '2d' ) ;
2026-04-24 13:29:00 +00:00
ctx . fillStyle = CONFIG . DRAWING _BACKGROUND ;
2026-04-21 13:22:09 +00:00
ctx . fillRect ( 0 , 0 , w , h ) ;
let blob ;
try {
blob = await new Promise ( ( resolve , reject ) => {
canvas . toBlob ( ( b ) => ( b ? resolve ( b ) : reject ( new Error ( 'toBlob failed' ) ) ) , 'image/png' ) ;
} ) ;
} catch ( e ) {
ErrorHandler . handle ( 'create drawing' , e ) ;
return ;
}
const file = new File ( [ blob ] , 'drawing.png' , { type : 'image/png' } ) ;
try {
2026-04-21 14:00:58 +00:00
const path = await this . uploadMedia ( file , '' , {
nextToNotes : true ,
contentFolder : targetFolder ,
} ) ;
2026-04-21 13:22:09 +00:00
await this . loadNotes ( ) ;
this . viewMedia ( path , 'drawing' ) ;
} catch ( error ) {
2026-04-21 14:00:58 +00:00
ErrorHandler . handle ( 'create drawing' , error ) ;
2026-04-21 13:22:09 +00:00
}
} ,
_drawingEncodeMediaPath ( ) {
return this . currentMedia . split ( '/' ) . map ( ( s ) => encodeURIComponent ( s ) ) . join ( '/' ) ;
} ,
2026-04-24 13:29:00 +00:00
/ * *
* Clamp a dimension to the [ MIN , MAX ] document - px range . Falls back to ` fallback `
* when value is missing , non - finite or non - positive . Always returns an integer .
* /
_drawingClampDim ( value , fallback ) {
const n = Number ( value ) ;
if ( ! Number . isFinite ( n ) || n <= 0 ) return Math . round ( fallback ) ;
const clamped = Math . min (
CONFIG . DRAWING _MAX _DOC _DIM ,
Math . max ( CONFIG . DRAWING _MIN _DOC _DIM , n )
) ;
return Math . round ( clamped ) ;
} ,
/ * *
* Convert a pointer event to DOCUMENT - space coordinates ( 0. . drawingDocW , 0. . drawingDocH ) .
* The mapping is independent of CSS size , DPR and zoom — strokes stored from this
* function render identically on any device and round - trip cleanly through PNG export .
* /
2026-04-21 13:22:09 +00:00
_drawingCanvasCoords ( event ) {
const canvas = this . _drawingCanvasEl ;
if ( ! canvas ) return { x : 0 , y : 0 } ;
const rect = canvas . getBoundingClientRect ( ) ;
2026-04-24 13:29:00 +00:00
if ( rect . width <= 0 || rect . height <= 0 ) return { x : 0 , y : 0 } ;
2026-04-21 13:22:09 +00:00
return {
2026-04-24 13:29:00 +00:00
x : ( ( event . clientX - rect . left ) / rect . width ) * this . drawingDocW ,
y : ( ( event . clientY - rect . top ) / rect . height ) * this . drawingDocH ,
2026-04-21 13:22:09 +00:00
} ;
} ,
_drawingDrawOp ( ctx , op ) {
if ( ! op ) return ;
2026-05-04 10:33:20 +00:00
if ( op . type === 'fill' ) {
if ( op . bitmap ) {
ctx . drawImage ( op . bitmap , op . x , op . y ) ;
}
return ;
}
2026-05-07 12:30:55 +00:00
if ( op . type === 'text' ) {
const text = op . text ;
if ( typeof text !== 'string' || text === '' ) return ;
ctx . save ( ) ;
ctx . fillStyle = op . color ;
ctx . font = ` ${ op . fontSize } px sans-serif ` ;
ctx . textBaseline = 'top' ;
ctx . fillText ( text , op . x , op . y ) ;
ctx . restore ( ) ;
return ;
}
2026-04-21 13:22:09 +00:00
if ( op . type === 'stroke' ) {
const pts = op . points ;
if ( ! pts || pts . length < 2 ) return ;
ctx . save ( ) ;
ctx . strokeStyle = op . color ;
ctx . lineWidth = op . lineWidth ;
ctx . lineCap = 'round' ;
ctx . lineJoin = 'round' ;
ctx . beginPath ( ) ;
ctx . moveTo ( pts [ 0 ] [ 0 ] , pts [ 0 ] [ 1 ] ) ;
for ( let i = 1 ; i < pts . length ; i ++ ) {
ctx . lineTo ( pts [ i ] [ 0 ] , pts [ i ] [ 1 ] ) ;
}
ctx . stroke ( ) ;
ctx . restore ( ) ;
return ;
}
ctx . save ( ) ;
ctx . strokeStyle = op . color ;
ctx . lineWidth = op . lineWidth ;
ctx . lineCap = 'round' ;
ctx . lineJoin = 'round' ;
if ( op . type === 'line' ) {
ctx . beginPath ( ) ;
ctx . moveTo ( op . x1 , op . y1 ) ;
ctx . lineTo ( op . x2 , op . y2 ) ;
ctx . stroke ( ) ;
} else if ( op . type === 'rect' ) {
const nx = op . w < 0 ? op . x + op . w : op . x ;
const ny = op . h < 0 ? op . y + op . h : op . y ;
ctx . strokeRect ( nx , ny , Math . abs ( op . w ) , Math . abs ( op . h ) ) ;
} else if ( op . type === 'ellipse' ) {
const nx = op . w < 0 ? op . x + op . w : op . x ;
const ny = op . h < 0 ? op . y + op . h : op . y ;
const rw = Math . abs ( op . w ) / 2 ;
const rh = Math . abs ( op . h ) / 2 ;
const cx = nx + rw ;
const cy = ny + rh ;
ctx . beginPath ( ) ;
ctx . ellipse ( cx , cy , rw , rh , 0 , 0 , Math . PI * 2 ) ;
ctx . stroke ( ) ;
}
ctx . restore ( ) ;
} ,
2026-04-24 13:29:00 +00:00
/ * *
* Repaint the visible canvas . All drawing math runs in DOCUMENT space ; a single
* setTransform call maps ( docW , docH ) → device pixels by combining the doc → display
* scale with the device pixel ratio . _drawingDrawOp itself is space - agnostic .
* /
2026-04-21 13:22:09 +00:00
drawingRedraw ( ) {
const canvas = this . _drawingCanvasEl ;
const ctx = this . _drawingCtx ;
if ( ! canvas || ! ctx ) return ;
2026-04-24 13:29:00 +00:00
const docW = this . drawingDocW ;
const docH = this . drawingDocH ;
const cssW = this . _drawingCssW ;
const cssH = this . _drawingCssH ;
const dpr = this . _drawingDpr || 1 ;
if ( ! docW || ! docH || ! cssW || ! cssH ) return ;
const sx = ( cssW / docW ) * dpr ;
const sy = ( cssH / docH ) * dpr ;
ctx . setTransform ( sx , 0 , 0 , sy , 0 , 0 ) ;
ctx . clearRect ( 0 , 0 , docW , docH ) ;
2026-04-21 15:39:22 +00:00
ctx . fillStyle = CONFIG . DRAWING _BACKGROUND ;
2026-04-24 13:29:00 +00:00
ctx . fillRect ( 0 , 0 , docW , docH ) ;
2026-04-21 13:22:09 +00:00
if ( this . _drawingBaseImage && this . _drawingBaseImage . complete ) {
2026-04-24 13:29:00 +00:00
ctx . drawImage ( this . _drawingBaseImage , 0 , 0 , docW , docH ) ;
2026-04-21 13:22:09 +00:00
}
for ( const op of this . drawingOps ) {
this . _drawingDrawOp ( ctx , op ) ;
}
if ( this . drawingDraft ) {
this . _drawingDrawOp ( ctx , this . drawingDraft ) ;
}
} ,
_drawingScheduleRedraw ( ) {
if ( this . _drawingRaf ) return ;
this . _drawingRaf = requestAnimationFrame ( ( ) => {
this . _drawingRaf = null ;
this . drawingRedraw ( ) ;
} ) ;
} ,
2026-05-04 10:33:20 +00:00
/ * *
* Render the current drawing state ( background + base image + all committed ops ) to a
* fresh off - screen canvas at the given document size . Used by drawingSave ( ) to produce
* a device - independent PNG , and by drawingFill ( ) to sample the visible state at full
* doc resolution before flood - filling . Returns the canvas , or null if a 2 D context
* can ' t be obtained .
* /
_drawingRenderToOffscreen ( docW , docH ) {
const off = document . createElement ( 'canvas' ) ;
off . width = docW ;
off . height = docH ;
const ctx = off . getContext ( '2d' ) ;
if ( ! ctx ) return null ;
ctx . fillStyle = CONFIG . DRAWING _BACKGROUND ;
ctx . fillRect ( 0 , 0 , docW , docH ) ;
if ( this . _drawingBaseImage && this . _drawingBaseImage . complete ) {
ctx . drawImage ( this . _drawingBaseImage , 0 , 0 , docW , docH ) ;
}
for ( const op of this . drawingOps ) {
this . _drawingDrawOp ( ctx , op ) ;
}
return off ;
} ,
2026-04-21 13:22:09 +00:00
_drawingDisconnectResizeObserver ( ) {
if ( this . _drawingResizeObserver ) {
try {
this . _drawingResizeObserver . disconnect ( ) ;
} catch ( _ ) {
/* ignore */
}
this . _drawingResizeObserver = null ;
}
} ,
2026-04-21 14:00:58 +00:00
_drawingCancelAutosave ( ) {
if ( this . _drawingAutosaveTimeout ) {
clearTimeout ( this . _drawingAutosaveTimeout ) ;
this . _drawingAutosaveTimeout = null ;
}
} ,
/ * *
* Debounced PNG save ( same delay as notes ) . Never runs while the primary button is held
* ( active stroke / shape ) ; pending timers are cleared when a new stroke starts .
* /
_drawingScheduleAutosave ( ) {
if ( this . currentMediaType !== 'drawing' || ! this . currentMedia ) return ;
if ( this . _drawingAutosaveTimeout ) {
clearTimeout ( this . _drawingAutosaveTimeout ) ;
this . _drawingAutosaveTimeout = null ;
}
const path = this . currentMedia ;
const pollMs = 150 ;
const attemptSave = ( ) => {
if ( this . currentMedia !== path || this . currentMediaType !== 'drawing' ) {
this . _drawingAutosaveTimeout = null ;
return ;
}
if ( this . drawingIsPointerDown ) {
this . _drawingAutosaveTimeout = setTimeout ( attemptSave , pollMs ) ;
return ;
}
this . _drawingAutosaveTimeout = null ;
2026-04-21 14:34:34 +00:00
this . drawingSave ( ) ;
2026-04-21 14:00:58 +00:00
} ;
2026-06-08 09:02:05 +00:00
this . _drawingAutosaveTimeout = setTimeout ( attemptSave , this . autosaveDelayMs ) ;
2026-04-21 14:00:58 +00:00
} ,
2026-04-24 13:29:00 +00:00
/ * *
* Size the visible canvas as a letterbox of ( drawingDocW × drawingDocH ) inside the
* available wrap , preserving aspect ratio . The display canvas may be smaller than
* the wrap on narrow viewports — that is intentional . The document space stays
* fixed ; only the on - screen viewport scales .
* /
2026-04-21 13:22:09 +00:00
_drawingLayoutCanvas ( ) {
const wrap = this . $refs . drawingCanvasWrap ;
const canvas = this . $refs . drawingCanvas ;
if ( ! wrap || ! canvas || this . currentMediaType !== 'drawing' ) return ;
2026-04-24 13:29:00 +00:00
// Available area (with sane fallbacks for the brief moment before layout settles).
let availW = wrap . clientWidth ;
let availH = wrap . clientHeight ;
if ( availW < 32 ) {
availW = Math . max ( 320 , wrap . parentElement ? wrap . parentElement . clientWidth : 320 ) ;
}
if ( availH < 32 ) {
2026-04-21 13:22:09 +00:00
const col = wrap . closest ( '.flex-1.flex.flex-col' ) || wrap . closest ( '.flex-1' ) ;
const h = col ? col . clientHeight : 0 ;
2026-04-24 13:29:00 +00:00
availH = h > 64 ? h : Math . max ( 240 , Math . floor ( ( availW || 400 ) * 0.5 ) ) ;
}
// Letterbox the document into the available area.
const docW = this . drawingDocW ;
const docH = this . drawingDocH ;
const docAspect = docW / docH ;
const wrapAspect = availW / availH ;
let cssW ;
let cssH ;
if ( wrapAspect > docAspect ) {
cssH = availH ;
cssW = cssH * docAspect ;
} else {
cssW = availW ;
cssH = cssW / docAspect ;
2026-04-21 13:22:09 +00:00
}
2026-04-24 13:29:00 +00:00
2026-04-21 13:22:09 +00:00
const dpr = window . devicePixelRatio || 1 ;
2026-04-24 13:29:00 +00:00
canvas . style . width = ` ${ cssW } px ` ;
2026-04-21 13:22:09 +00:00
canvas . style . height = ` ${ cssH } px ` ;
2026-04-24 13:29:00 +00:00
// Backing store in device pixels — capped above by availW/availH * dpr, so this
// never allocates more than the viewport could ever show. Cheap on every device.
canvas . width = Math . max ( 1 , Math . round ( cssW * dpr ) ) ;
canvas . height = Math . max ( 1 , Math . round ( cssH * dpr ) ) ;
2026-04-21 13:22:09 +00:00
this . _drawingCanvasEl = canvas ;
this . _drawingCssW = cssW ;
this . _drawingCssH = cssH ;
this . _drawingDpr = dpr ;
if ( ! this . _drawingCtx ) {
this . _drawingCtx = canvas . getContext ( '2d' ) ;
}
this . drawingRedraw ( ) ;
} ,
async initDrawingViewer ( ) {
if ( this . currentMediaType !== 'drawing' || ! this . currentMedia ) return ;
this . _drawingDisconnectResizeObserver ( ) ;
await this . $nextTick ( ) ;
if ( this . _drawingObjectURL ) {
URL . revokeObjectURL ( this . _drawingObjectURL ) ;
this . _drawingObjectURL = null ;
}
const canvas = this . $refs . drawingCanvas ;
const wrap = this . $refs . drawingCanvasWrap ;
if ( ! canvas || ! wrap ) return ;
this . _drawingCtx = canvas . getContext ( '2d' ) ;
2026-05-04 10:33:20 +00:00
this . _drawingDisposeOps ( this . drawingOps ) ;
this . _drawingDisposeOps ( this . drawingRedoStack ) ;
2026-04-21 13:22:09 +00:00
this . drawingOps = [ ] ;
this . drawingRedoStack = [ ] ;
this . drawingDraft = null ;
this . drawingIsPointerDown = false ;
2026-05-07 12:30:55 +00:00
this . drawingTextActive = false ;
this . drawingTextValue = '' ;
2026-04-21 13:22:09 +00:00
this . _drawingBaseImage = null ;
2026-04-21 15:39:22 +00:00
this . drawingHasRasterFromFile = false ;
2026-04-24 13:29:00 +00:00
// Reset to defaults; replaced by the loaded PNG's natural size below.
// For drawings created via createNewDrawing() the file already matches the defaults
// (or the size requested by a future "new drawing" dialog).
this . drawingDocW = CONFIG . DRAWING _DEFAULT _DOC _W ;
this . drawingDocH = CONFIG . DRAWING _DEFAULT _DOC _H ;
2026-04-21 13:22:09 +00:00
this . _drawingLoadToken = Symbol ( ) ;
const token = this . _drawingLoadToken ;
this . _drawingResizeObserver = new ResizeObserver ( ( ) => {
if ( this . currentMediaType !== 'drawing' || ! this . currentMedia ) return ;
this . _drawingLayoutCanvas ( ) ;
} ) ;
this . _drawingResizeObserver . observe ( wrap ) ;
requestAnimationFrame ( ( ) => {
if ( token !== this . _drawingLoadToken ) return ;
this . _drawingLayoutCanvas ( ) ;
requestAnimationFrame ( ( ) => {
if ( token !== this . _drawingLoadToken ) return ;
this . _drawingLayoutCanvas ( ) ;
} ) ;
} ) ;
try {
const enc = this . _drawingEncodeMediaPath ( ) ;
const res = await fetch ( ` /api/media/ ${ enc } ` , { credentials : 'same-origin' } ) ;
if ( ! res . ok ) throw new Error ( 'Failed to load drawing' ) ;
const blob = await res . blob ( ) ;
if ( token !== this . _drawingLoadToken ) return ;
const url = URL . createObjectURL ( blob ) ;
this . _drawingObjectURL = url ;
const img = new Image ( ) ;
await new Promise ( ( resolve , reject ) => {
img . onload = resolve ;
img . onerror = reject ;
img . src = url ;
} ) ;
if ( token !== this . _drawingLoadToken ) return ;
this . _drawingBaseImage = img ;
2026-04-21 15:39:22 +00:00
this . drawingHasRasterFromFile = true ;
2026-04-24 13:29:00 +00:00
// Adopt the PNG's intrinsic size as the document size for this drawing.
// The PNG itself is the source of truth — no sidecar metadata required.
this . drawingDocW = this . _drawingClampDim ( img . naturalWidth , CONFIG . DRAWING _DEFAULT _DOC _W ) ;
this . drawingDocH = this . _drawingClampDim ( img . naturalHeight , CONFIG . DRAWING _DEFAULT _DOC _H ) ;
2026-04-21 13:22:09 +00:00
this . _drawingLayoutCanvas ( ) ;
} catch ( e ) {
if ( token !== this . _drawingLoadToken ) return ;
ErrorHandler . handle ( 'load drawing' , e ) ;
2026-04-21 15:39:22 +00:00
this . drawingHasRasterFromFile = false ;
2026-04-21 13:22:09 +00:00
this . _drawingLayoutCanvas ( ) ;
}
} ,
2026-04-21 15:39:22 +00:00
_drawingRgbToHex ( r , g , b ) {
const h = ( n ) => {
const s = n . toString ( 16 ) ;
return s . length === 1 ? ` 0 ${ s } ` : s ;
} ;
return ` # ${ h ( r ) } ${ h ( g ) } ${ h ( b ) } ` ;
} ,
2026-05-04 10:33:20 +00:00
_drawingHexToRgb ( hex ) {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i . exec ( hex || '' ) ;
if ( ! m ) return { r : 0 , g : 0 , b : 0 } ;
return { r : parseInt ( m [ 1 ] , 16 ) , g : parseInt ( m [ 2 ] , 16 ) , b : parseInt ( m [ 3 ] , 16 ) } ;
} ,
/ * *
* Release the native bitmap memory backing any 'fill' ops in ` ops ` . Safe to call on a
* mixed list ( vector ops are skipped ) . Caller is responsible for clearing the array
* itself afterwards if needed .
* /
_drawingDisposeOps ( ops ) {
if ( ! ops ) return ;
for ( const op of ops ) {
if ( op && op . type === 'fill' && op . bitmap && typeof op . bitmap . close === 'function' ) {
try { op . bitmap . close ( ) ; } catch ( _ ) { /* ignore */ }
}
}
} ,
/ * *
* Iterative scanline flood fill on an ImageData buffer . Marks every pixel that is
* connected to ( sx , sy ) and matches the seed color exactly ( 4 - way connectivity ) .
* Returns { mask , minX , minY , maxX , maxY } describing the filled region ' s bounding
* box , or null if the seed pixel is already the same color as the fill ( so the caller
* can skip pushing a no - op onto the undo stack ) . Stack - based — never recurses , so a
* full - canvas fill at 1600 × 1000 stays well under any browser stack limit .
*
* Known optimization opportunities ( revisit only if fills feel slow in practice ) :
* 1 ) Add a ` tolerance ` parameter to soften antialiasing halos around strokes .
* 2 ) Push only the start of each new connected run above / below the current span
* instead of every cell — the textbook scanline optimization , ~ 3 – 5 × faster .
* 3 ) Replace the Array < [ x , y ] > stack with a flat Int32Array + top pointer to remove
* per - push allocations and cut GC pressure on large fills .
* /
_drawingFlood ( imgData , sx , sy , fillRGB ) {
const { data , width : W , height : H } = imgData ;
if ( sx < 0 || sy < 0 || sx >= W || sy >= H ) return null ;
const seedIdx = ( sy * W + sx ) * 4 ;
const tR = data [ seedIdx ] ;
const tG = data [ seedIdx + 1 ] ;
const tB = data [ seedIdx + 2 ] ;
const tA = data [ seedIdx + 3 ] ;
if ( tR === fillRGB . r && tG === fillRGB . g && tB === fillRGB . b && tA === 255 ) {
return null ;
}
const mask = new Uint8Array ( W * H ) ;
let minX = sx , minY = sy , maxX = sx , maxY = sy ;
const matches = ( px , py ) => {
if ( mask [ py * W + px ] ) return false ;
const i = ( py * W + px ) * 4 ;
return (
data [ i ] === tR &&
data [ i + 1 ] === tG &&
data [ i + 2 ] === tB &&
data [ i + 3 ] === tA
) ;
} ;
const stack = [ [ sx , sy ] ] ;
while ( stack . length ) {
const [ x , y ] = stack . pop ( ) ;
if ( ! matches ( x , y ) ) continue ;
let lx = x ;
while ( lx > 0 && matches ( lx - 1 , y ) ) lx -- ;
let rx = x ;
while ( rx < W - 1 && matches ( rx + 1 , y ) ) rx ++ ;
for ( let i = lx ; i <= rx ; i ++ ) {
mask [ y * W + i ] = 1 ;
}
if ( lx < minX ) minX = lx ;
if ( rx > maxX ) maxX = rx ;
if ( y < minY ) minY = y ;
if ( y > maxY ) maxY = y ;
if ( y > 0 ) {
for ( let i = lx ; i <= rx ; i ++ ) {
if ( matches ( i , y - 1 ) ) stack . push ( [ i , y - 1 ] ) ;
}
}
if ( y < H - 1 ) {
for ( let i = lx ; i <= rx ; i ++ ) {
if ( matches ( i , y + 1 ) ) stack . push ( [ i , y + 1 ] ) ;
}
}
}
return { mask , minX , minY , maxX , maxY } ;
} ,
2026-04-24 13:29:00 +00:00
/ * *
* Sample visible canvas color under the pointer ; sets drawingColor .
* Pointer coords come in document space ; we map to device pixels via the canvas ' s
* actual backing store ( which already includes both the doc → display scale and DPR ) .
* /
2026-04-21 15:39:22 +00:00
drawingSampleColor ( e ) {
const canvas = this . _drawingCanvasEl ;
const ctx = this . _drawingCtx ;
if ( ! canvas || ! ctx ) return ;
2026-04-24 13:29:00 +00:00
const docW = this . drawingDocW ;
const docH = this . drawingDocH ;
if ( ! docW || ! docH ) return ;
2026-04-21 15:39:22 +00:00
const { x , y } = this . _drawingCanvasCoords ( e ) ;
this . drawingRedraw ( ) ;
2026-04-24 13:29:00 +00:00
let ix = Math . floor ( ( x / docW ) * canvas . width ) ;
let iy = Math . floor ( ( y / docH ) * canvas . height ) ;
2026-04-21 15:39:22 +00:00
ix = Math . max ( 0 , Math . min ( ix , canvas . width - 1 ) ) ;
iy = Math . max ( 0 , Math . min ( iy , canvas . height - 1 ) ) ;
const pix = ctx . getImageData ( ix , iy , 1 , 1 ) . data ;
this . drawingColor = this . _drawingRgbToHex ( pix [ 0 ] , pix [ 1 ] , pix [ 2 ] ) ;
} ,
2026-05-04 10:33:20 +00:00
/ * *
* Paint - bucket flood fill at the click point . Renders the current state to a fresh
* off - screen canvas at document resolution , runs an iterative scanline flood fill from
* the clicked pixel , then stores only the cropped delta as a 'fill' op so the existing
* undo / redo machinery ( which just shuffles ops between drawingOps and drawingRedoStack )
* works unchanged . The cropped ImageBitmap keeps memory proportional to the filled
* area , not the canvas area .
* /
async drawingFill ( e ) {
if ( this . currentMediaType !== 'drawing' || ! this . currentMedia ) return ;
const docW = this . _drawingClampDim ( this . drawingDocW , CONFIG . DRAWING _DEFAULT _DOC _W ) ;
const docH = this . _drawingClampDim ( this . drawingDocH , CONFIG . DRAWING _DEFAULT _DOC _H ) ;
if ( ! docW || ! docH ) return ;
const { x , y } = this . _drawingCanvasCoords ( e ) ;
const sx = Math . floor ( x ) ;
const sy = Math . floor ( y ) ;
if ( sx < 0 || sy < 0 || sx >= docW || sy >= docH ) return ;
const off = this . _drawingRenderToOffscreen ( docW , docH ) ;
if ( ! off ) return ;
const offCtx = off . getContext ( '2d' ) ;
if ( ! offCtx ) return ;
const imgData = offCtx . getImageData ( 0 , 0 , docW , docH ) ;
const fillRGB = this . _drawingHexToRgb ( this . drawingColor ) ;
const result = this . _drawingFlood ( imgData , sx , sy , fillRGB ) ;
// Clicked on a pixel that's already the fill color (or out of bounds): no-op.
if ( ! result ) return ;
const w = result . maxX - result . minX + 1 ;
const h = result . maxY - result . minY + 1 ;
const overlay = new ImageData ( w , h ) ;
const ovData = overlay . data ;
const { mask } = result ;
for ( let py = 0 ; py < h ; py ++ ) {
const srcRow = ( result . minY + py ) * docW + result . minX ;
const dstRow = py * w * 4 ;
for ( let px = 0 ; px < w ; px ++ ) {
if ( mask [ srcRow + px ] ) {
const di = dstRow + px * 4 ;
ovData [ di ] = fillRGB . r ;
ovData [ di + 1 ] = fillRGB . g ;
ovData [ di + 2 ] = fillRGB . b ;
ovData [ di + 3 ] = 255 ;
}
}
}
let bitmap ;
try {
bitmap = await createImageBitmap ( overlay ) ;
} catch ( err ) {
ErrorHandler . handle ( 'drawing fill' , err ) ;
return ;
}
// Drawing may have closed (navigation, clear) while createImageBitmap awaited.
if ( this . currentMediaType !== 'drawing' || ! this . currentMedia ) {
try { bitmap . close ( ) ; } catch ( _ ) { /* ignore */ }
return ;
}
this . _drawingCancelAutosave ( ) ;
this . _drawingDisposeOps ( this . drawingRedoStack ) ;
this . drawingRedoStack = [ ] ;
this . drawingOps . push ( {
type : 'fill' ,
x : result . minX ,
y : result . minY ,
bitmap ,
} ) ;
this . drawingRedraw ( ) ;
this . _drawingScheduleAutosave ( ) ;
} ,
2026-04-21 15:39:22 +00:00
/** True when there is a loaded bitmap and/or session strokes to clear away. */
drawingClearEnabled ( ) {
if ( this . currentMediaType !== 'drawing' ) return false ;
return ! ! ( this . drawingHasRasterFromFile || ( this . drawingOps && this . drawingOps . length > 0 ) ) ;
} ,
/ * *
* Replace the in - memory drawing with a blank canvas and schedule save so the file on disk
* becomes a fresh white PNG ( same dimensions as the viewer ) . Drops the loaded raster .
* /
async drawingClear ( ) {
if ( this . currentMediaType !== 'drawing' || ! this . currentMedia ) return ;
if ( ! this . _drawingBaseImage && this . drawingOps . length === 0 ) return ;
const ok = await this . confirmModalAsk ( {
title : this . t ( 'drawing.clear_title' ) ,
message : this . t ( 'drawing.clear_confirm' ) ,
danger : true ,
confirmLabel : this . t ( 'drawing.clear' ) ,
} ) ;
if ( ! ok ) return ;
this . _drawingCancelAutosave ( ) ;
if ( this . _drawingObjectURL ) {
try {
URL . revokeObjectURL ( this . _drawingObjectURL ) ;
} catch ( _ ) {
/* ignore */
}
this . _drawingObjectURL = null ;
}
this . _drawingBaseImage = null ;
this . drawingHasRasterFromFile = false ;
this . drawingDraft = null ;
this . drawingIsPointerDown = false ;
2026-05-04 10:33:20 +00:00
this . _drawingDisposeOps ( this . drawingOps ) ;
this . _drawingDisposeOps ( this . drawingRedoStack ) ;
2026-04-21 15:39:22 +00:00
this . drawingOps = [ ] ;
this . drawingRedoStack = [ ] ;
2026-05-07 12:30:55 +00:00
this . drawingTextActive = false ;
this . drawingTextValue = '' ;
2026-04-21 15:39:22 +00:00
this . drawingRedraw ( ) ;
this . _drawingScheduleAutosave ( ) ;
} ,
2026-04-21 13:22:09 +00:00
drawingPointerDown ( e ) {
if ( this . currentMediaType !== 'drawing' || e . button !== 0 ) return ;
const canvas = this . _drawingCanvasEl ;
if ( ! canvas ) return ;
2026-04-21 15:39:22 +00:00
const tool = this . drawingTool ;
if ( tool === 'eyedropper' ) {
e . preventDefault ( ) ;
this . drawingSampleColor ( e ) ;
return ;
}
2026-05-04 10:33:20 +00:00
if ( tool === 'fill' ) {
e . preventDefault ( ) ;
this . drawingFill ( e ) ;
return ;
}
2026-05-07 12:30:55 +00:00
if ( tool === 'text' ) {
e . preventDefault ( ) ;
this . drawingTextStart ( e ) ;
return ;
}
2026-04-21 14:00:58 +00:00
this . _drawingCancelAutosave ( ) ;
2026-04-21 13:22:09 +00:00
canvas . setPointerCapture ( e . pointerId ) ;
this . _drawingPointerId = e . pointerId ;
const { x , y } = this . _drawingCanvasCoords ( e ) ;
this . drawingIsPointerDown = true ;
2026-05-04 10:33:20 +00:00
this . _drawingDisposeOps ( this . drawingRedoStack ) ;
2026-04-21 13:22:09 +00:00
this . drawingRedoStack = [ ] ;
const lw = this . drawingLineWidth ;
2026-04-21 15:39:22 +00:00
const color = tool === 'eraser' ? CONFIG . DRAWING _BACKGROUND : this . drawingColor ;
if ( tool === 'freehand' || tool === 'eraser' ) {
2026-04-21 13:22:09 +00:00
this . drawingDraft = { type : 'stroke' , color , lineWidth : lw , points : [ [ x , y ] ] } ;
} else if ( tool === 'line' ) {
this . drawingDraft = { type : 'line' , color , lineWidth : lw , x1 : x , y1 : y , x2 : x , y2 : y } ;
} else if ( tool === 'rect' ) {
this . drawingDraft = { type : 'rect' , color , lineWidth : lw , x , y , w : 0 , h : 0 } ;
} else if ( tool === 'ellipse' ) {
this . drawingDraft = { type : 'ellipse' , color , lineWidth : lw , x , y , w : 0 , h : 0 } ;
}
this . drawingRedraw ( ) ;
} ,
drawingPointerMove ( e ) {
if ( ! this . drawingIsPointerDown || this . currentMediaType !== 'drawing' ) return ;
const { x , y } = this . _drawingCanvasCoords ( e ) ;
const d = this . drawingDraft ;
if ( ! d ) return ;
if ( d . type === 'stroke' ) {
const pts = d . points ;
const last = pts [ pts . length - 1 ] ;
const dx = x - last [ 0 ] ;
const dy = y - last [ 1 ] ;
if ( dx * dx + dy * dy < 1 ) return ;
pts . push ( [ x , y ] ) ;
this . _drawingScheduleRedraw ( ) ;
return ;
}
if ( d . type === 'line' ) {
d . x2 = x ;
d . y2 = y ;
} else if ( d . type === 'rect' || d . type === 'ellipse' ) {
d . w = x - d . x ;
d . h = y - d . y ;
}
this . _drawingScheduleRedraw ( ) ;
} ,
drawingPointerUp ( e ) {
if ( ! this . drawingIsPointerDown || this . currentMediaType !== 'drawing' ) return ;
const canvas = this . _drawingCanvasEl ;
if ( canvas && this . _drawingPointerId === e . pointerId ) {
try {
canvas . releasePointerCapture ( e . pointerId ) ;
} catch ( _ ) {
/* ignore */
}
}
this . _drawingPointerId = null ;
this . drawingIsPointerDown = false ;
const d = this . drawingDraft ;
this . drawingDraft = null ;
if ( ! d ) {
this . drawingRedraw ( ) ;
return ;
}
if ( d . type === 'stroke' ) {
if ( ! d . points || d . points . length < 2 ) {
this . drawingRedraw ( ) ;
return ;
}
this . drawingOps . push ( {
type : 'stroke' ,
color : d . color ,
lineWidth : d . lineWidth ,
points : d . points . slice ( ) ,
} ) ;
} else if ( d . type === 'line' ) {
const dx = d . x2 - d . x1 ;
const dy = d . y2 - d . y1 ;
if ( dx * dx + dy * dy < 4 ) {
this . drawingRedraw ( ) ;
return ;
}
this . drawingOps . push ( {
type : 'line' ,
color : d . color ,
lineWidth : d . lineWidth ,
x1 : d . x1 ,
y1 : d . y1 ,
x2 : d . x2 ,
y2 : d . y2 ,
} ) ;
} else if ( d . type === 'rect' || d . type === 'ellipse' ) {
if ( Math . abs ( d . w ) < 2 && Math . abs ( d . h ) < 2 ) {
this . drawingRedraw ( ) ;
return ;
}
this . drawingOps . push ( {
type : d . type ,
color : d . color ,
lineWidth : d . lineWidth ,
x : d . x ,
y : d . y ,
w : d . w ,
h : d . h ,
} ) ;
}
this . drawingRedraw ( ) ;
2026-04-21 14:00:58 +00:00
this . _drawingScheduleAutosave ( ) ;
2026-04-21 13:22:09 +00:00
} ,
drawingUndo ( ) {
if ( this . drawingOps . length === 0 ) return ;
this . drawingRedoStack . push ( this . drawingOps . pop ( ) ) ;
this . drawingRedraw ( ) ;
2026-04-21 14:00:58 +00:00
this . _drawingScheduleAutosave ( ) ;
2026-04-21 13:22:09 +00:00
} ,
drawingRedo ( ) {
if ( this . drawingRedoStack . length === 0 ) return ;
this . drawingOps . push ( this . drawingRedoStack . pop ( ) ) ;
this . drawingRedraw ( ) ;
2026-04-21 14:00:58 +00:00
this . _drawingScheduleAutosave ( ) ;
2026-04-21 13:22:09 +00:00
} ,
2026-05-07 12:30:55 +00:00
/ * *
* Text tool — single - line , commit - once . Clicking the canvas spawns a floating < input >
* at the click position ; pressing Enter or losing focus rasterizes the text into a
* 'text' op ( rendered in DOC space via ctx . fillText so it auto - scales with the canvas ) .
* Esc cancels without writing . Each commit pushes one op so undo / redo handles it just
* like any other shape — no separate "edit text" mode , no DOM cleanup gymnastics .
* /
drawingTextStart ( e ) {
const canvas = this . _drawingCanvasEl ;
if ( ! canvas ) return ;
// If a previous input was still open (e.g. mobile pointerdown before blur fires),
// commit it first so we never lose typed text.
if ( this . drawingTextActive ) {
this . drawingTextCommit ( ) ;
}
// CSS coords are stored relative to the WRAP (the input's offset parent), not the
// canvas: the wrap is a flex container that centers the canvas, so when the canvas
// is narrower than the wrap there are gutters on either side that would otherwise
// shift the floating input away from the click point.
const wrap = this . $refs . drawingCanvasWrap || canvas . parentElement ;
const wrapRect = wrap . getBoundingClientRect ( ) ;
const { x : docX , y : docY } = this . _drawingCanvasCoords ( e ) ;
this . drawingTextDocX = docX ;
this . drawingTextDocY = docY ;
this . drawingTextCssX = e . clientX - wrapRect . left ;
this . drawingTextCssY = e . clientY - wrapRect . top ;
this . drawingTextDocFontSize = Math . max ( 14 , this . drawingLineWidth * 6 ) ;
this . drawingTextValue = '' ;
this . drawingTextActive = true ;
this . drawingDraft = null ;
this . $nextTick ( ( ) => {
const el = document . getElementById ( 'drawing-text-input' ) ;
if ( ! el ) return ;
// Wipe any leftover text from a previous session before focusing — contenteditable
// doesn't auto-clear from setting drawingTextValue alone.
el . textContent = '' ;
el . focus ( ) ;
} ) ;
} ,
2026-05-11 08:52:15 +00:00
/ * *
* Normalize text - tool input : ctx . fillText doesn ' t honor line breaks ( renders them
* as literal LF glyphs / tofu ) , and Firefox treats contenteditable = "plaintext-only"
* as plain "true" so multi - line paste sneaks newlines into textContent . Also clamp
* length defensively so a giant paste can ' t bloat the op / autosave payload .
* /
_drawingTextSanitize ( s ) {
if ( typeof s !== 'string' || s . length === 0 ) return '' ;
const collapsed = s . replace ( /[\r\n\t\v\f]+/g , ' ' ) ;
return collapsed . length > 1024 ? collapsed . slice ( 0 , 1024 ) : collapsed ;
} ,
2026-05-07 12:30:55 +00:00
/ * *
* Live - preview handler for the text tool . The contenteditable div is invisible
* ( color : transparent ) , so the only thing the user actually SEES of their typing
* is whatever drawingRedraw paints from drawingDraft . Pushing the in - progress
* text into drawingDraft means the live preview goes through the very same
* ctx . fillText call path as the final commit — guaranteeing zero pixel drift .
* /
drawingTextOnInput ( e ) {
if ( ! this . drawingTextActive ) return ;
2026-05-11 08:52:15 +00:00
const text = this . _drawingTextSanitize ( e . target . textContent || '' ) ;
2026-05-07 12:30:55 +00:00
this . drawingTextValue = text ;
if ( text . length === 0 ) {
this . drawingDraft = null ;
} else {
this . drawingDraft = {
type : 'text' ,
color : this . drawingColor ,
x : this . drawingTextDocX ,
y : this . drawingTextDocY ,
text ,
fontSize : this . drawingTextDocFontSize ,
} ;
}
this . _drawingScheduleRedraw ( ) ;
} ,
drawingTextCommit ( ) {
if ( ! this . drawingTextActive ) return ;
// Read straight from the DOM so we capture the very last keystroke even if the
// @input event hasn't fired yet (it usually has, but Enter can race it).
const el = document . getElementById ( 'drawing-text-input' ) ;
const raw = el ? ( el . textContent || '' ) : ( this . drawingTextValue || '' ) ;
2026-05-11 08:52:15 +00:00
const text = this . _drawingTextSanitize ( raw ) . trim ( ) ;
2026-05-07 12:30:55 +00:00
const docX = this . drawingTextDocX ;
const docY = this . drawingTextDocY ;
const fontSize = this . drawingTextDocFontSize ;
const color = this . drawingColor ;
this . drawingTextActive = false ;
this . drawingTextValue = '' ;
this . drawingDraft = null ;
if ( el ) el . textContent = '' ;
if ( ! text ) {
this . drawingRedraw ( ) ;
return ;
}
this . _drawingDisposeOps ( this . drawingRedoStack ) ;
this . drawingRedoStack = [ ] ;
this . drawingOps . push ( {
type : 'text' ,
color ,
x : docX ,
y : docY ,
text ,
fontSize ,
} ) ;
this . drawingRedraw ( ) ;
this . _drawingScheduleAutosave ( ) ;
} ,
drawingTextCancel ( ) {
if ( ! this . drawingTextActive ) return ;
this . drawingTextActive = false ;
this . drawingTextValue = '' ;
this . drawingDraft = null ;
const el = document . getElementById ( 'drawing-text-input' ) ;
if ( el ) el . textContent = '' ;
this . drawingRedraw ( ) ;
} ,
2026-04-21 14:34:34 +00:00
/ * *
2026-04-21 15:39:22 +00:00
* Persist the flattened canvas to disk ( Ctrl + S , autosave ) . Same feedback as saveNote :
2026-04-21 14:34:34 +00:00
* header "Saved" only — never clears stroke undo / redo or reloads the image ; stacks reset when
* opening another drawing via initDrawingViewer ( ) .
* /
async drawingSave ( ) {
2026-04-21 13:22:09 +00:00
if ( ! this . currentMedia || this . currentMediaType !== 'drawing' ) return ;
2026-05-11 08:52:15 +00:00
// Commit any in-progress text BEFORE the in-flight guard so the typed value
// makes it into drawingOps before the next line wipes drawingDraft. Otherwise
// Ctrl+S (or autosave) mid-typing would silently drop whatever the user just
// typed from the saved PNG. drawingTextCommit will trigger another autosave,
// but that's harmless — the next save just re-uploads the same bytes.
if ( this . drawingTextActive ) {
this . drawingTextCommit ( ) ;
}
2026-04-21 16:00:50 +00:00
if ( this . _drawingSaveInFlight ) {
this . _drawingSaveQueued = true ;
return ;
}
this . _drawingSaveInFlight = true ;
2026-04-21 13:22:09 +00:00
try {
2026-04-21 16:00:50 +00:00
this . _drawingCancelAutosave ( ) ;
this . drawingDraft = null ;
this . drawingIsPointerDown = false ;
2026-04-24 13:29:00 +00:00
// Render onto an off-screen canvas at the exact document size. This makes the
// exported PNG byte-deterministic across devices: same ops, same dimensions →
// identical bytes regardless of DPR, window size, or zoom level. The off-screen
// canvas is allocated per save (microseconds for 1600× 1000) so dimension changes
// from a future "resize drawing" feature can never leak between saves.
const docW = this . _drawingClampDim ( this . drawingDocW , CONFIG . DRAWING _DEFAULT _DOC _W ) ;
const docH = this . _drawingClampDim ( this . drawingDocH , CONFIG . DRAWING _DEFAULT _DOC _H ) ;
2026-05-04 10:33:20 +00:00
const off = this . _drawingRenderToOffscreen ( docW , docH ) ;
if ( ! off ) return ;
2026-04-24 13:29:00 +00:00
// Keep the visible canvas in sync (cosmetic; UI doesn't have to wait for the upload).
2026-04-21 16:00:50 +00:00
this . drawingRedraw ( ) ;
const blob = await new Promise ( ( resolve , reject ) => {
2026-04-24 13:29:00 +00:00
off . toBlob ( ( b ) => ( b ? resolve ( b ) : reject ( new Error ( 'toBlob failed' ) ) ) , 'image/png' ) ;
2026-04-21 13:22:09 +00:00
} ) ;
2026-04-21 16:00:50 +00:00
this . isSaving = true ;
try {
const enc = this . _drawingEncodeMediaPath ( ) ;
const res = await fetch ( ` /api/media/ ${ enc } ` , {
method : 'PUT' ,
body : blob ,
headers : { 'Content-Type' : 'image/png' } ,
credentials : 'same-origin' ,
} ) ;
if ( ! res . ok ) {
const err = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
let detail = err . detail ;
if ( Array . isArray ( detail ) ) {
detail = detail . map ( ( d ) => d . msg || d ) . join ( ', ' ) ;
}
throw new Error ( detail || res . statusText ) ;
2026-04-21 13:22:09 +00:00
}
2026-04-21 16:00:50 +00:00
await this . loadNotes ( ) ;
this . lastSaved = true ;
setTimeout ( ( ) => {
this . lastSaved = false ;
} , CONFIG . SAVE _INDICATOR _DURATION ) ;
} catch ( error ) {
ErrorHandler . handle ( 'save drawing' , error ) ;
} finally {
this . isSaving = false ;
2026-04-21 13:22:09 +00:00
}
2026-04-21 14:00:58 +00:00
} finally {
2026-04-21 16:00:50 +00:00
this . _drawingSaveInFlight = false ;
if ( this . _drawingSaveQueued ) {
this . _drawingSaveQueued = false ;
queueMicrotask ( ( ) => {
if ( this . currentMedia && this . currentMediaType === 'drawing' ) {
this . drawingSave ( ) ;
}
} ) ;
}
2026-04-21 13:22:09 +00:00
}
} ,
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 ;
2026-01-18 18:04:05 +00:00
// Check if it's an external link or API path (media files, etc.)
2026-04-06 14:30:27 +00:00
// Safe external protocols: http, https, mailto, tel, ssh, ftp, sftp, and app deep links
const externalProtocols = [ 'http://' , 'https://' , '//' , 'mailto:' , 'tel:' , 'ssh:' , 'ftp:' , 'sftp:' , 'slack:' , 'discord:' , 'teams:' , 'vscode:' , 'zoom:' , 'whatsapp:' , 'telegram:' , 'signal:' , 'spotify:' , 'steam:' , 'magnet:' , '/api/' ] ;
if ( externalProtocols . some ( p => href . startsWith ( p ) ) ) {
2026-01-18 18:04:05 +00:00
return ; // Let external links and API paths work normally
2025-11-05 16:48:41 +00:00
}
// Prevent default navigation for internal links
event . preventDefault ( ) ;
2025-12-16 16:09:36 +00:00
// Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section")
const decodedHref = decodeURIComponent ( href ) ;
const hashIndex = decodedHref . indexOf ( '#' ) ;
const notePath = hashIndex !== - 1 ? decodedHref . substring ( 0 , hashIndex ) : decodedHref ;
const anchor = hashIndex !== - 1 ? decodedHref . substring ( hashIndex + 1 ) : null ;
// If it's just an anchor link (#heading), scroll within current note
if ( ! notePath && anchor ) {
this . scrollToAnchor ( anchor ) ;
return ;
}
2025-11-05 16:48:41 +00:00
2025-12-16 16:09:36 +00:00
// Skip if no path
2025-11-05 16:48:41 +00:00
if ( ! notePath ) return ;
2025-12-03 15:00:25 +00:00
// Find the note by path (try exact match first, then with .md extension)
2025-12-16 16:09:36 +00:00
let targetNote = this . notes . find ( n =>
2025-12-03 15:00:25 +00:00
n . path === notePath ||
n . path === notePath + '.md'
) ;
2025-12-16 16:09:36 +00:00
if ( ! targetNote ) {
2025-12-03 15:00:25 +00:00
// Try to find by name (in case link uses just the note name without path)
2025-12-16 16:09:36 +00:00
targetNote = this . notes . find ( n =>
2025-12-03 15:00:25 +00:00
n . name === notePath ||
n . name === notePath + '.md' ||
n . name . toLowerCase ( ) === notePath . toLowerCase ( ) ||
n . name . toLowerCase ( ) === ( notePath + '.md' ) . toLowerCase ( )
) ;
2025-12-16 16:09:36 +00:00
}
if ( ! targetNote ) {
// Last resort: case-insensitive path matching
targetNote = this . notes . find ( n =>
n . path . toLowerCase ( ) === notePath . toLowerCase ( ) ||
n . path . toLowerCase ( ) === ( notePath + '.md' ) . toLowerCase ( )
) ;
}
if ( targetNote ) {
// Load the note, then scroll to anchor if present
this . loadNote ( targetNote . path ) . then ( ( ) => {
if ( anchor ) {
// Small delay to ensure content is rendered
setTimeout ( ( ) => this . scrollToAnchor ( anchor ) , 100 ) ;
2025-12-03 15:00:25 +00:00
}
2025-12-16 16:09:36 +00:00
} ) ;
2026-04-17 07:39:52 +00:00
} else {
this . confirmModalAsk ( {
message : this . t ( 'notes.create_from_link' , { path : notePath } ) ,
danger : false ,
title : this . t ( 'sidebar.new_note' ) ,
confirmLabel : this . t ( 'common.create' ) ,
} ) . then ( ( ok ) => {
if ( ok ) this . createNote ( null , notePath ) ;
} ) ;
2025-12-16 16:09:36 +00:00
}
} ,
// Scroll to an anchor (heading) by slug - reuses outline data
scrollToAnchor ( anchor ) {
// Normalize the anchor (GitHub-style slug)
const targetSlug = anchor
. toLowerCase ( )
. replace ( /[^\w\s-]/g , '' )
. replace ( /\s+/g , '-' )
. replace ( /-+/g , '-' ) ;
// Find matching heading in outline
const heading = this . outline . find ( h => h . slug === targetSlug ) ;
if ( heading ) {
this . scrollToHeading ( heading ) ;
} else {
// Fallback: try to find heading by exact text match
const headingByText = this . outline . find ( h =>
h . text . toLowerCase ( ) . replace ( /\s+/g , '-' ) === anchor . toLowerCase ( )
) ;
if ( headingByText ) {
this . scrollToHeading ( headingByText ) ;
2025-11-05 16:48:41 +00:00
}
}
} ,
2025-12-09 11:08:12 +00:00
cancelDrag ( ) {
// Cancel any active drag operation (triggered by ESC key)
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 ;
}
2026-01-19 08:42:32 +00:00
// Capture dragged item info immediately (ondragend may clear it)
if ( ! this . draggedItem ) return ;
const { path : draggedPath , type : draggedType } = this . draggedItem ;
2025-12-15 11:18:14 +00:00
2026-01-19 08:42:32 +00:00
// Determine item category for endpoint selection
const isFolder = draggedType === 'folder' ;
const isNote = draggedType === 'note' ;
const isMedia = ! isFolder && ! isNote ; // image, audio, video, document
2025-11-05 16:48:41 +00:00
2026-01-19 08:42:32 +00:00
// Handle folder drop
if ( isFolder ) {
2025-11-05 16:48:41 +00:00
// Prevent dropping folder into itself or its subfolders
2026-01-19 08:42:32 +00:00
if ( targetFolderPath === draggedPath ||
targetFolderPath . startsWith ( draggedPath + '/' ) ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'folders.cannot_move_into_self' ) , { type : 'warning' } ) ;
2025-11-05 16:48:41 +00:00
return ;
}
2026-01-19 08:42:32 +00:00
const folderName = draggedPath . split ( '/' ) . pop ( ) ;
2025-11-05 16:48:41 +00:00
const newPath = targetFolderPath ? ` ${ targetFolderPath } / ${ folderName } ` : folderName ;
2026-01-19 08:42:32 +00:00
if ( newPath === draggedPath ) return ;
2025-11-05 16:48:41 +00:00
2025-12-15 11:18:14 +00:00
// Capture favorites info before async call
2026-01-19 08:42:32 +00:00
const oldPrefix = draggedPath + '/' ;
2025-12-15 11:18:14 +00:00
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' } ,
2026-01-19 08:42:32 +00:00
body : JSON . stringify ( { oldPath : draggedPath , newPath } )
2025-11-05 16:48:41 +00:00
} ) ;
if ( response . ok ) {
2026-01-19 08:42:32 +00:00
// Update favorites for notes inside moved folder
const favoritesInFolder = this . favorites . filter ( f => f . startsWith ( oldPrefix ) ) ;
if ( favoritesInFolder . length > 0 ) {
const newFavorites = this . favorites . map ( f =>
f . startsWith ( oldPrefix ) ? newPrefix + f . substring ( oldPrefix . length ) : f
) ;
2025-12-15 11:18:14 +00:00
this . favorites = newFavorites ;
this . favoritesSet = new Set ( newFavorites ) ;
this . saveFavorites ( ) ;
}
2026-01-19 08:42:32 +00:00
// Keep folder expanded if it was
const wasExpanded = this . expandedFolders . has ( draggedPath ) ;
2025-11-05 16:48:41 +00:00
await this . loadNotes ( ) ;
2026-01-19 08:42:32 +00:00
await this . loadSharedNotePaths ( ) ;
if ( wasExpanded ) {
this . expandedFolders . delete ( draggedPath ) ;
this . expandedFolders . add ( newPath ) ;
this . saveExpandedFolders ( ) ;
2025-11-05 16:48:41 +00:00
}
} else {
2025-12-15 11:18:14 +00:00
const errorData = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
2026-04-16 16:17:34 +00:00
this . toast ( errorData . detail || this . t ( 'move.failed_folder' ) , { type : 'error' } ) ;
2025-11-05 16:48:41 +00:00
}
} catch ( error ) {
console . error ( 'Failed to move folder:' , error ) ;
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'move.failed_folder' ) , { type : 'error' } ) ;
2025-11-05 16:48:41 +00:00
}
2026-01-19 08:42:32 +00:00
return ;
}
// Handle note or media drop into folder
const item = this . notes . find ( n => n . path === draggedPath ) ;
if ( ! item ) return ;
const filename = draggedPath . split ( '/' ) . pop ( ) ;
const newPath = targetFolderPath ? ` ${ targetFolderPath } / ${ filename } ` : filename ;
if ( newPath === draggedPath ) return ;
// Check if note is favorited (only for notes)
const wasFavorited = isNote && this . favoritesSet . has ( draggedPath ) ;
try {
// Use different endpoint for media vs notes
const endpoint = isMedia ? '/api/media/move' : '/api/notes/move' ;
const response = await fetch ( endpoint , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { oldPath : draggedPath , newPath } )
} ) ;
if ( response . ok ) {
// Update favorites if the moved note was favorited
if ( wasFavorited ) {
const newFavorites = this . favorites . map ( f => f === draggedPath ? newPath : f ) ;
this . favorites = newFavorites ;
this . favoritesSet = new Set ( newFavorites ) ;
this . saveFavorites ( ) ;
}
// Keep current item open if it was the moved one
const wasCurrentNote = this . currentNote === draggedPath ;
const wasCurrentMedia = this . currentMedia === draggedPath ;
await this . loadNotes ( ) ;
if ( isNote ) {
await this . loadSharedNotePaths ( ) ;
}
if ( wasCurrentNote ) this . currentNote = newPath ;
if ( wasCurrentMedia ) this . currentMedia = newPath ;
} else {
const errorData = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note' ;
2026-04-16 16:17:34 +00:00
this . toast ( errorData . detail || this . t ( errorKey ) , { type : 'error' } ) ;
2026-01-19 08:42:32 +00:00
}
} catch ( error ) {
console . error ( ` Failed to move ${ isMedia ? 'media' : 'note' } : ` , error ) ;
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note' ;
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( errorKey ) , { type : 'error' } ) ;
2025-11-05 16:48:41 +00:00
}
} ,
2026-01-19 08:42:32 +00:00
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 ;
2026-05-04 17:50:38 +00:00
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 = '' ;
2026-04-21 13:22:09 +00:00
if ( this . currentMediaType === 'drawing' ) {
this . _drawingDisconnectResizeObserver ( ) ;
}
2026-01-18 15:00:01 +00:00
this . currentMedia = '' ;
2026-01-05 10:05:08 +00:00
document . title = this . appName ;
2025-11-05 16:48:41 +00:00
return ;
}
throw new Error ( ` HTTP error! status: ${ response . status } ` ) ;
}
const data = await response . json ( ) ;
2026-05-04 17:50:38 +00:00
2025-11-05 16:48:41 +00:00
this . currentNote = notePath ;
2025-12-12 17:11:29 +00:00
this . _lastRenderedContent = '' ; // Clear render cache for new note
2026-04-13 14:11:55 +00:00
this . _lastRenderedNote = '' ;
2025-12-12 17:11:29 +00:00
this . _cachedRenderedHTML = '' ;
2026-01-18 16:32:22 +00:00
this . _initializedVideoSources = new Set ( ) ; // Clear video cache for new note
2025-11-05 16:48:41 +00:00
this . noteContent = data . content ;
2026-05-04 17:50:38 +00:00
// Note: scroll restoration happens later in the post-$nextTick block below
// (where the existing scrollToTop call used to live), via _restoreNoteScroll().
// Doing it there means a single, unified restore path instead of two racing
// ones. See the post-$nextTick block for the full timing rationale.
2025-11-05 16:48:41 +00:00
this . currentNoteName = notePath . split ( '/' ) . pop ( ) . replace ( '.md' , '' ) ;
2026-04-21 13:22:09 +00:00
if ( this . currentMediaType === 'drawing' ) {
this . _drawingDisconnectResizeObserver ( ) ;
}
2026-01-18 15:00:01 +00:00
this . currentMedia = '' ; // Clear image viewer when loading a note
2026-01-15 09:07:51 +00:00
this . shareInfo = null ; // Reset share info for new note
2025-12-29 09:51:50 +00:00
// Update browser tab title
2026-01-05 10:05:08 +00:00
document . title = ` ${ this . currentNoteName } - ${ this . appName } ` ;
2025-11-05 16:48:41 +00:00
this . lastSaved = false ;
2025-12-16 15:55:05 +00:00
// Extract outline for TOC panel
this . extractOutline ( data . content ) ;
2026-03-24 11:02:34 +00:00
// Store backlinks from API response
this . backlinks = data . backlinks || [ ] ;
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 = [ ] ;
2025-12-18 08:50:08 +00:00
this . hasPendingHistoryChanges = false ;
2025-11-05 16:48:41 +00:00
// 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 ( ) ;
2026-05-04 17:50:38 +00:00
// First pass: editor is ready (textarea reflows synchronously). Preview
// may still have scrollHeight=0 because the markdown render pipeline
// (marked + MathJax + Mermaid + syntax highlighting) is async.
this . _restoreNoteScroll ( ) ;
// Second pass on a later frame: by now the preview pane has typically
// rendered and laid out, so its scrollTop write actually sticks. The
// helper is idempotent — re-running for the editor is a no-op.
requestAnimationFrame ( ( ) => {
requestAnimationFrame ( ( ) => {
this . _restoreNoteScroll ( ) ;
} ) ;
} ) ;
2025-11-05 16:48:41 +00:00
// 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 ) ;
}
} ,
2026-01-19 09:14:37 +00:00
// Load item (note or media) from URL path
loadItemFromURL ( ) {
// Get path from URL (e.g., /folder/note or /folder/image.png)
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 ) ) ;
2026-01-19 09:14:37 +00:00
// Check if this is a media file (image, audio, video, PDF)
2025-11-20 13:10:17 +00:00
const matchedItem = this . notes . find ( n => n . path === decodedPath ) ;
2025-11-05 16:48:41 +00:00
2026-01-19 09:14:37 +00:00
if ( matchedItem && matchedItem . type !== 'note' ) {
// It's a media file, view it
this . viewMedia ( decodedPath , matchedItem . type , false ) ; // false = don't update history
2025-11-20 13:10:17 +00:00
} 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
// =====================================================
2026-06-04 12:24:03 +00:00
// Shift+click always forces the chooser regardless of newButtonAction.
2025-11-26 13:38:47 +00:00
toggleNewDropdown ( event ) {
2026-06-04 11:21:49 +00:00
const wantsChooser =
( event && event . shiftKey ) ||
! this . newButtonAction ||
this . newButtonAction === 'chooser' ;
if ( wantsChooser ) {
this . _openNewDropdownChooser ( event ) ;
return ;
}
switch ( this . newButtonAction ) {
case 'note' : this . createNote ( ) ; break ;
case 'folder' : this . createFolder ( ) ; break ;
case 'template' : this . openTemplateModal ( ) ; break ;
case 'drawing' : this . createNewDrawing ( ) ; break ;
default : this . _openNewDropdownChooser ( event ) ;
}
} ,
_openNewDropdownChooser ( event ) {
this . showNewDropdown = true ;
2025-11-26 13:38:47 +00:00
if ( event && event . target ) {
const rect = event . target . getBoundingClientRect ( ) ;
2026-06-04 11:21:49 +00:00
let top = rect . bottom + 4 ;
2025-11-26 13:38:47 +00:00
let left = rect . left ;
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
} ,
2026-06-04 11:21:49 +00:00
openTemplateModal ( ) {
this . showNewDropdown = false ;
this . mobileSidebarOpen = false ;
const hasLastUsed = ! ! this . lastUsedTemplate &&
this . availableTemplates . some ( t => t . name === this . lastUsedTemplate ) ;
this . selectedTemplate = hasLastUsed ? this . lastUsedTemplate : '' ;
this . newTemplateNoteName = this . autoFillNoteTitle ? this . _autoTitleTimestamp ( ) : '' ;
this . showTemplateModal = true ;
2026-06-04 12:24:03 +00:00
// Only steal focus when the modal is one-Enter-away from submission.
2026-06-04 11:21:49 +00:00
if ( hasLastUsed ) {
this . $nextTick ( ( ) => {
const el = document . getElementById ( 'template-note-name-input' ) ;
if ( el ) {
el . focus ( ) ;
el . select ( ) ;
}
} ) ;
}
} ,
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
} ,
2026-04-21 13:22:09 +00:00
/ * *
* Parent folder for new note / folder / drawing from the + menu when no explicit path is passed .
* Same rules as the create - name modal : '' = vault root ; otherwise a folder path ( e . g . folder1 / sub ) .
* /
inferredNewItemTargetFolder ( ) {
if ( this . dropdownTargetFolder !== null && this . dropdownTargetFolder !== undefined ) {
return this . dropdownTargetFolder ;
}
return this . selectedHomepageFolder || '' ;
} ,
2025-11-17 13:20:57 +00:00
// =====================================================
// UNIFIED CREATION FUNCTIONS (reusable from anywhere)
// =====================================================
2026-02-09 17:17:12 +00:00
// Switch to split view (if in preview-only mode) and focus editor for new notes
focusEditorForNewNote ( ) {
// Only switch if in preview-only mode - don't disturb edit or split mode
if ( this . viewMode === 'preview' ) {
this . viewMode = 'split' ;
this . saveViewMode ( ) ;
}
// Focus the editor after a short delay to ensure DOM is updated
this . $nextTick ( ( ) => {
const editor = document . getElementById ( 'note-editor' ) ;
if ( editor ) editor . focus ( ) ;
} ) ;
} ,
2025-12-19 14:52:29 +00:00
async createNote ( folderPath = null , directPath = null ) {
if ( directPath ) {
2026-04-17 07:27:36 +00:00
const notePath = directPath . endsWith ( '.md' ) ? directPath : ` ${ directPath } .md ` ;
const existingNote = this . notes . find ( note => note . path === notePath ) ;
if ( existingNote ) {
this . toast ( this . t ( 'notes.already_exists' , { name : notePath } ) , { type : 'warning' } ) ;
return ;
}
await this . _finalizeCreateNote ( notePath ) ;
return ;
}
const explicitTarget = folderPath !== null && folderPath !== undefined ? folderPath : undefined ;
this . openCreateNameModal ( 'note' , explicitTarget ) ;
} ,
/ * *
* @ param { 'note' | 'folder' } kind
* @ param { string | undefined } explicitTargetFolder - if set , use as parent folder context ( "" = root )
* /
openCreateNameModal ( kind , explicitTargetFolder = undefined ) {
2026-04-21 13:22:09 +00:00
const targetFolder =
explicitTargetFolder !== undefined ? explicitTargetFolder : this . inferredNewItemTargetFolder ( ) ;
2026-04-17 07:27:36 +00:00
this . closeDropdown ( ) ;
this . mobileSidebarOpen = false ;
this . createNameModalKind = kind ;
this . createNameModalTargetFolder = targetFolder ;
2026-06-04 11:21:49 +00:00
this . createNameModalInput =
( kind === 'note' && this . autoFillNoteTitle ) ? this . _autoTitleTimestamp ( ) : '' ;
2026-04-17 07:27:36 +00:00
this . showCreateNameModal = true ;
this . $nextTick ( ( ) => {
const el = document . getElementById ( 'create-name-modal-input' ) ;
if ( el ) {
el . focus ( ) ;
el . select ( ) ;
2025-12-19 14:52:29 +00:00
}
2026-04-17 07:27:36 +00:00
} ) ;
} ,
2026-06-04 12:24:03 +00:00
// Zettelkasten-style yyyymmddHHMMSS in local time.
2026-06-04 11:21:49 +00:00
_autoTitleTimestamp ( ) {
const d = new Date ( ) ;
const pad = ( n ) => String ( n ) . padStart ( 2 , '0' ) ;
return ` ${ d . getFullYear ( ) } ${ pad ( d . getMonth ( ) + 1 ) } ${ pad ( d . getDate ( ) ) } ` +
` ${ pad ( d . getHours ( ) ) } ${ pad ( d . getMinutes ( ) ) } ${ pad ( d . getSeconds ( ) ) } ` ;
} ,
2026-04-17 07:27:36 +00:00
closeCreateNameModal ( ) {
this . showCreateNameModal = false ;
this . createNameModalInput = '' ;
} ,
cancelCreateNameModal ( ) {
this . closeCreateNameModal ( ) ;
} ,
createNameModalHelpText ( ) {
const tf = this . createNameModalTargetFolder ;
if ( this . createNameModalKind === 'note' ) {
return tf
? this . t ( 'notes.prompt_name_in_folder' , { folder : tf } )
2025-12-19 14:52:29 +00:00
: this . t ( 'notes.prompt_name_with_path' ) ;
2026-04-17 07:27:36 +00:00
}
return tf
? this . t ( 'folders.prompt_name_in_folder' , { folder : tf } )
: this . t ( 'folders.prompt_name_with_path' ) ;
} ,
createNameModalTitle ( ) {
return this . createNameModalKind === 'note'
? this . t ( 'sidebar.new_note' )
: this . t ( 'sidebar.new_folder' ) ;
} ,
createNameModalLabel ( ) {
return this . createNameModalKind === 'note'
2026-04-20 10:45:41 +00:00
? this . t ( 'notes.prompt_name' )
2026-04-17 07:27:36 +00:00
: this . t ( 'folders.prompt_name' ) ;
} ,
async submitCreateNameModal ( ) {
const rawName = ( this . createNameModalInput || '' ) . trim ( ) ;
if ( ! rawName ) {
this . closeCreateNameModal ( ) ;
return ;
}
const targetFolder = this . createNameModalTargetFolder ;
const kind = this . createNameModalKind ;
if ( kind === 'note' ) {
const validation = targetFolder
? FilenameValidator . validateFilename ( rawName )
: FilenameValidator . validatePath ( rawName ) ;
2025-12-19 14:52:29 +00:00
if ( ! validation . valid ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . getValidationErrorMessage ( validation , 'note' ) , { type : 'warning' } ) ;
2025-12-19 14:52:29 +00:00
return ;
}
const validatedName = validation . sanitized ;
2026-04-17 07:27:36 +00:00
const notePath = targetFolder
? ` ${ targetFolder } / ${ validatedName } .md `
: ( validatedName . endsWith ( '.md' ) ? validatedName : ` ${ validatedName } .md ` ) ;
const existingNote = this . notes . find ( note => note . path === notePath ) ;
if ( existingNote ) {
this . toast ( this . t ( 'notes.already_exists' , { name : notePath } ) , { type : 'warning' } ) ;
return ;
2025-12-19 14:52:29 +00:00
}
2026-04-17 07:27:36 +00:00
const ok = await this . _finalizeCreateNote ( notePath ) ;
if ( ok ) this . closeCreateNameModal ( ) ;
return ;
2025-11-17 13:20:57 +00:00
}
2026-04-17 07:27:36 +00:00
const validation = targetFolder
? FilenameValidator . validateFilename ( rawName )
: FilenameValidator . validatePath ( rawName ) ;
if ( ! validation . valid ) {
this . toast ( this . getValidationErrorMessage ( validation , 'folder' ) , { type : 'warning' } ) ;
2025-11-17 13:20:57 +00:00
return ;
}
2026-04-17 07:27:36 +00:00
const validatedName = validation . sanitized ;
const folderPath = targetFolder ? ` ${ targetFolder } / ${ validatedName } ` : validatedName ;
const existingFolder = this . allFolders . find ( folder => folder === folderPath ) ;
if ( existingFolder ) {
this . toast ( this . t ( 'folders.already_exists' , { name : validatedName } ) , { type : 'warning' } ) ;
return ;
}
const ok = await this . _finalizeCreateFolder ( folderPath , targetFolder ) ;
if ( ok ) this . closeCreateNameModal ( ) ;
} ,
async _finalizeCreateNote ( notePath ) {
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-12-19 14:52:29 +00:00
const folderPart = notePath . includes ( '/' ) ? notePath . substring ( 0 , notePath . lastIndexOf ( '/' ) ) : '' ;
if ( folderPart ) this . expandedFolders . add ( folderPart ) ;
2025-11-05 16:48:41 +00:00
await this . loadNotes ( ) ;
await this . loadNote ( notePath ) ;
2026-02-09 17:17:12 +00:00
this . focusEditorForNewNote ( ) ;
2026-04-17 07:27:36 +00:00
return true ;
2025-11-05 16:48:41 +00:00
}
2026-04-17 07:27:36 +00:00
ErrorHandler . handle ( 'create note' , new Error ( 'Server returned error' ) ) ;
return false ;
2025-11-05 16:48:41 +00:00
} catch ( error ) {
ErrorHandler . handle ( 'create note' , error ) ;
2026-04-17 07:27:36 +00:00
return false ;
2025-11-05 16:48:41 +00:00
}
} ,
2026-04-17 07:27:36 +00:00
async _finalizeCreateFolder ( folderPath , targetFolder ) {
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
this . goToHomepageFolder ( folderPath ) ;
2026-04-17 07:27:36 +00:00
return true ;
2025-11-05 16:48:41 +00:00
}
2026-04-17 07:27:36 +00:00
ErrorHandler . handle ( 'create folder' , new Error ( 'Server returned error' ) ) ;
return false ;
2025-11-05 16:48:41 +00:00
} catch ( error ) {
ErrorHandler . handle ( 'create folder' , error ) ;
2026-04-17 07:27:36 +00:00
return false ;
2025-11-05 16:48:41 +00:00
}
} ,
2026-04-17 07:27:36 +00:00
async createFolder ( parentPath = null ) {
const explicitTarget = parentPath !== null && parentPath !== undefined ? parentPath : undefined ;
this . openCreateNameModal ( 'folder' , explicitTarget ) ;
} ,
renameFolder ( folderPath , currentName ) {
this . renameFolderPath = folderPath ;
this . renameFolderOldName = currentName ;
this . renameFolderInput = currentName ;
this . showRenameFolderModal = true ;
this . mobileSidebarOpen = false ;
this . $nextTick ( ( ) => {
const el = document . getElementById ( 'rename-folder-modal-input' ) ;
if ( el ) {
el . focus ( ) ;
el . select ( ) ;
}
} ) ;
} ,
closeRenameFolderModal ( ) {
this . showRenameFolderModal = false ;
this . renameFolderPath = '' ;
this . renameFolderOldName = '' ;
this . renameFolderInput = '' ;
} ,
cancelRenameFolderModal ( ) {
this . closeRenameFolderModal ( ) ;
} ,
2026-04-17 07:39:52 +00:00
/ * *
* Theme - styled confirm dialog ( replaces window . confirm ) .
* @ param { { message : string , title ? : string , danger ? : boolean , confirmLabel ? : string , cancelLabel ? : string } } options
* @ returns { Promise < boolean > }
* /
confirmModalAsk ( options = { } ) {
const { message , title , danger = true , confirmLabel , cancelLabel } = options ;
return new Promise ( ( resolve ) => {
this . confirmModalTitle = title || this . t ( 'common.confirm_title' ) ;
this . confirmModalMessage = message || '' ;
this . confirmModalDanger = danger ;
this . confirmModalConfirmLabel = confirmLabel
|| ( danger ? this . t ( 'common.delete' ) : this . t ( 'common.ok' ) ) ;
this . confirmModalCancelLabel = cancelLabel || this . t ( 'common.cancel' ) ;
this . _confirmModalResolve = resolve ;
this . showConfirmModal = true ;
this . mobileSidebarOpen = false ;
} ) ;
} ,
confirmModalConfirm ( ) {
this . _confirmModalFinish ( true ) ;
} ,
confirmModalCancel ( ) {
this . _confirmModalFinish ( false ) ;
} ,
_confirmModalFinish ( ok ) {
if ( ! this . _confirmModalResolve ) return ;
this . showConfirmModal = false ;
const fn = this . _confirmModalResolve ;
this . _confirmModalResolve = null ;
fn ( ! ! ok ) ;
} ,
2026-04-17 07:27:36 +00:00
async submitRenameFolderModal ( ) {
const folderPath = this . renameFolderPath ;
const currentName = this . renameFolderOldName ;
const newName = ( this . renameFolderInput || '' ) . trim ( ) ;
if ( ! newName || newName === currentName ) {
this . closeRenameFolderModal ( ) ;
return ;
}
2025-11-05 16:48:41 +00:00
2025-12-17 09:03:40 +00:00
const validation = FilenameValidator . validateFilename ( newName ) ;
if ( ! validation . valid ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . getValidationErrorMessage ( validation , 'folder' ) , { type : 'warning' } ) ;
2025-11-05 16:48:41 +00:00
return ;
}
2025-12-17 09:03:40 +00:00
const validatedName = validation . sanitized ;
2025-11-05 16:48:41 +00:00
const pathParts = folderPath . split ( '/' ) ;
2025-12-17 09:03:40 +00:00
pathParts [ pathParts . length - 1 ] = validatedName ;
2025-11-05 16:48:41 +00:00
const newPath = pathParts . join ( '/' ) ;
2026-04-17 07:27:36 +00:00
const ok = await this . _finalizeRenameFolder ( folderPath , newPath ) ;
if ( ok ) this . closeRenameFolderModal ( ) ;
} ,
async _finalizeRenameFolder ( folderPath , newPath ) {
2025-11-05 16:48:41 +00:00
try {
const response = await fetch ( '/api/folders/rename' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
2026-04-17 07:27:36 +00:00
body : JSON . stringify ( {
2025-11-05 16:48:41 +00:00
oldPath : folderPath ,
newPath : newPath
} )
} ) ;
if ( response . ok ) {
if ( this . expandedFolders . has ( folderPath ) ) {
this . expandedFolders . delete ( folderPath ) ;
this . expandedFolders . add ( newPath ) ;
}
2025-12-15 11:18:14 +00:00
const folderPrefix = folderPath + '/' ;
const newFolderPrefix = newPath + '/' ;
const newFavorites = this . favorites . map ( f => {
if ( f . startsWith ( folderPrefix ) ) {
return f . replace ( folderPrefix , newFolderPrefix ) ;
}
return f ;
} ) ;
if ( JSON . stringify ( newFavorites ) !== JSON . stringify ( this . favorites ) ) {
this . favorites = newFavorites ;
this . favoritesSet = new Set ( newFavorites ) ;
this . saveFavorites ( ) ;
}
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 ( ) ;
2026-04-17 07:27:36 +00:00
return true ;
2025-11-05 16:48:41 +00:00
}
2026-04-17 07:27:36 +00:00
ErrorHandler . handle ( 'rename folder' , new Error ( 'Server returned error' ) ) ;
return false ;
2025-11-05 16:48:41 +00:00
} catch ( error ) {
ErrorHandler . handle ( 'rename folder' , error ) ;
2026-04-17 07:27:36 +00:00
return false ;
2025-11-05 16:48:41 +00:00
}
} ,
// Delete folder
async deleteFolder ( folderPath , folderName ) {
2026-04-17 07:39:52 +00:00
const ok = await this . confirmModalAsk ( {
message : this . t ( 'folders.confirm_delete' , { name : folderName } ) ,
} ) ;
if ( ! ok ) return ;
2025-11-05 16:48:41 +00:00
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 = '' ;
2026-01-05 10:05:08 +00:00
document . title = this . appName ;
2025-11-05 16:48:41 +00:00
}
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-12-16 15:55:05 +00:00
// Update outline (TOC) in real-time
this . extractOutline ( this . noteContent ) ;
2025-11-05 16:48:41 +00:00
this . saveTimeout = setTimeout ( ( ) => {
2025-12-18 08:50:08 +00:00
// Commit to undo history when autosave triggers (same debounce timing)
if ( this . hasPendingHistoryChanges ) {
this . commitToHistory ( ) ;
}
2025-11-05 16:48:41 +00:00
this . saveNote ( ) ;
2026-06-08 09:02:05 +00:00
} , this . autosaveDelayMs ) ;
2025-11-05 16:48:41 +00:00
} ,
2025-12-18 08:50:08 +00:00
// Mark that we have pending changes (called on each keystroke)
2025-11-05 16:48:41 +00:00
pushToHistory ( ) {
2025-12-18 08:50:08 +00:00
this . hasPendingHistoryChanges = true ;
} ,
// Immediately commit pending changes to history (call before undo/redo)
flushHistory ( ) {
if ( this . hasPendingHistoryChanges ) {
this . commitToHistory ( ) ;
}
} ,
// Actually commit to undo history (internal)
commitToHistory ( ) {
2025-12-14 14:16:06 +00:00
const editor = document . getElementById ( 'note-editor' ) ;
const cursorPos = editor ? editor . selectionStart : 0 ;
2025-12-18 08:50:08 +00:00
// Only push if content actually changed from last history entry
2025-11-05 16:48:41 +00:00
if ( this . undoHistory . length > 0 &&
2025-12-14 14:16:06 +00:00
this . undoHistory [ this . undoHistory . length - 1 ] . content === this . noteContent ) {
2025-12-18 08:50:08 +00:00
this . hasPendingHistoryChanges = false ;
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 = [ ] ;
2025-12-18 08:50:08 +00:00
this . hasPendingHistoryChanges = false ;
2025-11-05 16:48:41 +00:00
} ,
// Undo last change
undo ( ) {
2025-12-18 08:50:08 +00:00
if ( ! this . currentNote ) return ;
// Flush any pending history changes first (so we don't lose unsaved edits)
this . flushHistory ( ) ;
if ( this . undoHistory . length <= 1 ) return ;
2025-11-05 16:48:41 +00:00
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 ( ) {
2025-12-18 08:50:08 +00:00
if ( ! this . currentNote ) return ;
// Flush any pending history changes first
this . flushHistory ( ) ;
if ( this . redoHistory . length === 0 ) return ;
2025-11-05 16:48:41 +00:00
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
2026-06-29 11:07:00 +00:00
// Trim trailing whitespace from double-click word selections.
// No-op on browsers that already exclude it (Firefox, Safari).
trimSelectionTrailingSpace ( event ) {
const editor = event && event . target ;
if ( ! editor ) return ;
const start = editor . selectionStart ;
let end = editor . selectionEnd ;
if ( start >= end ) return ;
const text = editor . value ;
while ( end > start && /\s/ . test ( text . charAt ( end - 1 ) ) ) end -- ;
if ( end !== editor . selectionEnd ) {
editor . setSelectionRange ( start , end ) ;
}
} ,
2025-11-24 09:17:21 +00:00
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 ) ;
2026-06-29 11:07:00 +00:00
// Push edge whitespace OUTSIDE inline wrappers — "**house **" is
// invalid per CommonMark. Block wrappers (with \n) keep it intact.
const isInline = ! before . includes ( '\n' ) && ! after . includes ( '\n' ) ;
let leading = '' , trailing = '' , core = selectedText ;
if ( isInline && selectedText ) {
const m = selectedText . match ( /^(\s*)([\s\S]*?)(\s*)$/ ) ;
leading = m [ 1 ] ;
core = m [ 2 ] ;
trailing = m [ 3 ] ;
}
const textToWrap = core || placeholder ;
2025-11-24 09:17:21 +00:00
// Build the new text
2026-06-29 11:07:00 +00:00
const newText = leading + before + textToWrap + after + trailing ;
2025-11-24 09:17:21 +00:00
// Update content
this . noteContent = this . noteContent . substring ( 0 , start ) + newText + this . noteContent . substring ( end ) ;
this . $nextTick ( ( ) => {
2026-06-29 11:07:00 +00:00
const coreStart = start + leading . length + before . length ;
const coreEnd = coreStart + textToWrap . length ;
editor . setSelectionRange ( coreStart , coreEnd ) ;
2025-11-24 09:17:21 +00:00
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-12-18 13:16:04 +00:00
// Format selected text or insert formatting at cursor
formatText ( type ) {
2025-12-18 13:23:16 +00:00
// Simple wrap cases - reuse wrapSelection()
const wrapFormats = {
'bold' : [ '**' , '**' , 'bold' ] ,
'italic' : [ '*' , '*' , 'italic' ] ,
'strikethrough' : [ '~~' , '~~' , 'strikethrough' ] ,
'code' : [ '`' , '`' , 'code' ]
} ;
2025-12-18 13:16:04 +00:00
2025-12-18 13:23:16 +00:00
if ( wrapFormats [ type ] ) {
const [ before , after , placeholder ] = wrapFormats [ type ] ;
this . wrapSelection ( before , after , placeholder ) ;
return ;
}
2025-12-18 13:16:04 +00:00
2025-12-18 13:23:16 +00:00
// Special cases that need custom handling
2025-12-18 13:16:04 +00:00
switch ( type ) {
case 'heading' :
2025-12-18 13:23:16 +00:00
this . insertLinePrefix ( '## ' , 'Heading' ) ;
2025-12-18 13:16:04 +00:00
break ;
case 'quote' :
2025-12-18 13:23:16 +00:00
this . insertLinePrefix ( '> ' , 'quote' ) ;
2025-12-18 13:16:04 +00:00
break ;
case 'bullet' :
2025-12-18 13:23:16 +00:00
this . insertLinePrefix ( '- ' , 'item' ) ;
2025-12-18 13:16:04 +00:00
break ;
case 'numbered' :
2025-12-18 13:23:16 +00:00
this . insertLinePrefix ( '1. ' , 'item' ) ;
2025-12-18 13:16:04 +00:00
break ;
case 'checkbox' :
2025-12-18 13:23:16 +00:00
this . insertLinePrefix ( '- [ ] ' , 'task' ) ;
break ;
case 'link' :
this . insertLink ( ) ;
break ;
case 'image' :
this . wrapSelection ( '' , 'alt text' ) ;
break ;
case 'codeblock' :
this . wrapSelection ( '```\n' , '\n```' , 'code' ) ;
2025-12-18 13:16:04 +00:00
break ;
case 'table' :
this . insertTable ( ) ;
2025-12-18 13:23:16 +00:00
break ;
}
} ,
// Insert a line prefix (for headings, lists, quotes)
insertLinePrefix ( prefix , 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 beforeText = this . noteContent . substring ( 0 , start ) ;
const afterText = this . noteContent . substring ( end ) ;
// Check if at start of line
const atLineStart = beforeText . endsWith ( '\n' ) || beforeText === '' ;
const newline = atLineStart ? '' : '\n' ;
let replacement ;
if ( selectedText ) {
// Prefix each line of selection
replacement = newline + selectedText . split ( '\n' ) . map ( ( line , i ) => {
// For numbered lists, increment the number
if ( prefix === '1. ' ) return ` ${ i + 1 } . ${ line } ` ;
return prefix + line ;
} ) . join ( '\n' ) ;
} else {
replacement = newline + prefix + placeholder ;
2025-12-18 13:16:04 +00:00
}
this . noteContent = beforeText + replacement + afterText ;
this . $nextTick ( ( ) => {
2025-12-18 13:23:16 +00:00
if ( selectedText ) {
editor . setSelectionRange ( start + newline . length , start + replacement . length ) ;
} else {
const placeholderStart = start + newline . length + prefix . length ;
editor . setSelectionRange ( placeholderStart , placeholderStart + placeholder . length ) ;
}
2025-12-18 13:16:04 +00:00
editor . focus ( ) ;
} ) ;
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 ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'notes.empty_name' ) , { type : 'warning' } ) ;
2025-11-05 16:48:41 +00:00
return ;
}
2025-12-17 09:03:40 +00:00
// Validate the new name (single segment, no path separators)
const validation = FilenameValidator . validateFilename ( newName ) ;
if ( ! validation . valid ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . getValidationErrorMessage ( validation , 'note' ) , { type : 'warning' } ) ;
2025-12-17 09:03:40 +00:00
// Reset the name in the UI
this . currentNoteName = oldPath . split ( '/' ) . pop ( ) . replace ( '.md' , '' ) ;
return ;
}
const validatedName = validation . sanitized ;
2025-11-05 16:48:41 +00:00
const folder = oldPath . split ( '/' ) . slice ( 0 , - 1 ) . join ( '/' ) ;
2025-12-17 09:03:40 +00:00
const newPath = folder ? ` ${ folder } / ${ validatedName } .md ` : ` ${ validatedName } .md ` ;
2025-11-05 16:48:41 +00:00
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 ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'notes.already_exists' , { name : validatedName } ) , { type : 'warning' } ) ;
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 ) {
2026-04-17 07:39:52 +00:00
const ok = await this . confirmModalAsk ( {
message : this . t ( 'notes.confirm_delete' , { name : noteName } ) ,
} ) ;
if ( ! ok ) 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
2026-04-13 14:11:55 +00:00
this . _lastRenderedNote = '' ;
2025-12-12 17:11:29 +00:00
this . _cachedRenderedHTML = '' ;
2026-01-05 10:05:08 +00:00
document . title = this . appName ;
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
2026-02-20 07:26:52 +00:00
debouncedSearchNotes ( ) {
if ( this . searchDebounceTimeout ) {
clearTimeout ( this . searchDebounceTimeout ) ;
}
const hasTextSearch = this . searchQuery . trim ( ) . length > 0 ;
if ( ! hasTextSearch ) {
this . isSearching = false ;
this . searchNotes ( ) ;
return ;
}
this . isSearching = true ;
this . searchResults = [ ] ;
this . searchDebounceTimeout = setTimeout ( ( ) => {
this . searchNotes ( ) ;
} , CONFIG . SEARCH _DEBOUNCE _DELAY ) ;
} ,
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
2026-04-13 14:11:55 +00:00
if ( this . noteContent === this . _lastRenderedContent &&
this . currentNote === this . _lastRenderedNote &&
this . _cachedRenderedHTML ) {
2025-12-12 17:11:29 +00:00
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
2026-01-03 16:24:52 +00:00
// BUT we need to protect code blocks first to avoid converting [[text]] inside code
2025-12-12 17:11:29 +00:00
const self = this ; // Reference for closure
2026-01-03 16:24:52 +00:00
// Step 1: Temporarily replace code blocks and inline code with placeholders
const codeBlocks = [ ] ;
// Protect fenced code blocks (```...```)
contentToRender = contentToRender . replace ( /```[\s\S]*?```/g , ( match ) => {
codeBlocks . push ( match ) ;
return ` \x 00CODEBLOCK ${ codeBlocks . length - 1 } \x 00 ` ;
} ) ;
// Protect inline code (`...`)
contentToRender = contentToRender . replace ( /`[^`]+`/g , ( match ) => {
codeBlocks . push ( match ) ;
return ` \x 00CODEBLOCK ${ codeBlocks . length - 1 } \x 00 ` ;
} ) ;
2026-01-18 15:00:01 +00:00
// Step 2: Convert media wikilinks FIRST: ![[file.png]] or ![[file.png|alt text]]
// Must be before note wikilinks to prevent [[file.png]] from being matched first
2026-01-05 11:51:24 +00:00
contentToRender = contentToRender . replace (
/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g ,
2026-01-18 15:00:01 +00:00
( match , mediaName , altText ) => {
const filename = mediaName . trim ( ) ;
2026-01-05 11:51:24 +00:00
const alt = altText ? altText . trim ( ) : filename . replace ( /\.[^/.]+$/ , '' ) ;
2026-01-18 15:00:01 +00:00
// Resolve media path using O(1) lookup
const mediaPath = self . resolveMediaWikilink ( filename ) ;
2026-01-05 11:51:24 +00:00
2026-01-18 15:00:01 +00:00
if ( mediaPath ) {
2026-01-05 11:51:24 +00:00
// URL-encode path segments for the API
2026-01-18 15:00:01 +00:00
const encodedPath = mediaPath . split ( '/' ) . map ( segment => {
2026-01-05 11:51:24 +00:00
try {
return encodeURIComponent ( decodeURIComponent ( segment ) ) ;
} catch ( e ) {
return encodeURIComponent ( segment ) ;
}
} ) . join ( '/' ) ;
const safeAlt = alt . replace ( /"/g , '"' ) ;
2026-01-18 15:00:01 +00:00
const mediaSrc = ` /api/media/ ${ encodedPath } ` ;
const mediaType = self . getMediaType ( filename ) ;
// Return appropriate HTML based on media type
switch ( mediaType ) {
case 'audio' :
return ` <div class="media-embed media-audio"><audio controls preload="none" src=" ${ mediaSrc } " title=" ${ safeAlt } "></audio><span class="media-caption"> ${ safeAlt } </span></div> ` ;
case 'video' :
return ` <div class="media-embed media-video"><video controls preload="none" poster="" src=" ${ mediaSrc } " title=" ${ safeAlt } "></video></div> ` ;
case 'document' :
2026-06-10 16:20:55 +00:00
// DOMPurify strips <iframe>, so emit a sentinel <span>
// that survives sanitization; the DOM walker below
// upgrades it to a media-pdf wrapper + iframe. Issue #239.
return ` <span class="md-pdf-embed" data-src=" ${ mediaSrc } " data-alt=" ${ safeAlt } "></span> ` ;
2026-01-18 15:00:01 +00:00
default : // image
return ` <img src=" ${ mediaSrc } " alt=" ${ safeAlt } " title=" ${ safeAlt } "> ` ;
}
2026-01-05 11:51:24 +00:00
}
2026-01-18 15:00:01 +00:00
// Media not found - return broken indicator
2026-01-05 11:51:24 +00:00
const safeFilename = filename . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
2026-01-18 15:00:01 +00:00
const mediaType = self . getMediaType ( filename ) ;
const icon = mediaType === 'audio' ? '🎵' : mediaType === 'video' ? '🎬' : mediaType === 'document' ? '📄' : '🖼️' ;
return ` <span class="wikilink-broken" title="Media not found"> ${ icon } ${ safeFilename } </span> ` ;
2026-01-05 11:51:24 +00:00
}
) ;
// Step 2b: Convert note wikilinks: [[note]] or [[note|display text]]
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> ` ;
}
) ;
2026-06-29 11:07:00 +00:00
// Step 2c: GitHub-style callouts. Runs inside the code-protection
// window. Unknown types fall through to plain blockquotes.
2026-06-29 10:19:06 +00:00
{
const CALLOUT _RE = /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/i ;
const CALLOUT _ICONS = { note : 'ℹ ️ ' , tip : '💡' , important : '❗' , warning : '⚠️' , caution : '🛑' } ;
const CALLOUT _TITLES = { note : 'Note' , tip : 'Tip' , important : 'Important' , warning : 'Warning' , caution : 'Caution' } ;
const escapeAttr = ( s ) => s . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
const srcLines = contentToRender . split ( '\n' ) ;
const outLines = [ ] ;
let li = 0 ;
while ( li < srcLines . length ) {
const m = srcLines [ li ] . match ( CALLOUT _RE ) ;
if ( ! m ) {
outLines . push ( srcLines [ li ] ) ;
li ++ ;
continue ;
}
const type = m [ 1 ] . toLowerCase ( ) ;
const title = escapeAttr ( ( m [ 2 ] || '' ) . trim ( ) || CALLOUT _TITLES [ type ] ) ;
const icon = CALLOUT _ICONS [ type ] ;
const bodyLines = [ ] ;
li ++ ;
while ( li < srcLines . length && srcLines [ li ] . startsWith ( '>' ) ) {
bodyLines . push ( srcLines [ li ] . replace ( /^>\s?/ , '' ) ) ;
li ++ ;
}
outLines . push (
'' ,
` <div class="callout callout- ${ type } "> ` ,
` <div class="callout-title"><span class="callout-icon" aria-hidden="true"> ${ icon } </span><span class="callout-title-text"> ${ title } </span></div> ` ,
` <div class="callout-body"> ` ,
'' ,
bodyLines . join ( '\n' ) ,
'' ,
` </div> ` ,
` </div> ` ,
''
) ;
}
contentToRender = outLines . join ( '\n' ) ;
}
2026-01-03 16:24:52 +00:00
// Step 3: Restore code blocks
contentToRender = contentToRender . replace ( /\x00CODEBLOCK(\d+)\x00/g , ( match , index ) => {
return codeBlocks [ parseInt ( index ) ] ;
} ) ;
2026-04-06 10:01:52 +00:00
// Protect LaTeX \(...\) and \[...\] delimiters from marked.js escaping
marked . use ( {
extensions : [ {
name : 'protectLatexMath' ,
level : 'inline' ,
start ( src ) { return src . match ( /\\[\(\[]/ ) ? . index ; } ,
tokenizer ( src ) {
// Match \(...\) or \[...\]
const match = src . match ( /^(\\[\(\[])([\s\S]*?)(\\[\)\]])/ ) ;
if ( match ) {
return {
type : 'html' ,
raw : match [ 0 ] ,
text : match [ 0 ]
} ;
}
}
} ]
} ) ;
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
2026-03-03 08:27:01 +00:00
// Sanitize HTML to prevent XSS attacks
// DOMPurify defaults allow most HTML/SVG tags but strip scripts, iframes, and event handlers
// MathJax and Mermaid run AFTER this, so their elements don't need whitelisting
html = DOMPurify . sanitize ( html ) ;
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
2026-01-18 15:00:01 +00:00
// Also convert non-image media (audio, video, PDF) to appropriate elements
2025-11-23 15:59:57 +00:00
const images = tempDiv . querySelectorAll ( 'img' ) ;
images . forEach ( img => {
2026-01-18 15:00:01 +00:00
let src = img . getAttribute ( 'src' ) ;
2025-12-11 10:39:12 +00:00
if ( src ) {
2026-01-18 18:04:05 +00:00
const isExternal = src . startsWith ( 'http://' ) || src . startsWith ( 'https://' ) || src . startsWith ( '//' ) ;
const isLocal = ! isExternal && ! src . startsWith ( 'data:' ) ;
2026-01-18 15:00:01 +00:00
// Transform relative paths to /api/media/ for serving
2026-01-18 18:04:05 +00:00
if ( isLocal && ! src . startsWith ( '/api/media/' ) ) {
2026-04-13 14:11:55 +00:00
const vaultRelative = self . currentNote
? self . resolveMarkdownMediaPathForNote ( self . currentNote , src )
: src . split ( '#' ) [ 0 ] . split ( '?' ) [ 0 ] ;
const encodedPath = self . encodeVaultRelativePathForMediaApi ( vaultRelative ) ;
2026-01-18 15:00:01 +00:00
src = ` /api/media/ ${ encodedPath } ` ;
img . setAttribute ( 'src' , src ) ;
}
// Check if this is non-image media and convert to appropriate element
const mediaType = self . getMediaType ( src ) ;
const altText = img . getAttribute ( 'alt' ) || src . split ( '/' ) . pop ( ) . replace ( /\.[^/.]+$/ , '' ) ;
const safeAlt = altText . replace ( /"/g , '"' ) ;
2026-01-18 18:04:05 +00:00
// Only convert LOCAL media to embedded elements (security)
// External non-image media gets styled links instead
if ( isLocal || src . startsWith ( '/api/media/' ) ) {
if ( mediaType === 'audio' ) {
const wrapper = document . createElement ( 'div' ) ;
wrapper . className = 'media-embed media-audio' ;
wrapper . innerHTML = ` <audio controls preload="none" src=" ${ src } " title=" ${ safeAlt } "></audio><span class="media-caption"> ${ safeAlt } </span> ` ;
img . replaceWith ( wrapper ) ;
return ;
} else if ( mediaType === 'video' ) {
const wrapper = document . createElement ( 'div' ) ;
wrapper . className = 'media-embed media-video' ;
wrapper . innerHTML = ` <video controls preload="none" poster="" src=" ${ src } " title=" ${ safeAlt } "></video> ` ;
img . replaceWith ( wrapper ) ;
return ;
} else if ( mediaType === 'document' ) {
// Local PDFs: show iframe preview
const wrapper = document . createElement ( 'div' ) ;
wrapper . className = 'media-embed media-pdf' ;
wrapper . innerHTML = ` <iframe src=" ${ src } " title=" ${ safeAlt } "></iframe> ` ;
img . replaceWith ( wrapper ) ;
return ;
}
} else if ( isExternal && mediaType === 'document' ) {
// External PDFs: styled link (opens in new tab)
const link = document . createElement ( 'a' ) ;
link . href = src ;
link . target = '_blank' ;
link . rel = 'noopener noreferrer' ;
link . className = 'pdf-link' ;
link . title = ` Open ${ safeAlt } ` ;
link . innerHTML = ` <span class="pdf-link-content">📄 ${ safeAlt } </span><span class="pdf-link-note">Opens in new tab</span> ` ;
img . replaceWith ( link ) ;
2026-01-18 15:00:01 +00:00
return ;
2025-12-11 10:39:12 +00:00
}
2026-01-18 18:04:05 +00:00
// External audio/video: leave as broken image for security
2025-12-11 10:39:12 +00:00
}
2026-01-18 15:00:01 +00:00
// For regular images, set title attribute
2025-11-23 15:59:57 +00:00
const altText = img . getAttribute ( 'alt' ) ;
if ( altText ) {
img . setAttribute ( 'title' , altText ) ;
}
} ) ;
2026-06-10 16:20:55 +00:00
// Upgrade PDF sentinels emitted by the wiki-embed pre-processor.
// Must run AFTER DOMPurify because iframes are stripped by the sanitizer.
tempDiv . querySelectorAll ( 'span.md-pdf-embed' ) . forEach ( span => {
const src = span . dataset . src || '' ;
const alt = span . dataset . alt || '' ;
if ( ! src ) return ;
const safeAlt = alt . replace ( /"/g , '"' ) ;
const wrapper = document . createElement ( 'div' ) ;
wrapper . className = 'media-embed media-pdf' ;
wrapper . innerHTML = ` <iframe src=" ${ src } " title=" ${ safeAlt } "></iframe> ` ;
span . replaceWith ( wrapper ) ;
} ) ;
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 ) ;
}
} ) ;
2026-01-18 16:32:22 +00:00
// Enable video metadata loading (for first frame preview)
// Track by source URL to prevent duplicate requests on re-renders
if ( ! this . _initializedVideoSources ) this . _initializedVideoSources = new Set ( ) ;
previewEl . querySelectorAll ( 'video[preload="none"]' ) . forEach ( ( video ) => {
const src = video . getAttribute ( 'src' ) ;
if ( src && ! this . _initializedVideoSources . has ( src ) ) {
this . _initializedVideoSources . add ( src ) ;
video . preload = 'metadata' ;
}
} ) ;
2025-11-05 16:48:41 +00:00
}
} , 0 ) ;
2025-12-12 17:11:29 +00:00
// Cache the result for performance
this . _lastRenderedContent = this . noteContent ;
2026-04-13 14:11:55 +00:00
this . _lastRenderedNote = this . currentNote ;
2025-12-12 17:11:29 +00:00
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 ) {
2026-01-03 16:35:49 +00:00
// Extract language from code element class (e.g., "language-toml" -> "TOML")
const codeElement = preElement . querySelector ( 'code' ) ;
let language = '' ;
if ( codeElement && codeElement . className ) {
const match = codeElement . className . match ( /language-(\w+)/ ) ;
if ( match ) {
const langMap = {
'js' : 'JavaScript' , 'ts' : 'TypeScript' , 'py' : 'Python' ,
'rb' : 'Ruby' , 'cs' : 'C#' , 'cpp' : 'C++' , 'sh' : 'Shell' ,
'bash' : 'Bash' , 'zsh' : 'Zsh' , 'yml' : 'YAML' , 'md' : 'Markdown'
} ;
const rawLang = match [ 1 ] . toLowerCase ( ) ;
language = langMap [ rawLang ] || match [ 1 ] . toUpperCase ( ) ;
}
}
// Create copy button with language label
2025-11-05 16:48:41 +00:00
const button = document . createElement ( 'button' ) ;
button . className = 'copy-code-button' ;
2026-01-03 16:35:49 +00:00
const displayText = language || this . t ( 'common.copy_to_clipboard' ) . split ( ' ' ) [ 0 ] ; // Use first word as fallback
button . innerHTML = ` <span> ${ displayText } </span> ` ;
button . dataset . originalText = displayText ; // Store for restore after copy
button . title = this . t ( 'common.copy_to_clipboard' ) ;
2025-11-05 16:48:41 +00:00
// Style the button
button . style . position = 'absolute' ;
button . style . top = '8px' ;
button . style . right = '8px' ;
2026-01-03 16:35:49 +00:00
button . style . padding = '4px 10px' ;
2025-11-05 16:48:41 +00:00
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' ;
2026-01-03 16:35:49 +00:00
button . style . fontSize = '11px' ;
button . style . fontWeight = '600' ;
button . style . fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace' ;
button . style . textTransform = 'uppercase' ;
button . style . letterSpacing = '0.5px' ;
2025-11-05 16:48:41 +00:00
// 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 ;
2026-01-03 16:35:49 +00:00
const originalText = button . dataset . originalText ;
const copiedText = this . t ( 'common.copied' ) ;
const copyTitle = this . t ( 'common.copy_to_clipboard' ) ;
2025-11-05 16:48:41 +00:00
try {
await navigator . clipboard . writeText ( code ) ;
2026-01-03 16:35:49 +00:00
// Visual feedback - show localized "Copied!"
button . innerHTML = ` <span> ${ copiedText } </span> ` ;
2025-11-05 16:48:41 +00:00
button . style . backgroundColor = 'rgba(34, 197, 94, 0.8)' ;
2026-01-03 16:35:49 +00:00
button . title = copiedText ;
2025-11-05 16:48:41 +00:00
// Reset after 2 seconds
setTimeout ( ( ) => {
2026-01-03 16:35:49 +00:00
button . innerHTML = ` <span> ${ originalText } </span> ` ;
2025-11-05 16:48:41 +00:00
button . style . backgroundColor = 'rgba(0, 0, 0, 0.6)' ;
2026-01-03 16:35:49 +00:00
button . title = copyTitle ;
2025-11-05 16:48:41 +00:00
} , 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' ) ;
2026-01-03 16:35:49 +00:00
button . innerHTML = ` <span> ${ copiedText } </span> ` ;
2025-11-05 16:48:41 +00:00
button . style . backgroundColor = 'rgba(34, 197, 94, 0.8)' ;
setTimeout ( ( ) => {
2026-01-03 16:35:49 +00:00
button . innerHTML = ` <span> ${ originalText } </span> ` ;
2025-11-05 16:48:41 +00:00
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 ;
2026-05-04 17:50:38 +00:00
// Remember the user-driven scroll position as a percentage so it restores
// correctly across view modes (edit / split / preview) where editor and
// preview have wildly different scrollHeights. Saving after the isScrolling
// guard means we only record real user input, never the programmatic mirror.
if ( this . currentNote ) {
this . noteScrollPositions [ this . currentNote ] = scrollPercentage ;
}
2025-11-05 16:48:41 +00:00
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 ;
2026-05-04 17:50:38 +00:00
// Capture preview-driven scrolls too — this is the only path that fires in
// preview-only mode, where the textarea is display:none and never scrolls.
if ( this . currentNote ) {
this . noteScrollPositions [ this . currentNote ] = scrollPercentage ;
}
2025-11-05 16:48:41 +00:00
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
// Save sidebar width to localStorage
saveSidebarWidth ( ) {
localStorage . setItem ( 'sidebarWidth' , this . sidebarWidth . toString ( ) ) ;
} ,
// 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
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
// Save editor width to localStorage
saveEditorWidth ( ) {
localStorage . setItem ( 'editorWidth' , this . editorWidth . toString ( ) ) ;
} ,
2026-05-04 17:50:38 +00:00
/ * *
* Restore the saved scroll percentage for the current note in both panes . Called from
* loadNote ( ) ( after the new content has been laid out ) and from the viewMode watcher
* ( browsers reset scrollTop to 0 when an overflow : auto element transitions from
* display : none back to display : block via x - show , so newly - visible panes need
* re - positioning ) . Storing / applying a percentage means the same logical position is
* preserved across mode toggles even though editor and preview have different
* scrollHeights . Idempotent : safe to call multiple times . The isScrolling flag
* prevents the existing scroll - sync handlers from echoing these programmatic writes .
* /
_restoreNoteScroll ( ) {
if ( ! this . currentNote || this . currentMedia ) return ;
const pct = this . noteScrollPositions [ this . currentNote ] || 0 ;
if ( ! this . _domCache . editor || ! this . _domCache . previewContainer ) {
this . refreshDOMCache ( ) ;
}
const editor = this . _domCache . editor ;
const preview = this . _domCache . previewContainer ;
if ( editor ) {
const scrollable = editor . scrollHeight - editor . clientHeight ;
if ( scrollable > 0 ) {
this . isScrolling = true ;
editor . scrollTop = pct * scrollable ;
}
}
if ( preview ) {
const scrollable = preview . scrollHeight - preview . clientHeight ;
if ( scrollable > 0 ) {
this . isScrolling = true ;
preview . scrollTop = pct * scrollable ;
}
}
} ,
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
} ,
2026-04-06 10:01:52 +00:00
// Export current note as HTML via backend API
2025-11-12 10:16:12 +00:00
async exportToHTML ( ) {
if ( ! this . currentNote || ! this . noteContent ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'notes.no_content' ) , { type : 'info' } ) ;
2025-11-12 10:16:12 +00:00
return ;
}
try {
2026-04-06 10:01:52 +00:00
// Build API URL with current theme
2025-11-12 10:16:12 +00:00
const currentTheme = this . currentTheme || 'light' ;
2026-04-06 10:01:52 +00:00
const encodedPath = this . currentNote . split ( '/' ) . map ( s => encodeURIComponent ( s ) ) . join ( '/' ) ;
const url = ` /api/export/ ${ encodedPath } ?theme= ${ encodeURIComponent ( currentTheme ) } ` ;
2025-11-12 10:16:12 +00:00
2026-04-06 10:01:52 +00:00
// Fetch the exported HTML from backend
const response = await fetch ( url ) ;
if ( ! response . ok ) {
const error = await response . json ( ) . catch ( ( ) => ( { detail : 'Export failed' } ) ) ;
throw new Error ( error . detail || 'Export failed' ) ;
2025-11-12 10:16:12 +00:00
}
2026-04-06 10:01:52 +00:00
// Get filename from Content-Disposition header or use note name
let filename = ( this . currentNoteName || 'note' ) + '.html' ;
const contentDisposition = response . headers . get ( 'Content-Disposition' ) ;
if ( contentDisposition ) {
const match = contentDisposition . match ( /filename="([^"]+)"/ ) ;
if ( match ) {
filename = match [ 1 ] ;
2025-11-12 10:16:12 +00:00
}
}
2026-04-06 10:01:52 +00:00
// Download as blob
const blob = await response . blob ( ) ;
const blobUrl = URL . createObjectURL ( blob ) ;
2025-11-12 10:16:12 +00:00
const a = document . createElement ( 'a' ) ;
2026-04-06 10:01:52 +00:00
a . href = blobUrl ;
a . download = filename ;
2025-11-12 10:16:12 +00:00
document . body . appendChild ( a ) ;
a . click ( ) ;
// Cleanup
2026-04-06 10:01:52 +00:00
URL . revokeObjectURL ( blobUrl ) ;
2025-11-12 10:16:12 +00:00
document . body . removeChild ( a ) ;
} catch ( error ) {
console . error ( 'HTML export failed:' , error ) ;
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'export.failed' , { error : error . message } ) , { type : 'error' } ) ;
2025-11-12 10:16:12 +00:00
}
2025-11-20 03:01:27 +00:00
} ,
2026-04-06 10:58:38 +00:00
// Open print preview in new window
printPreview ( ) {
if ( ! this . currentNote || ! this . noteContent ) {
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'notes.no_content' ) , { type : 'info' } ) ;
2026-04-06 10:58:38 +00:00
return ;
}
// Build API URL with current theme and download=false for inline display
const currentTheme = this . currentTheme || 'light' ;
const encodedPath = this . currentNote . split ( '/' ) . map ( s => encodeURIComponent ( s ) ) . join ( '/' ) ;
const url = ` /api/export/ ${ encodedPath } ?theme= ${ encodeURIComponent ( currentTheme ) } &download=false ` ;
// Open in new window/tab
window . open ( url , '_blank' ) ;
} ,
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
} ,
2026-01-15 09:07:51 +00:00
// ============================================================================
// Share Functions
// ============================================================================
2026-04-22 13:38:16 +00:00
// Load list of shared note paths (for visual indicators and Shared sidebar panel)
2026-01-15 09:07:51 +00:00
async loadSharedNotePaths ( ) {
try {
const response = await fetch ( '/api/shared-notes' ) ;
if ( response . ok ) {
const data = await response . json ( ) ;
this . _sharedNotePaths = new Set ( data . paths || [ ] ) ;
2026-04-22 13:38:16 +00:00
this . _syncSharedNotePathsList ( ) ;
2026-01-15 09:07:51 +00:00
}
} catch ( error ) {
console . error ( 'Failed to load shared note paths:' , error ) ;
this . _sharedNotePaths = new Set ( ) ;
2026-04-22 13:38:16 +00:00
this . _sharedNotePathsList = [ ] ;
2026-01-15 09:07:51 +00:00
}
} ,
2026-04-22 13:38:16 +00:00
_syncSharedNotePathsList ( ) {
this . _sharedNotePathsList = [ ... this . _sharedNotePaths ] . sort ( ( a , b ) =>
a . localeCompare ( b , undefined , { sensitivity : 'base' } )
) ;
} ,
/** Display rows for Shared notes panel: name + folder line (search-style) */
getSharedPanelItems ( ) {
return this . _sharedNotePathsList . map ( ( path ) => {
const noMd = path . replace ( /\.md$/i , '' ) ;
const last = Math . max ( noMd . lastIndexOf ( '/' ) , noMd . lastIndexOf ( '\\' ) ) ;
const name = last < 0 ? noMd : noMd . slice ( last + 1 ) ;
const folder = last < 0 ? '' : noMd . slice ( 0 , last ) . replace ( /\\/g , '/' ) ;
return { path , name , folder : folder || 'Root' } ;
} ) ;
} ,
2026-01-15 09:07:51 +00:00
// Check if a note is currently shared (O(1) lookup)
isNoteShared ( notePath ) {
return this . _sharedNotePaths . has ( notePath ) ;
} ,
2026-01-15 15:32:35 +00:00
// ============================================
// Quick Switcher (Ctrl+Alt+P)
// ============================================
openQuickSwitcher ( ) {
this . showQuickSwitcher = true ;
this . quickSwitcherQuery = '' ;
this . quickSwitcherIndex = 0 ;
// Populate initial results
this . quickSwitcherResults = ( this . allNotes || [ ] ) . slice ( 0 , 10 ) ;
// Focus the input after the modal renders
this . $nextTick ( ( ) => {
const input = document . getElementById ( 'quickSwitcherInput' ) ;
if ( input ) input . focus ( ) ;
} ) ;
} ,
closeQuickSwitcher ( ) {
this . showQuickSwitcher = false ;
this . quickSwitcherQuery = '' ;
this . quickSwitcherIndex = 0 ;
} ,
// Filter notes for quick switcher based on query
filterQuickSwitcher ( query ) {
// Only include actual notes, not images
const notes = ( this . notes || [ ] ) . filter ( n => n . type === 'note' ) ;
if ( ! query || ! query . trim ( ) ) {
// Show recent notes when no query
return notes . slice ( 0 , 10 ) ;
}
const q = query . toLowerCase ( ) ;
return notes
. filter ( n =>
n . name . toLowerCase ( ) . includes ( q ) ||
n . path . toLowerCase ( ) . includes ( q )
)
. slice ( 0 , 10 ) ;
} ,
// Handle keyboard navigation in quick switcher
handleQuickSwitcherKeydown ( e ) {
const results = this . quickSwitcherResults ;
if ( e . key === 'ArrowDown' ) {
e . preventDefault ( ) ;
this . quickSwitcherIndex = Math . min ( this . quickSwitcherIndex + 1 , results . length - 1 ) ;
this . scrollQuickSwitcherIntoView ( ) ;
} else if ( e . key === 'ArrowUp' ) {
e . preventDefault ( ) ;
this . quickSwitcherIndex = Math . max ( this . quickSwitcherIndex - 1 , 0 ) ;
this . scrollQuickSwitcherIntoView ( ) ;
} else if ( e . key === 'Enter' ) {
e . preventDefault ( ) ;
const note = results [ this . quickSwitcherIndex ] ;
if ( note ) {
this . loadNote ( note . path ) ;
this . closeQuickSwitcher ( ) ;
}
} else if ( e . key === 'Escape' ) {
e . preventDefault ( ) ;
this . closeQuickSwitcher ( ) ;
}
} ,
// Scroll selected item into view in quick switcher
scrollQuickSwitcherIntoView ( ) {
2026-01-15 16:15:44 +00:00
this . $nextTick ( ( ) => {
const items = document . querySelectorAll ( '[data-quick-switcher-item]' ) ;
if ( items [ this . quickSwitcherIndex ] ) {
items [ this . quickSwitcherIndex ] . scrollIntoView ( { block : 'nearest' } ) ;
2026-01-15 15:32:35 +00:00
}
2026-01-15 16:15:44 +00:00
} ) ;
2026-01-15 15:32:35 +00:00
} ,
// Select note from quick switcher by click
selectQuickSwitcherNote ( note ) {
this . loadNote ( note . path ) ;
this . closeQuickSwitcher ( ) ;
} ,
2026-02-09 17:55:55 +00:00
// Close share modal and reset state after animation
closeShareModal ( ) {
this . showShareModal = false ;
// Delay state reset until modal is fully hidden
setTimeout ( ( ) => {
this . showShareQR = false ;
this . shareInfo = null ;
this . shareLoading = false ;
} , 200 ) ;
} ,
// Generate QR code for share URL
generateQRCode ( url ) {
if ( ! url || typeof qrcode === 'undefined' ) return '' ;
try {
const qr = qrcode ( 0 , 'M' ) ; // 0 = auto version, M = medium error correction
qr . addData ( url ) ;
qr . make ( ) ;
return qr . createDataURL ( 4 ) ; // 4 = module size in pixels
} catch ( e ) {
console . error ( 'QR code generation failed:' , e ) ;
return '' ;
}
} ,
2026-01-15 09:07:51 +00:00
// Open share modal and fetch current share status
async openShareModal ( ) {
if ( ! this . currentNote ) return ;
2026-02-09 17:55:55 +00:00
// Reset state BEFORE showing modal to prevent flicker
this . showShareQR = false ;
2026-01-15 09:07:51 +00:00
this . shareInfo = null ;
2026-02-09 17:55:55 +00:00
this . shareLoading = true ;
this . showShareModal = true ;
2026-01-15 09:07:51 +00:00
try {
const notePath = this . currentNote . replace ( '.md' , '' ) ;
const encodedPath = notePath . split ( '/' ) . map ( segment => encodeURIComponent ( segment ) ) . join ( '/' ) ;
const response = await fetch ( ` /api/share/ ${ encodedPath } ` ) ;
if ( response . ok ) {
this . shareInfo = await response . json ( ) ;
} else {
this . shareInfo = { shared : false } ;
}
} catch ( error ) {
console . error ( 'Failed to get share status:' , error ) ;
this . shareInfo = { shared : false } ;
} finally {
this . shareLoading = false ;
}
} ,
// Create a share link for the current note (with current theme)
async createShareLink ( ) {
if ( ! this . currentNote ) return ;
this . shareLoading = true ;
try {
const notePath = this . currentNote . replace ( '.md' , '' ) ;
const encodedPath = notePath . split ( '/' ) . map ( segment => encodeURIComponent ( segment ) ) . join ( '/' ) ;
const response = await fetch ( ` /api/share/ ${ encodedPath } ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { theme : this . currentTheme || 'light' } )
} ) ;
if ( response . ok ) {
this . shareInfo = await response . json ( ) ;
this . shareInfo . shared = true ;
// Update the shared paths set
this . _sharedNotePaths . add ( this . currentNote ) ;
2026-04-22 13:38:16 +00:00
this . _syncSharedNotePathsList ( ) ;
2026-01-15 09:07:51 +00:00
} else {
const error = await response . json ( ) ;
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'share.error_creating' , { error : error . detail || 'Unknown error' } ) , { type : 'error' } ) ;
2026-01-15 09:07:51 +00:00
}
} catch ( error ) {
console . error ( 'Failed to create share link:' , error ) ;
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'share.error_creating' , { error : error . message } ) , { type : 'error' } ) ;
2026-01-15 09:07:51 +00:00
} finally {
this . shareLoading = false ;
}
} ,
// Copy share link to clipboard
async copyShareLink ( ) {
if ( ! this . shareInfo ? . url ) return ;
try {
await navigator . clipboard . writeText ( this . shareInfo . url ) ;
} catch ( error ) {
// Fallback for older browsers
const textArea = document . createElement ( 'textarea' ) ;
textArea . value = this . shareInfo . url ;
document . body . appendChild ( textArea ) ;
textArea . select ( ) ;
document . execCommand ( 'copy' ) ;
document . body . removeChild ( textArea ) ;
}
this . shareLinkCopied = true ;
setTimeout ( ( ) => {
this . shareLinkCopied = false ;
} , 2000 ) ;
} ,
// Revoke share link
async revokeShareLink ( ) {
if ( ! this . currentNote ) return ;
2026-04-17 07:39:52 +00:00
const ok = await this . confirmModalAsk ( {
message : this . t ( 'share.confirm_revoke' ) ,
danger : false ,
confirmLabel : this . t ( 'common.yes' ) ,
} ) ;
if ( ! ok ) return ;
2026-01-15 09:07:51 +00:00
this . shareLoading = true ;
try {
const notePath = this . currentNote . replace ( '.md' , '' ) ;
const encodedPath = notePath . split ( '/' ) . map ( segment => encodeURIComponent ( segment ) ) . join ( '/' ) ;
const response = await fetch ( ` /api/share/ ${ encodedPath } ` , {
method : 'DELETE'
} ) ;
if ( response . ok ) {
this . shareInfo = { shared : false } ;
// Update the shared paths set
this . _sharedNotePaths . delete ( this . currentNote ) ;
2026-04-22 13:38:16 +00:00
this . _syncSharedNotePathsList ( ) ;
2026-01-15 09:07:51 +00:00
} else {
const error = await response . json ( ) ;
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'share.error_revoking' , { error : error . detail || 'Unknown error' } ) , { type : 'error' } ) ;
2026-01-15 09:07:51 +00:00
}
} catch ( error ) {
console . error ( 'Failed to revoke share link:' , error ) ;
2026-04-16 16:17:34 +00:00
this . toast ( this . t ( 'share.error_revoking' , { error : error . message } ) , { type : 'error' } ) ;
2026-01-15 09:07:51 +00:00
} finally {
this . shareLoading = false ;
}
} ,
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 = '' ;
2026-01-18 15:00:01 +00:00
this . currentMedia = '' ;
2025-12-16 15:55:05 +00:00
this . outline = [ ] ;
2026-03-24 11:02:34 +00:00
this . backlinks = [ ] ;
2026-01-05 10:05:08 +00:00
document . title = this . appName ;
2025-11-27 10:30:49 +00:00
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 = '' ;
2026-01-18 15:00:01 +00:00
this . currentMedia = '' ;
2025-12-16 15:55:05 +00:00
this . outline = [ ] ;
2026-03-24 11:02:34 +00:00
this . backlinks = [ ] ;
2025-11-24 09:04:20 +00:00
this . mobileSidebarOpen = false ;
2026-01-05 10:05:08 +00:00
document . title = this . appName ;
2025-11-24 09:04:20 +00:00
2025-12-18 08:50:08 +00:00
// Clear undo/redo history
this . undoHistory = [ ] ;
this . redoHistory = [ ] ;
this . hasPendingHistoryChanges = false ;
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 : '' } , '' , '/' ) ;
2025-12-04 17:05:41 +00:00
} ,
2026-01-13 11:21:19 +00:00
// Mobile files/home tab - context-aware behavior
mobileFilesTabClick ( ) {
2026-01-18 15:00:01 +00:00
if ( this . currentNote || this . currentMedia || this . showGraph ) {
2026-01-13 11:21:19 +00:00
// Viewing content → go home
this . goHome ( ) ;
} else {
// On homepage → toggle files sidebar
this . activePanel = 'files' ;
this . mobileSidebarOpen = ! this . mobileSidebarOpen ;
}
} ,
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
}
}
}