NoteDiscovery/frontend/app.js

6901 lines
295 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// NoteDiscovery Frontend Application
// Configuration constants
const CONFIG = {
AUTOSAVE_DELAY: 1000, // ms - Debounce before note save (autoSave) and drawing PNG autosave (_drawingScheduleAutosave)
/** Must match drawingRedraw() fill and eraser stroke color (opaque “whiteboard”). */
DRAWING_BACKGROUND: '#ffffff',
/**
* 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.
*/
DRAWING_DEFAULT_DOC_W: 1200,
DRAWING_DEFAULT_DOC_H: 800,
/** Hard bounds applied when (re)loading or creating a drawing. */
DRAWING_MIN_DOC_DIM: 64,
DRAWING_MAX_DOC_DIM: 4096,
SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing
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)
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,
};
/** 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';
// 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 },
hideUnderscoreFolders: { key: 'hideUnderscoreFolders', type: 'boolean', default: false },
tabInsertsTab: { key: 'tabInsertsTab', type: 'boolean', default: false },
// String settings
sortMode: { key: 'sortMode', type: 'string', default: 'a-z' },
// 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: [] },
};
// Centralized error handling
const ErrorHandler = {
/**
* Handle errors consistently across the app
* @param {string} operation - The operation that failed (e.g., "load notes", "save note")
* @param {Error} error - The error object
* @param {boolean} notify - Whether to show a toast (listens on notediscovery:toast)
*/
handle(operation, error, notify = true) {
console.error(`Failed to ${operation}:`, error);
if (notify) {
const message = `Failed to ${operation}. Please try again.`;
window.dispatchEvent(new CustomEvent('notediscovery:toast', {
detail: { message, type: 'error' }
}));
}
}
};
/**
* 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('/') };
}
};
function noteApp() {
return {
// App state
appName: 'NoteDiscovery',
appVersion: '0.0.0',
authEnabled: false,
demoMode: false,
alreadyDonated: false,
notes: [],
currentNote: '',
currentNoteName: '',
noteContent: '',
viewMode: 'split', // 'edit', 'split', 'preview'
searchQuery: '',
// Graph state (separate overlay, doesn't affect viewMode)
showGraph: false,
graphInstance: null,
graphLoaded: false,
graphData: null,
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,
linkCopied: false,
zenMode: false,
previousViewMode: 'split',
favorites: [],
favoritesSet: new Set(), // For O(1) lookups
favoritesExpanded: true,
saveTimeout: null,
// 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
},
// Media lookup map for O(1) media wikilink resolution (built on loadNotes)
// Maps media filename (case-insensitive) -> full path
_mediaLookup: new Map(),
// Preview rendering debounce
_previewDebounceTimeout: null,
_lastRenderedContent: '',
_lastRenderedNote: '',
_cachedRenderedHTML: '',
_mathDebounceTimeout: null,
_mermaidDebounceTimeout: null,
// Theme state
currentTheme: 'light',
availableThemes: [],
// Locale/i18n state
currentLocale: localStorage.getItem('locale') || 'en-US',
availableLocales: [],
// Translations loaded from backend (preloaded before Alpine init via window.__preloadedTranslations)
translations: window.__preloadedTranslations || {},
// Syntax highlighting
syntaxHighlightEnabled: false,
syntaxHighlightTimeout: null,
// Readable line length (preview max-width)
readableLineLength: true,
// Hide underscore-prefixed folders (_attachments, _templates) from sidebar
// Read synchronously to prevent flash on initial render
hideUnderscoreFolders: localStorage.getItem('hideUnderscoreFolders') === 'true',
// Tab key inserts tab character instead of changing focus
tabInsertsTab: localStorage.getItem('tabInsertsTab') === 'true',
// Note sorting mode (a-z, z-a, newest, oldest, largest, smallest)
sortMode: localStorage.getItem('sortMode') || 'a-z',
// Icon rail / panel state
activePanel: 'files', // 'files', 'search', 'tags', 'outline', 'backlinks', 'shared', 'settings'
// Folder state
folderTree: [],
allFolders: [],
expandedFolders: new Set(),
dragOverFolder: null, // Track which folder is being hovered during drag
// Tags state
allTags: {},
selectedTags: [],
tagsExpanded: false,
tagReloadTimeout: null, // For debouncing tag reloads
// Search state
searchDebounceTimeout: null,
isSearching: false,
// Outline (TOC) state
outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...]
// Backlinks state
backlinks: [], // [{path: 'note.md', name: 'Note', references: [{line_number: 5, context: '...', type: 'wikilink'}]}]
// Scroll sync state
isScrolling: false,
// Unified drag state for notes, folders, and media
draggedItem: null, // { path: string, type: 'note' | 'folder' | 'image' | 'audio' | 'video' | 'document' }
dropTarget: null, // 'editor' | 'folder' | null
// Undo/Redo history
undoHistory: [],
redoHistory: [],
maxHistorySize: CONFIG.MAX_UNDO_HISTORY,
isUndoRedo: false,
hasPendingHistoryChanges: false,
// Stats plugin state
statsPluginEnabled: false,
noteStats: null,
statsExpanded: false,
// Note metadata (frontmatter) state
noteMetadata: null,
metadataExpanded: false,
_lastFrontmatter: null, // Cache to avoid re-parsing unchanged frontmatter
// Sidebar resize state
sidebarWidth: CONFIG.DEFAULT_SIDEBAR_WIDTH,
isResizing: false,
// Mobile sidebar state
mobileSidebarOpen: false,
// Split view resize state
editorWidth: 50, // percentage
isResizingSplit: false,
// Dropdown state
showNewDropdown: false,
dropdownTargetFolder: null, // Folder context for "New" dropdown ('' = root, null = not set)
dropdownPosition: { top: 0, left: 0 }, // Position for contextual dropdown
// Template state
showTemplateModal: false,
availableTemplates: [],
selectedTemplate: '',
newTemplateNoteName: '',
// 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: '',
// Generic confirm dialog (replaces window.confirm)
showConfirmModal: false,
confirmModalTitle: '',
confirmModalMessage: '',
confirmModalDanger: true,
confirmModalConfirmLabel: '',
confirmModalCancelLabel: '',
_confirmModalResolve: null,
// Share state
showShareModal: false,
shareInfo: null,
shareLoading: false,
showShareQR: false,
shareLinkCopied: false,
_sharedNotePaths: new Set(), // O(1) lookup for shared note indicators
_sharedNotePathsList: [], // sorted paths, mirrors Set for reactive sidebar panel
// Quick Switcher state (Ctrl+Alt+P)
showQuickSwitcher: false,
quickSwitcherQuery: '',
quickSwitcherIndex: 0,
quickSwitcherResults: [],
// Non-blocking notifications (replaces window.alert)
toasts: [],
_toastIdSeq: 0,
// Homepage state
selectedHomepageFolder: '',
_homepageCache: {
folderPath: null,
notes: null,
folders: null,
breadcrumb: null
},
// Homepage constants
HOMEPAGE_MAX_NOTES: 50,
// Computed-like helpers for homepage (cached for performance)
homepageNotes() {
// Return cached result if folder hasn't changed
if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.notes) {
return this._homepageCache.notes;
}
if (!this.folderTree || typeof this.folderTree !== 'object') {
return [];
}
const folderNode = this.getFolderNode(this.selectedHomepageFolder || '');
const result = (folderNode && Array.isArray(folderNode.notes)) ? folderNode.notes : [];
// Cache the result
this._homepageCache.notes = result;
this._homepageCache.folderPath = this.selectedHomepageFolder;
return result;
},
homepageFolders() {
// Return cached result if folder hasn't changed
if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.folders) {
return this._homepageCache.folders;
}
if (!this.folderTree || typeof this.folderTree !== 'object') {
return [];
}
// Get child folders
let childFolders = [];
if (!this.selectedHomepageFolder) {
// Root level: all top-level folders
childFolders = Object.entries(this.folderTree)
.filter(([key]) => key !== '__root__')
.map(([, folder]) => folder);
} else {
// Inside a folder: get its children
const parentFolder = this.getFolderNode(this.selectedHomepageFolder);
if (parentFolder && parentFolder.children) {
childFolders = Object.values(parentFolder.children);
}
}
// Map to simplified structure (note count already cached in folder node)
const result = childFolders
.map(folder => ({
name: folder.name,
path: folder.path,
noteCount: folder.noteCount || 0 // Use pre-calculated count
}))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
// Cache the result
this._homepageCache.folders = result;
this._homepageCache.folderPath = this.selectedHomepageFolder;
return result;
},
homepageBreadcrumb() {
// Return cached result if folder hasn't changed
if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.breadcrumb) {
return this._homepageCache.breadcrumb;
}
const breadcrumb = [{ name: this.t('homepage.title'), path: '' }];
if (this.selectedHomepageFolder) {
const parts = this.selectedHomepageFolder.split('/').filter(Boolean);
let currentPath = '';
parts.forEach(part => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
breadcrumb.push({ name: part, path: currentPath });
});
}
// 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];
},
// 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'
});
},
getFolderNode(folderPath = '') {
if (!this.folderTree || typeof this.folderTree !== 'object') {
return null;
}
if (!folderPath) {
return this.folderTree['__root__'] || { name: '', path: '', children: {}, notes: [], noteCount: 0 };
}
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;
},
// Mermaid state cache
lastMermaidTheme: null,
// Media viewer state
currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility)
currentMediaType: 'image', // 'image', 'audio', 'video', 'document', 'drawing'
// 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.
drawingTool: 'freehand',
drawingColor: '#1a1a1a',
drawingLineWidth: 4,
drawingOps: [],
/**
* 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,
drawingRedoStack: [],
drawingDraft: null,
drawingIsPointerDown: false,
/** True after the PNG from disk has been decoded into _drawingBaseImage; false after Clear. */
drawingHasRasterFromFile: false,
/** Prevents overlapping drawingSave() runs (Ctrl+S + autosave + fast retries). */
_drawingSaveInFlight: false,
/** If true, run drawingSave again after the current one finishes (coalesce). */
_drawingSaveQueued: false,
_drawingAutosaveTimeout: null,
// DOM element cache (to avoid repeated querySelector calls)
_domCache: {
editor: null,
previewContainer: null,
previewContent: null
},
// Initialize app
async init() {
// Prevent double initialization (Alpine.js may call x-init twice in some cases)
if (window.__noteapp_initialized) return;
window.__noteapp_initialized = true;
// Store global reference for native event handlers in x-html content
window.$root = this;
// ESC key to cancel drag operations
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.draggedItem) {
this.cancelDrag();
}
});
await this.loadConfig();
await this.loadThemes();
await this.initTheme();
await this.loadAvailableLocales();
// Note: Translations are preloaded synchronously before Alpine init (see index.html)
// loadLocale() is only called when user changes language from settings
await this.loadNotes();
await this.loadSharedNotePaths();
await this.loadTemplates();
await this.checkStatsPlugin();
this.loadLocalSettings();
// Parse URL and load specific note if provided
this.loadItemFromURL();
// Set initial homepage state ONLY if we're actually on the homepage
if (window.location.pathname === '/') {
window.history.replaceState({ homepageFolder: '' }, '', '/');
document.title = this.appName;
}
// Listen for browser back/forward navigation
window.addEventListener('popstate', (e) => {
if (e.state && e.state.notePath) {
// Navigating to a note
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();
}
} else if (e.state && e.state.mediaPath) {
// Navigating to a media file
this.viewMedia(e.state.mediaPath, null, false);
} else {
// Navigating back to homepage
this.currentNote = '';
this.noteContent = '';
this.currentNoteName = '';
this.outline = [];
this.backlinks = [];
this.shareInfo = null; // Reset share info
document.title = this.appName;
// 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 = '';
}
// Invalidate cache to force recalculation
this._homepageCache = {
folderPath: null,
notes: null,
folders: null,
breadcrumb: null
};
// Clear search
this.searchQuery = '';
this.searchResults = [];
this.clearSearchHighlights();
}
});
// Cache DOM references after initial render
this.$nextTick(() => {
this.refreshDOMCache();
});
// Setup mobile view mode handler
this.setupMobileViewMode();
// Watch view mode changes and auto-save
this.$watch('viewMode', (newValue) => {
this.saveViewMode();
// Scroll to top when switching modes
this.$nextTick(() => {
this.scrollToTop();
});
});
// Watch for changes in note content to re-apply search highlights
this.$watch('noteContent', () => {
if (this.currentSearchHighlight) {
// Re-apply highlights after content changes (with small delay for render)
this.$nextTick(() => {
setTimeout(() => {
// Don't focus editor during content changes (false)
this.highlightSearchTerm(this.currentSearchHighlight, false);
}, 50);
});
}
});
// Watch tags panel expanded state and save to localStorage
this.$watch('tagsExpanded', () => {
this.saveTagsExpanded();
});
// Watch favorites expanded state and save to localStorage
this.$watch('favoritesExpanded', () => {
this.saveFavoritesExpanded();
});
// Setup keyboard shortcuts (only once to prevent double triggers)
if (!window.__noteapp_shortcuts_initialized) {
window.__noteapp_shortcuts_initialized = true;
window.addEventListener('keydown', (e) => {
// Use e.key (not e.code) for letter keys to support non-QWERTY keyboard layouts
// Ctrl/Cmd + S to save (drawing saves PNG; notes save markdown)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
if (this.currentMedia && this.currentMediaType === 'drawing') {
e.preventDefault();
this.drawingSave();
} else {
e.preventDefault();
this.saveNote();
}
}
// Ctrl/Cmd + Alt + P for Quick Switcher
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
this.openQuickSwitcher();
return;
}
// Ctrl/Cmd + Alt/Option + N for new note
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'n') {
e.preventDefault();
this.createNote();
}
// Ctrl/Cmd + Alt/Option + F for new folder
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'f') {
e.preventDefault();
this.createFolder();
}
// Ctrl/Cmd + Z for undo (drawing vs note editor)
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
if (this.currentMedia && this.currentMediaType === 'drawing') {
e.preventDefault();
this.drawingUndo();
} else {
e.preventDefault();
this.undo();
}
}
// Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
if (this.currentMedia && this.currentMediaType === 'drawing') {
e.preventDefault();
this.drawingRedo();
} else {
e.preventDefault();
this.redo();
}
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
if (this.currentMedia && this.currentMediaType === 'drawing') {
e.preventDefault();
this.drawingRedo();
} else {
e.preventDefault();
this.redo();
}
}
// F3 for next search match
if (e.code === 'F3' && !e.shiftKey) {
e.preventDefault();
this.nextMatch();
}
// Shift + F3 for previous search match
if (e.code === 'F3' && e.shiftKey) {
e.preventDefault();
this.previousMatch();
}
// 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
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
e.preventDefault();
this.wrapSelection('**', '**', 'bold text');
}
// Ctrl/Cmd + I for italic
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'i') {
e.preventDefault();
this.wrapSelection('*', '*', 'italic text');
}
// Ctrl/Cmd + K for link
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
this.insertLink();
}
// Ctrl/Cmd + Alt/Option + T for table
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 't') {
e.preventDefault();
this.insertTable();
}
// Ctrl/Cmd + Alt/Option + Z for Zen mode
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'z') {
e.preventDefault();
this.toggleZenMode();
}
}
// Escape to exit Zen mode (works anywhere)
if (e.key === 'Escape' && this.zenMode) {
e.preventDefault();
this.toggleZenMode();
}
});
}
// 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');
}
});
}
// 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;
}
});
// 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
});
});
}
},
// Load app configuration
async loadConfig() {
try {
const response = await fetch('/api/config');
const config = await response.json();
this.appName = config.name;
this.appVersion = config.version || '0.0.0';
this.authEnabled = config.authentication?.enabled || false;
this.demoMode = config.demoMode || false;
this.alreadyDonated = config.alreadyDonated || false;
} 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);
},
// Syntax highlighting toggle
toggleSyntaxHighlight() {
this.syntaxHighlightEnabled = !this.syntaxHighlightEnabled;
localStorage.setItem('syntaxHighlightEnabled', this.syntaxHighlightEnabled);
if (this.syntaxHighlightEnabled) {
this.updateSyntaxHighlight();
}
},
// 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;
}
}
// 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);
},
// Hide underscore folders toggle (hides _attachments, _templates, etc. from sidebar)
toggleHideUnderscoreFolders() {
this.hideUnderscoreFolders = !this.hideUnderscoreFolders;
localStorage.setItem('hideUnderscoreFolders', this.hideUnderscoreFolders);
},
// Tab inserts tab toggle (Tab key inserts tab character instead of changing focus)
toggleTabInsertsTab() {
this.tabInsertsTab = !this.tabInsertsTab;
localStorage.setItem('tabInsertsTab', this.tabInsertsTab);
},
// Handle Tab key in editor (inserts tab if setting enabled)
handleTabKey(event) {
if (!this.tabInsertsTab) return;
event.preventDefault();
const textarea = event.target;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
this.noteContent = this.noteContent.substring(0, start) + '\t' + this.noteContent.substring(end);
this.$nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1;
});
this.autoSave();
},
/**
* Enter: continue blockquote / bullet / ordered list / task list; second Enter on empty item exits (see editor-markdown-continue.js).
*/
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();
},
// 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());
},
// Update syntax highlight overlay (debounced, called on input)
updateSyntaxHighlight() {
if (!this.syntaxHighlightEnabled) return;
clearTimeout(this.syntaxHighlightTimeout);
this.syntaxHighlightTimeout = setTimeout(() => {
const overlay = document.getElementById('syntax-overlay');
if (overlay) {
overlay.innerHTML = this.highlightMarkdown(this.noteContent);
}
}, 50); // 50ms debounce
},
// Sync overlay scroll with textarea
syncOverlayScroll() {
const textarea = document.getElementById('note-editor');
const overlay = document.getElementById('syntax-overlay');
if (textarea && overlay) {
overlay.scrollTop = textarea.scrollTop;
overlay.scrollLeft = textarea.scrollLeft;
}
},
// Highlight markdown syntax
highlightMarkdown(text) {
if (!text) return '';
// Escape HTML first
let html = this.escapeHtml(text);
// Store code blocks and inline code with placeholders to protect from other patterns
const codePlaceholders = [];
// Code blocks FIRST - protect them before anything else
html = html.replace(/(```[\s\S]*?```)/g, (match) => {
codePlaceholders.push('<span class="md-codeblock">' + match + '</span>');
return `\x00CODE${codePlaceholders.length - 1}\x00`;
});
// 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 `\x00CODE${codePlaceholders.length - 1}\x00`;
});
}
// Inline code - protect it
html = html.replace(/`([^`\n]+)`/g, (match) => {
codePlaceholders.push('<span class="md-code">' + match + '</span>');
return `\x00CODE${codePlaceholders.length - 1}\x00`;
});
// Now apply other patterns (they won't match inside protected code)
// 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>');
// Bold (must come before italic)
html = html.replace(/\*\*([^*]+)\*\*/g, '<span class="md-bold">**$1**</span>');
html = html.replace(/__([^_]+)__/g, '<span class="md-bold">__$1__</span>');
// Italic
html = html.replace(/(?<![*\\])\*([^*\n]+)\*(?!\*)/g, '<span class="md-italic">*$1*</span>');
html = html.replace(/(?<![_\\])_([^_\n]+)_(?!_)/g, '<span class="md-italic">_$1_</span>');
// Wikilinks [[...]]
html = html.replace(/\[\[([^\]]+)\]\]/g, '<span class="md-wikilink">[[$1]]</span>');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<span class="md-link">[$1]</span><span class="md-link-url">($2)</span>');
// Lists - 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}`;
});
html = html.replace(/^(\s*)(\d+\.)([ \t])(.*)$/gm, (match, indent, bullet, space, rest) => {
return `${indent}<span class="md-list">${bullet}</span>${space}${rest}`;
});
// Blockquotes
html = html.replace(/^(&gt;.*)$/gm, '<span class="md-blockquote">$1</span>');
// Horizontal rules
html = html.replace(/^([-*_]{3,})$/gm, '<span class="md-hr">$1</span>');
// Restore protected code blocks
html = html.replace(/\x00CODE(\d+)\x00/g, (match, index) => codePlaceholders[parseInt(index)]);
// Add trailing space to match textarea's phantom line for cursor
// This ensures the overlay and textarea have the same content height
html += '\n ';
return html;
},
// 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';
}
}
// 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);
}
// Refresh graph if visible (longer delay to ensure CSS is applied)
if (this.showGraph) {
setTimeout(() => this.initGraph(), 300);
}
// 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);
}
} catch (error) {
console.error('Failed to load theme:', error);
}
},
// ==================== 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}}}`);
},
/**
* 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);
},
/**
* 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');
}
},
// 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 ====================
// Load all notes
async loadNotes() {
try {
const response = await fetch('/api/notes');
const data = await response.json();
this.notes = data.notes;
this.allFolders = data.folders || [];
this.buildNoteLookupMaps(); // Build O(1) lookup maps
this.buildFolderTree();
await this.loadTags(); // Load tags after notes are loaded
} catch (error) {
ErrorHandler.handle('load notes', error);
}
},
// 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();
this._mediaLookup.clear();
for (const note of this.notes) {
const path = note.path;
const pathLower = path.toLowerCase();
const name = note.name;
const nameLower = name.toLowerCase();
// Handle media files separately - build media lookup map
if (note.type !== 'note') {
// 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();
// First match wins if there are duplicates
if (!this._mediaLookup.has(filenameWithExt)) {
this._mediaLookup.set(filenameWithExt, path);
}
continue;
}
// Notes only from here
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')
);
},
// Resolve media wikilink to full path (O(1) lookup)
// Returns the full path if found, null otherwise
resolveMediaWikilink(mediaName) {
const nameLower = mediaName.toLowerCase();
return this._mediaLookup.get(nameLower) || null;
},
// 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('/');
},
// Load all tags
async loadTags() {
try {
const response = await fetch('/api/tags');
const data = await response.json();
this.allTags = data.tags || {};
} catch (error) {
ErrorHandler.handle('load tags', error, false); // Optional: no toast
}
},
// 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();
},
// ========================================================================
// Template Methods
// ========================================================================
// Load available templates from _templates folder
async loadTemplates() {
try {
const response = await fetch('/api/templates');
const data = await response.json();
this.availableTemplates = data.templates || [];
} catch (error) {
ErrorHandler.handle('load templates', error, false); // Optional: no toast
}
},
// Create a new note from a template
async createNoteFromTemplate() {
if (!this.selectedTemplate || !this.newTemplateNoteName.trim()) {
return;
}
try {
// Validate the note name
const validation = FilenameValidator.validateFilename(this.newTemplateNoteName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
return;
}
// Determine the note path based on dropdown context
let notePath = validation.sanitized;
if (!notePath.endsWith('.md')) {
notePath += '.md';
}
const targetFolder = this.inferredNewItemTargetFolder();
// If we have a target folder, create note in that folder
if (targetFolder) {
notePath = `${targetFolder}/${notePath}`;
}
// CRITICAL: Check if note already exists
const existingNote = this.notes.find(note => note.path === notePath);
if (existingNote) {
this.toast(this.t('notes.already_exists', { name: validation.sanitized }), { type: 'warning' });
return;
}
// 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();
this.toast(error.detail || this.t('templates.create_failed'), { type: 'error' });
return;
}
const data = await response.json();
// Close modal and reset state
this.showTemplateModal = false;
this.selectedTemplate = '';
this.newTemplateNoteName = '';
// Reload notes and open the new note
await this.loadNotes();
await this.loadNote(data.path);
this.focusEditorForNewNote();
} catch (error) {
ErrorHandler.handle('create note from template', error);
}
},
// Clear all tag filters
clearTagFilters() {
this.selectedTags = [];
// Apply unified filtering
this.applyFilters();
},
// ========================================================================
// Outline (TOC) Methods
// ========================================================================
// Extract headings from markdown content for the outline
extractOutline(content) {
if (!content) {
this.outline = [];
this.backlinks = [];
return;
}
const headings = [];
const lines = content.split('\n');
const slugCounts = {}; // Track duplicate slugs
// Skip frontmatter and code blocks
let inFrontmatter = false;
let inCodeBlock = false;
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;
}
// Handle fenced code blocks (``` or ~~~)
if (line.trim().startsWith('```') || line.trim().startsWith('~~~')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) {
continue;
}
// 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);
}
}
},
// Navigate to a backlink (note that links to current note)
navigateToBacklink(backlinkPath) {
this.loadNote(backlinkPath);
},
// Unified filtering logic combining tags and text search
async applyFilters() {
const hasTextSearch = this.searchQuery.trim().length > 0;
const hasTagFilter = this.selectedTags.length > 0;
// Case 1: No filters at all → show full folder tree
if (!hasTextSearch && !hasTagFilter) {
this.isSearching = false;
this.searchResults = [];
this.currentSearchHighlight = '';
this.clearSearchHighlights();
this.buildFolderTree();
return;
}
// Case 2: Only tag filter → convert to flat list of matching notes
if (hasTagFilter && !hasTextSearch) {
this.isSearching = false;
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) {
this.isSearching = true;
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);
this.searchResults = [];
} finally {
this.isSearching = false;
}
}
},
// 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 : [];
},
// ==================== 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
this.favorites = [...this.favorites, path];
}
// Recreate Set from array for consistency
this.favoritesSet = new Set(this.favorites);
this.saveFavorites();
},
// Get favorite notes with full details (for display)
get favoriteNotes() {
return this.favorites
.map(path => {
// 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());
}
if (!note) return null;
return {
path: note.path, // Use actual path from notes (fixes case issues)
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);
}
},
// 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);
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 });
// For older dates, show the date in selected locale
return modified.toLocaleDateString(this.currentLocale, { month: 'short', day: 'numeric' });
},
// 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 [];
}
},
// 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;
});
});
// Add ALL notes to their folders (no filtering - tree only shown when no filters active)
this.notes.forEach(note => {
if (!note.folder) {
// Root level note
if (!tree['__root__']) {
tree['__root__'] = {
name: '',
path: '',
children: {},
notes: []
};
}
tree['__root__'].notes.push(note);
} else {
// Navigate to the folder and add note
const parts = note.folder.split('/');
let current = tree;
for (let i = 0; i < parts.length; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {
name: parts[i],
path: parts.slice(0, i + 1).join('/'),
children: {},
notes: []
};
}
if (i === parts.length - 1) {
current[parts[i]].notes.push(note);
} else {
current = current[parts[i]].children;
}
}
}
});
// Sort all notes arrays alphabetically (create new sorted arrays for reactivity)
const sortNotes = (obj) => {
if (obj.notes && obj.notes.length > 0) {
// Create a new sorted array instead of mutating for Alpine reactivity
obj.notes = [...obj.notes].sort(this.getSortComparator());
}
if (obj.children && Object.keys(obj.children).length > 0) {
Object.values(obj.children).forEach(child => sortNotes(child));
}
};
// Sort notes in root (create new array for reactivity)
if (tree['__root__'] && tree['__root__'].notes) {
tree['__root__'].notes = [...tree['__root__'].notes].sort(this.getSortComparator());
}
// Sort notes in all folders
Object.values(tree).forEach(folder => {
if (folder.path !== undefined) { // Skip __root__ as it was already sorted
sortNotes(folder);
}
});
// 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;
},
// =====================================================================
// 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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
},
// 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);
},
// Item (note/media) handlers - read from dataset
handleItemClick(el) {
this.openItem(el.dataset.path, el.dataset.type);
},
handleItemHover(el, isEnter) {
const path = el.dataset.path;
if (path !== this.currentNote && path !== this.currentMedia) {
el.style.backgroundColor = isEnter ? 'var(--bg-hover)' : 'transparent';
}
},
handleDeleteItemClick(el, event) {
event.stopPropagation();
if (el.dataset.type !== 'note') {
this.deleteMedia(el.dataset.path);
} else {
this.deleteNote(el.dataset.path, el.dataset.name);
}
},
// =====================================================================
// FOLDER TREE RENDERING
// =====================================================================
// Render folder recursively (helper for deep nesting)
// Uses data-* attributes to store path/name, avoiding JS string escaping issues
renderFolderRecursive(folder, level = 0, isTopLevel = false) {
if (!folder) return '';
let html = '';
const isExpanded = this.expandedFolders.has(folder.path);
const esc = (s) => this.escapeHtmlAttr(s); // Shorthand for HTML escaping
// Render this folder's header
// Note: Using native event handlers with data-* attributes instead of Alpine directives
// because x-html doesn't process Alpine directives in dynamically generated content
html += `
<div>
<div
data-path="${esc(folder.path)}"
data-name="${esc(folder.name)}"
draggable="true"
ondragstart="window.$root.onItemDragStart(this.dataset.path, 'folder', event)"
ondragend="window.$root.onItemDragEnd()"
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)"
class="folder-item hover-accent px-2 py-1 text-sm relative"
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"
style="color: var(--text-tertiary); cursor: pointer; transition: transform 0.2s; pointer-events: none; margin-left: -5px; ${isExpanded ? 'transform: rotate(90deg);' : ''}"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 4l4 4-4 4V4z"/>
</svg>
</button>
<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)}">
<span>${esc(folder.name)}</span>
${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>` : ''}
</span>
</div>
<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()">
<button
data-path="${esc(folder.path)}"
onclick="window.$root.handleNewItemClick(this, event)"
class="px-1.5 py-0.5 text-xs rounded hover:brightness-110"
style="background-color: var(--bg-tertiary); color: var(--text-secondary);"
title="${esc(this.t('sidebar.new_note'))}"
>+</button>
<button
data-path="${esc(folder.path)}"
data-name="${esc(folder.name)}"
onclick="window.$root.handleRenameFolderClick(this, event)"
class="px-1.5 py-0.5 text-xs rounded hover:brightness-110"
style="background-color: var(--bg-tertiary); color: var(--text-secondary);"
title="${esc(this.t('sidebar.rename_folder'))}"
>✏️</button>
<button
data-path="${esc(folder.path)}"
data-name="${esc(folder.name)}"
onclick="window.$root.handleDeleteFolderClick(this, event)"
class="px-1 py-0.5 text-xs rounded hover:brightness-110"
style="color: var(--error);"
title="${esc(this.t('sidebar.delete_folder'))}"
>
<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"></path>
</svg>
</button>
</div>
</div>
`;
// If expanded, render folder contents (child folders + notes)
if (isExpanded) {
html += `<div class="folder-contents" style="padding-left: 10px;">`;
// First, render child folders (if any)
if (folder.children && Object.keys(folder.children).length > 0) {
const children = Object.entries(folder.children)
.filter(([k, v]) => !this.hideUnderscoreFolders || !v.name.startsWith('_'))
.sort((a, b) => this.getFolderSortComparator()(a[1], b[1]));
children.forEach(([childKey, childFolder]) => {
html += this.renderFolderRecursive(childFolder, 0, false);
});
}
// Then, render notes and images in this folder (after subfolders)
if (folder.notes && folder.notes.length > 0) {
folder.notes.forEach(note => {
html += this.renderNoteItem(note);
});
}
html += `</div>`; // Close folder-contents
}
html += `</div>`; // Close folder wrapper
return html;
},
// 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);
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>` : '';
const icon = this.getMediaIcon(note.type);
const deleteTitle = !isMediaFile
? this.t('toolbar.delete_note')
: note.type === 'drawing'
? this.t('toolbar.delete_drawing')
: this.t('toolbar.delete_image');
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)}</span>
<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);"
title="${esc(deleteTitle)}"
>
<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"></path>
</svg>
</button>
</div>
`;
},
// Render root-level items (notes and media not in any folder)
renderRootItems() {
const root = this.folderTree['__root__'];
if (!root || !root.notes || root.notes.length === 0) {
return '';
}
return root.notes.map(note => this.renderNoteItem(note)).join('');
},
// Toggle folder expansion
toggleFolder(folderPath) {
if (this.expandedFolders.has(folderPath)) {
this.expandedFolders.delete(folderPath);
} else {
this.expandedFolders.add(folderPath);
}
// Force Alpine reactivity by creating new Set reference
this.expandedFolders = new Set(this.expandedFolders);
},
// Check if folder is expanded
isFolderExpanded(folderPath) {
return this.expandedFolders.has(folderPath);
},
// Expand all folders
expandAllFolders() {
this.allFolders.forEach(folder => {
this.expandedFolders.add(folder);
});
// Force Alpine reactivity
this.expandedFolders = new Set(this.expandedFolders);
},
// Collapse all folders
collapseAllFolders() {
this.expandedFolders.clear();
// Force Alpine reactivity
this.expandedFolders = new Set(this.expandedFolders);
},
// 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);
});
// Force Alpine reactivity
this.expandedFolders = new Set(this.expandedFolders);
},
// 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;
const noteElements = sidebar.querySelectorAll('.note-item');
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
},
// Unified drag and drop handlers for notes, folders, and media
onItemDragStart(itemPath, itemType, event) {
// Set unified drag state
this.draggedItem = { path: itemPath, type: itemType };
// Make drag image semi-transparent
if (event.target) {
event.target.style.opacity = '0.5';
}
event.dataTransfer.effectAllowed = 'all';
},
onItemDragEnd() {
this.draggedItem = null;
this.dropTarget = null;
this.dragOverFolder = null;
// Reset opacity of all draggable items
document.querySelectorAll('.note-item, .folder-header').forEach(el => el.style.opacity = '1');
// Reset drag-over class
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
},
// Handle dragover on editor to show cursor position
onEditorDragOver(event) {
if (!this.draggedItem) return;
event.preventDefault();
this.dropTarget = 'editor';
// Focus the textarea
const textarea = event.target;
if (textarea.tagName !== 'TEXTAREA') return;
textarea.focus();
// Calculate cursor position from mouse coordinates
const pos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
if (pos >= 0) {
textarea.setSelectionRange(pos, pos);
}
},
// 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);
},
// Handle dragenter on editor
onEditorDragEnter(event) {
if (!this.draggedItem) return;
event.preventDefault();
this.dropTarget = 'editor';
},
// Handle dragleave on editor
onEditorDragLeave(event) {
// Only clear dropTarget if we're actually leaving the editor
// (not just moving between child elements)
if (event.target.tagName === 'TEXTAREA') {
this.dropTarget = null;
}
},
// Handle drop into editor to create internal link or upload media
async onEditorDrop(event) {
event.preventDefault();
this.dropTarget = null;
// Check if files are being dropped (media from file system)
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
await this.handleMediaDrop(event);
return;
}
// Otherwise, handle note/media link drop from sidebar
if (!this.draggedItem) return;
const notePath = this.draggedItem.path;
const isMediaFile = this.draggedItem.type !== 'note';
let link;
if (isMediaFile) {
// For media files (images, audio, video, PDF), use wiki-style embed link
const filename = notePath.split('/').pop();
link = `![[${filename}]]`;
} 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})`;
}
// Insert at drop position
const textarea = event.target;
// Recalculate position from drop coordinates for accuracy
let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
const textBefore = this.noteContent.substring(0, cursorPos);
const textAfter = this.noteContent.substring(cursorPos);
this.noteContent = textBefore + link + textAfter;
// Move cursor after the link
this.$nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = cursorPos + link.length;
textarea.focus();
});
// Trigger autosave
this.autoSave();
this.draggedItem = null;
},
/**
* 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`;
},
// Handle media files dropped into editor
async handleMediaDrop(event) {
const files = Array.from(event.dataTransfer.files);
// 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) {
this.toast(this.t('media.no_valid_files'), { type: 'warning' });
return;
}
const textarea = event.target;
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;
}
let uploaded = false;
for (const file of mediaFiles) {
try {
const mediaPath = await this.uploadMedia(file, notePath);
if (mediaPath) {
uploaded = true;
if (this.currentNote) {
await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
}
}
} catch (error) {
ErrorHandler.handle(`upload file ${file.name}`, error);
}
}
if (uploaded && !this.currentNote) {
await this.loadNotes();
}
},
// Upload a media file (image, audio, video, PDF)
// 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;
const formData = new FormData();
formData.append('file', file);
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 || '');
}
try {
const response = await fetch('/api/upload-media', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Upload failed');
}
const data = await response.json();
return data.path;
} catch (error) {
throw error;
}
},
// Insert 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) {
// Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png")
const filename = mediaPath.split('/').pop();
// Use wiki-style embed link: ![[filename.png]] or ![[filename.png|alt text]]
// 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}]]`;
// Reload notes FIRST to update image lookup maps before preview renders
await this.loadNotes();
const textBefore = this.noteContent.substring(0, cursorPos);
const textAfter = this.noteContent.substring(cursorPos);
this.noteContent = textBefore + markdown + '\n' + textAfter;
// Trigger autosave
this.autoSave();
},
// Handle paste event for clipboard media (images)
async handlePaste(event) {
if (!this.currentNote) return;
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault();
const blob = item.getAsFile();
if (blob) {
try {
const textarea = event.target;
const cursorPos = textarea.selectionStart || 0;
// Create a simple filename - backend will add timestamp to prevent collisions
const ext = item.type.split('/')[1] || 'png';
const filename = `pasted-image.${ext}`;
// Create a File from the blob
const file = new File([blob], filename, { type: item.type });
const mediaPath = await this.uploadMedia(file, this.currentNote);
if (mediaPath) {
await this.insertMediaMarkdown(mediaPath, filename, cursorPos);
}
} catch (error) {
ErrorHandler.handle('paste media', error);
}
}
break; // Only handle first media item
}
}
},
// Media type detection based on file extension (and drawing-*.png convention)
getMediaType(filename) {
if (!filename) return null;
const base = filename.split('/').pop().toLowerCase();
if (base.startsWith('drawing-') && base.endsWith('.png')) {
return 'drawing';
}
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: '🖼️',
drawing: '✏️',
audio: '🎵',
video: '🎬',
document: '📄',
};
return icons[type] || '';
},
// Open a note or media file (unified handler for sidebar/homepage clicks)
openItem(path, type = 'note', searchHighlight = '') {
this.showGraph = false;
// 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);
} else {
this.loadNote(path, true, searchHighlight);
}
},
// View a media file (image, audio, video, PDF) in the main pane
viewMedia(mediaPath, mediaType = null, updateHistory = true) {
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
this._drawingCancelAutosave();
}
this.showGraph = false; // Ensure graph is closed
this.currentNote = '';
this.currentNoteName = '';
this.noteContent = '';
this.currentMedia = mediaPath; // Reuse currentMedia for all media
this.currentMediaType = mediaType || this.getMediaType(mediaPath) || 'image';
this.shareInfo = null; // Reset share info
this.viewMode = 'preview'; // Use preview mode to show media
// Update browser tab title
const fileName = mediaPath.split('/').pop();
document.title = `${fileName} - ${this.appName}`;
// Expand folder tree to show the media file
this.expandFolderForNote(mediaPath);
// Update browser URL
if (updateHistory) {
// Encode each path segment to handle special characters
const encodedPath = mediaPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
window.history.pushState(
{ mediaPath: mediaPath },
'',
`/${encodedPath}`
);
}
// 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();
});
}
},
// 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();
const ok = await this.confirmModalAsk({
message: this.t('media.confirm_delete', { name: filename }),
});
if (!ok) return;
try {
const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
method: 'DELETE'
});
if (response.ok) {
await this.loadNotes(); // Refresh tree
// Clear viewer if deleting currently viewed media
if (this.currentMedia === mediaPath) {
this.currentMedia = '';
}
} else {
throw new Error('Failed to delete media file');
}
} catch (error) {
ErrorHandler.handle('delete media', error);
}
},
/**
* 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).
*/
/**
* 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 = {}) {
const targetFolder = this.inferredNewItemTargetFolder();
this.closeDropdown();
const w = this._drawingClampDim(opts.docW, CONFIG.DRAWING_DEFAULT_DOC_W);
const h = this._drawingClampDim(opts.docH, CONFIG.DRAWING_DEFAULT_DOC_H);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = CONFIG.DRAWING_BACKGROUND;
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 {
const path = await this.uploadMedia(file, '', {
nextToNotes: true,
contentFolder: targetFolder,
});
await this.loadNotes();
this.viewMedia(path, 'drawing');
} catch (error) {
ErrorHandler.handle('create drawing', error);
}
},
_drawingEncodeMediaPath() {
return this.currentMedia.split('/').map((s) => encodeURIComponent(s)).join('/');
},
/**
* 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.
*/
_drawingCanvasCoords(event) {
const canvas = this._drawingCanvasEl;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return { x: 0, y: 0 };
return {
x: ((event.clientX - rect.left) / rect.width) * this.drawingDocW,
y: ((event.clientY - rect.top) / rect.height) * this.drawingDocH,
};
},
_drawingDrawOp(ctx, op) {
if (!op) return;
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();
},
/**
* 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.
*/
drawingRedraw() {
const canvas = this._drawingCanvasEl;
const ctx = this._drawingCtx;
if (!canvas || !ctx) return;
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);
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);
}
if (this.drawingDraft) {
this._drawingDrawOp(ctx, this.drawingDraft);
}
},
_drawingScheduleRedraw() {
if (this._drawingRaf) return;
this._drawingRaf = requestAnimationFrame(() => {
this._drawingRaf = null;
this.drawingRedraw();
});
},
_drawingDisconnectResizeObserver() {
if (this._drawingResizeObserver) {
try {
this._drawingResizeObserver.disconnect();
} catch (_) {
/* ignore */
}
this._drawingResizeObserver = null;
}
},
_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;
this.drawingSave();
};
this._drawingAutosaveTimeout = setTimeout(attemptSave, CONFIG.AUTOSAVE_DELAY);
},
/**
* 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.
*/
_drawingLayoutCanvas() {
const wrap = this.$refs.drawingCanvasWrap;
const canvas = this.$refs.drawingCanvas;
if (!wrap || !canvas || this.currentMediaType !== 'drawing') return;
// 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) {
const col = wrap.closest('.flex-1.flex.flex-col') || wrap.closest('.flex-1');
const h = col ? col.clientHeight : 0;
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;
}
const dpr = window.devicePixelRatio || 1;
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
// 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));
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');
this.drawingOps = [];
this.drawingRedoStack = [];
this.drawingDraft = null;
this.drawingIsPointerDown = false;
this._drawingBaseImage = null;
this.drawingHasRasterFromFile = false;
// 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;
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;
this.drawingHasRasterFromFile = true;
// 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);
this._drawingLayoutCanvas();
} catch (e) {
if (token !== this._drawingLoadToken) return;
ErrorHandler.handle('load drawing', e);
this.drawingHasRasterFromFile = false;
this._drawingLayoutCanvas();
}
},
_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)}`;
},
/**
* 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).
*/
drawingSampleColor(e) {
const canvas = this._drawingCanvasEl;
const ctx = this._drawingCtx;
if (!canvas || !ctx) return;
const docW = this.drawingDocW;
const docH = this.drawingDocH;
if (!docW || !docH) return;
const { x, y } = this._drawingCanvasCoords(e);
this.drawingRedraw();
let ix = Math.floor((x / docW) * canvas.width);
let iy = Math.floor((y / docH) * canvas.height);
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]);
},
/** 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;
this.drawingOps = [];
this.drawingRedoStack = [];
this.drawingRedraw();
this._drawingScheduleAutosave();
},
drawingPointerDown(e) {
if (this.currentMediaType !== 'drawing' || e.button !== 0) return;
const canvas = this._drawingCanvasEl;
if (!canvas) return;
const tool = this.drawingTool;
if (tool === 'eyedropper') {
e.preventDefault();
this.drawingSampleColor(e);
return;
}
this._drawingCancelAutosave();
canvas.setPointerCapture(e.pointerId);
this._drawingPointerId = e.pointerId;
const { x, y } = this._drawingCanvasCoords(e);
this.drawingIsPointerDown = true;
this.drawingRedoStack = [];
const lw = this.drawingLineWidth;
const color = tool === 'eraser' ? CONFIG.DRAWING_BACKGROUND : this.drawingColor;
if (tool === 'freehand' || tool === 'eraser') {
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();
this._drawingScheduleAutosave();
},
drawingUndo() {
if (this.drawingOps.length === 0) return;
this.drawingRedoStack.push(this.drawingOps.pop());
this.drawingRedraw();
this._drawingScheduleAutosave();
},
drawingRedo() {
if (this.drawingRedoStack.length === 0) return;
this.drawingOps.push(this.drawingRedoStack.pop());
this.drawingRedraw();
this._drawingScheduleAutosave();
},
/**
* Persist the flattened canvas to disk (Ctrl+S, autosave). Same feedback as saveNote:
* header "Saved" only — never clears stroke undo/redo or reloads the image; stacks reset when
* opening another drawing via initDrawingViewer().
*/
async drawingSave() {
if (!this.currentMedia || this.currentMediaType !== 'drawing') return;
if (this._drawingSaveInFlight) {
this._drawingSaveQueued = true;
return;
}
this._drawingSaveInFlight = true;
try {
this._drawingCancelAutosave();
this.drawingDraft = null;
this.drawingIsPointerDown = false;
// 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);
const off = document.createElement('canvas');
off.width = docW;
off.height = docH;
const offCtx = off.getContext('2d');
if (!offCtx) return;
offCtx.fillStyle = CONFIG.DRAWING_BACKGROUND;
offCtx.fillRect(0, 0, docW, docH);
if (this._drawingBaseImage && this._drawingBaseImage.complete) {
offCtx.drawImage(this._drawingBaseImage, 0, 0, docW, docH);
}
for (const op of this.drawingOps) {
this._drawingDrawOp(offCtx, op);
}
// Keep the visible canvas in sync (cosmetic; UI doesn't have to wait for the upload).
this.drawingRedraw();
const blob = await new Promise((resolve, reject) => {
off.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
});
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);
}
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;
}
} finally {
this._drawingSaveInFlight = false;
if (this._drawingSaveQueued) {
this._drawingSaveQueued = false;
queueMicrotask(() => {
if (this.currentMedia && this.currentMediaType === 'drawing') {
this.drawingSave();
}
});
}
}
},
// Handle clicks on internal links in preview
handleInternalLink(event) {
// Check if clicked element is a link
const link = event.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href) return;
// Check if it's an external link or API path (media files, etc.)
// 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))) {
return; // Let external links and API paths work normally
}
// Prevent default navigation for internal links
event.preventDefault();
// 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;
}
// Skip if no path
if (!notePath) return;
// Find the note by path (try exact match first, then with .md extension)
let targetNote = this.notes.find(n =>
n.path === notePath ||
n.path === notePath + '.md'
);
if (!targetNote) {
// Try to find by name (in case link uses just the note name without path)
targetNote = this.notes.find(n =>
n.name === notePath ||
n.name === notePath + '.md' ||
n.name.toLowerCase() === notePath.toLowerCase() ||
n.name.toLowerCase() === (notePath + '.md').toLowerCase()
);
}
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);
}
});
} 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);
});
}
},
// 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);
}
}
},
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'));
},
async onFolderDrop(targetFolderPath) {
// Ignore if we're dropping into the editor
if (this.dropTarget === 'editor') {
return;
}
// Capture dragged item info immediately (ondragend may clear it)
if (!this.draggedItem) return;
const { path: draggedPath, type: draggedType } = this.draggedItem;
// Determine item category for endpoint selection
const isFolder = draggedType === 'folder';
const isNote = draggedType === 'note';
const isMedia = !isFolder && !isNote; // image, audio, video, document
// Handle folder drop
if (isFolder) {
// Prevent dropping folder into itself or its subfolders
if (targetFolderPath === draggedPath ||
targetFolderPath.startsWith(draggedPath + '/')) {
this.toast(this.t('folders.cannot_move_into_self'), { type: 'warning' });
return;
}
const folderName = draggedPath.split('/').pop();
const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName;
if (newPath === draggedPath) return;
// Capture favorites info before async call
const oldPrefix = draggedPath + '/';
const newPrefix = newPath + '/';
try {
const response = await fetch('/api/folders/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPath: draggedPath, newPath })
});
if (response.ok) {
// 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
);
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
// Keep folder expanded if it was
const wasExpanded = this.expandedFolders.has(draggedPath);
await this.loadNotes();
await this.loadSharedNotePaths();
if (wasExpanded) {
this.expandedFolders.delete(draggedPath);
this.expandedFolders.add(newPath);
this.saveExpandedFolders();
}
} else {
const errorData = await response.json().catch(() => ({}));
this.toast(errorData.detail || this.t('move.failed_folder'), { type: 'error' });
}
} catch (error) {
console.error('Failed to move folder:', error);
this.toast(this.t('move.failed_folder'), { type: 'error' });
}
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';
this.toast(errorData.detail || this.t(errorKey), { type: 'error' });
}
} catch (error) {
console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error);
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
this.toast(this.t(errorKey), { type: 'error' });
}
},
// Load a specific note
async loadNote(notePath, updateHistory = true, searchQuery = '') {
try {
// Close mobile sidebar when a note is selected
this.mobileSidebarOpen = false;
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
window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/');
this.currentNote = '';
this.noteContent = '';
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
}
this.currentMedia = '';
document.title = this.appName;
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.currentNote = notePath;
this._lastRenderedContent = ''; // Clear render cache for new note
this._lastRenderedNote = '';
this._cachedRenderedHTML = '';
this._initializedVideoSources = new Set(); // Clear video cache for new note
this.noteContent = data.content;
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
}
this.currentMedia = ''; // Clear image viewer when loading a note
this.shareInfo = null; // Reset share info for new note
// Update browser tab title
document.title = `${this.currentNoteName} - ${this.appName}`;
this.lastSaved = false;
// Extract outline for TOC panel
this.extractOutline(data.content);
// Store backlinks from API response
this.backlinks = data.backlinks || [];
// Initialize undo/redo history for this note (with cursor at start)
this.undoHistory = [{ content: data.content, cursorPos: 0 }];
this.redoHistory = [];
this.hasPendingHistoryChanges = false;
// 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(
{
notePath: notePath,
searchQuery: searchQuery,
homepageFolder: this.selectedHomepageFolder || '' // Save current folder state
},
'',
url
);
}
// Calculate stats if plugin enabled
if (this.statsPluginEnabled) {
this.calculateStats();
}
// Parse frontmatter metadata
this.parseMetadata();
// Store search query for highlighting
if (searchQuery) {
this.currentSearchHighlight = searchQuery;
} else {
// Clear highlights if no search query
this.currentSearchHighlight = '';
}
// Expand folder tree to show the loaded note
this.expandFolderForNote(notePath);
// Use $nextTick twice to ensure Alpine.js has time to:
// 1. First tick: expand folders and update DOM
// 2. Second tick: highlight the note and setup everything else
this.$nextTick(() => {
this.$nextTick(() => {
this.refreshDOMCache();
this.setupScrollSync();
this.scrollToTop();
// Apply or clear search highlighting
if (searchQuery) {
// Pass true to focus editor when loading from search result
this.highlightSearchTerm(searchQuery, true);
} else {
this.clearSearchHighlights();
}
// Scroll note into view in sidebar if needed
this.scrollNoteIntoView(notePath);
});
});
} catch (error) {
ErrorHandler.handle('load note', error);
}
},
// Load item (note or media) from URL path
loadItemFromURL() {
// Get path from URL (e.g., /folder/note or /folder/image.png)
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);
}
// Skip if root path or static assets
if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) {
return;
}
// Remove leading slash and decode URL encoding (e.g., %20 -> space)
const decodedPath = decodeURIComponent(path.substring(1));
// Check if this is a media file (image, audio, video, PDF)
const matchedItem = this.notes.find(n => n.path === decodedPath);
if (matchedItem && matchedItem.type !== 'note') {
// It's a media file, view it
this.viewMedia(decodedPath, matchedItem.type, false); // false = don't update history
} 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();
}
}
},
// 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);
// First match is active (styled via CSS)
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;
// Update styling - make current match prominent (via CSS class)
allMatches.forEach((mark, i) => {
mark.classList.toggle('active-match', i === index);
});
// 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;
},
// =====================================================
// DROPDOWN MENU SYSTEM
// =====================================================
toggleNewDropdown(event) {
this.showNewDropdown = true; // Always open (or keep open)
if (event && event.target) {
const rect = event.target.getBoundingClientRect();
// Position dropdown next to the clicked element
let top = rect.bottom + 4; // 4px spacing
let left = rect.left;
// Keep dropdown on screen
const dropdownWidth = 200;
const dropdownHeight = 150;
if (left + dropdownWidth > window.innerWidth) {
left = rect.right - dropdownWidth;
}
if (top + dropdownHeight > window.innerHeight) {
top = rect.top - dropdownHeight - 4;
}
this.dropdownPosition = { top, left };
}
},
closeDropdown() {
this.showNewDropdown = false;
this.dropdownTargetFolder = null; // Reset folder context
},
/**
* 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 || '';
},
// =====================================================
// UNIFIED CREATION FUNCTIONS (reusable from anywhere)
// =====================================================
// 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();
});
},
async createNote(folderPath = null, directPath = null) {
if (directPath) {
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) {
const targetFolder =
explicitTargetFolder !== undefined ? explicitTargetFolder : this.inferredNewItemTargetFolder();
this.closeDropdown();
this.mobileSidebarOpen = false;
this.createNameModalKind = kind;
this.createNameModalTargetFolder = targetFolder;
this.createNameModalInput = '';
this.showCreateNameModal = true;
this.$nextTick(() => {
const el = document.getElementById('create-name-modal-input');
if (el) {
el.focus();
el.select();
}
});
},
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 })
: this.t('notes.prompt_name_with_path');
}
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'
? this.t('notes.prompt_name')
: 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);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
return;
}
const validatedName = validation.sanitized;
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;
}
const ok = await this._finalizeCreateNote(notePath);
if (ok) this.closeCreateNameModal();
return;
}
const validation = targetFolder
? FilenameValidator.validateFilename(rawName)
: FilenameValidator.validatePath(rawName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' });
return;
}
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) {
try {
const response = await fetch(`/api/notes/${notePath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: '' })
});
if (response.ok) {
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
if (folderPart) this.expandedFolders.add(folderPart);
await this.loadNotes();
await this.loadNote(notePath);
this.focusEditorForNewNote();
return true;
}
ErrorHandler.handle('create note', new Error('Server returned error'));
return false;
} catch (error) {
ErrorHandler.handle('create note', error);
return false;
}
},
async _finalizeCreateFolder(folderPath, targetFolder) {
try {
const response = await fetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: folderPath })
});
if (response.ok) {
if (targetFolder) {
this.expandedFolders.add(targetFolder);
}
this.expandedFolders.add(folderPath);
await this.loadNotes();
this.goToHomepageFolder(folderPath);
return true;
}
ErrorHandler.handle('create folder', new Error('Server returned error'));
return false;
} catch (error) {
ErrorHandler.handle('create folder', error);
return false;
}
},
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();
},
/**
* 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);
},
async submitRenameFolderModal() {
const folderPath = this.renameFolderPath;
const currentName = this.renameFolderOldName;
const newName = (this.renameFolderInput || '').trim();
if (!newName || newName === currentName) {
this.closeRenameFolderModal();
return;
}
const validation = FilenameValidator.validateFilename(newName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' });
return;
}
const validatedName = validation.sanitized;
const pathParts = folderPath.split('/');
pathParts[pathParts.length - 1] = validatedName;
const newPath = pathParts.join('/');
const ok = await this._finalizeRenameFolder(folderPath, newPath);
if (ok) this.closeRenameFolderModal();
},
async _finalizeRenameFolder(folderPath, newPath) {
try {
const response = await fetch('/api/folders/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldPath: folderPath,
newPath: newPath
})
});
if (response.ok) {
if (this.expandedFolders.has(folderPath)) {
this.expandedFolders.delete(folderPath);
this.expandedFolders.add(newPath);
}
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);
}
await this.loadNotes();
return true;
}
ErrorHandler.handle('rename folder', new Error('Server returned error'));
return false;
} catch (error) {
ErrorHandler.handle('rename folder', error);
return false;
}
},
// Delete folder
async deleteFolder(folderPath, folderName) {
const ok = await this.confirmModalAsk({
message: this.t('folders.confirm_delete', { name: folderName }),
});
if (!ok) return;
try {
const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
// Remove from expanded folders
this.expandedFolders.delete(folderPath);
// 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();
}
// Clear current note if it was in the deleted folder
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
this.currentNote = '';
this.noteContent = '';
document.title = this.appName;
}
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();
}
// Parse metadata in real-time
this.parseMetadata();
// Update outline (TOC) in real-time
this.extractOutline(this.noteContent);
this.saveTimeout = setTimeout(() => {
// Commit to undo history when autosave triggers (same debounce timing)
if (this.hasPendingHistoryChanges) {
this.commitToHistory();
}
this.saveNote();
}, CONFIG.AUTOSAVE_DELAY);
},
// Mark that we have pending changes (called on each keystroke)
pushToHistory() {
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() {
const editor = document.getElementById('note-editor');
const cursorPos = editor ? editor.selectionStart : 0;
// Only push if content actually changed from last history entry
if (this.undoHistory.length > 0 &&
this.undoHistory[this.undoHistory.length - 1].content === this.noteContent) {
this.hasPendingHistoryChanges = false;
return;
}
this.undoHistory.push({ content: this.noteContent, cursorPos });
// Limit history size
if (this.undoHistory.length > this.maxHistorySize) {
this.undoHistory.shift();
}
// Clear redo history when new change is made
this.redoHistory = [];
this.hasPendingHistoryChanges = false;
},
// Undo last change
undo() {
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;
const editor = document.getElementById('note-editor');
// Pop current state to redo history
const currentState = this.undoHistory.pop();
this.redoHistory.push(currentState);
// Get previous state
const previousState = this.undoHistory[this.undoHistory.length - 1];
// Apply previous state
this.isUndoRedo = true;
this.noteContent = previousState.content;
// Recalculate stats with new content
if (this.statsPluginEnabled) {
this.calculateStats();
}
// Restore cursor position from the state we're going back to
this.$nextTick(() => {
this.saveNote();
this.isUndoRedo = false;
if (editor) {
setTimeout(() => {
const newPos = Math.min(previousState.cursorPos, this.noteContent.length);
editor.setSelectionRange(newPos, newPos);
editor.focus();
}, 0);
}
});
},
// Redo last undone change
redo() {
if (!this.currentNote) return;
// Flush any pending history changes first
this.flushHistory();
if (this.redoHistory.length === 0) return;
const editor = document.getElementById('note-editor');
// Pop from redo history
const nextState = this.redoHistory.pop();
// Push to undo history
this.undoHistory.push(nextState);
// Apply next state
this.isUndoRedo = true;
this.noteContent = nextState.content;
// Recalculate stats with new content
if (this.statsPluginEnabled) {
this.calculateStats();
}
// Restore cursor position from the state we're going forward to
this.$nextTick(() => {
this.saveNote();
this.isUndoRedo = false;
if (editor) {
setTimeout(() => {
const newPos = Math.min(nextState.cursorPos, this.noteContent.length);
editor.setSelectionRange(newPos, newPos);
editor.focus();
}, 0);
}
});
},
// Markdown formatting helpers
wrapSelection(before, after, placeholder) {
const editor = document.getElementById('note-editor');
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = this.noteContent.substring(start, end);
const textToWrap = selectedText || placeholder;
// Build the new text
const newText = before + textToWrap + after;
// Update content
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
// Set cursor position (select the wrapped text or placeholder)
this.$nextTick(() => {
if (selectedText) {
// If text was selected, keep it selected (inside the wrapper)
editor.setSelectionRange(start + before.length, start + before.length + selectedText.length);
} else {
// If no text selected, select the placeholder
editor.setSelectionRange(start + before.length, start + before.length + placeholder.length);
}
editor.focus();
});
// Trigger autosave
this.autoSave();
},
insertLink() {
const editor = document.getElementById('note-editor');
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = this.noteContent.substring(start, end);
// If text is selected, use it as link text; otherwise use placeholder
const linkText = selectedText || 'link text';
const linkUrl = 'url';
// Build the markdown link
const newText = `[${linkText}](${linkUrl})`;
// Update content
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
// Set cursor position to select the URL part for easy editing
this.$nextTick(() => {
const urlStart = start + linkText.length + 3; // After "[linkText]("
const urlEnd = urlStart + linkUrl.length;
editor.setSelectionRange(urlStart, urlEnd);
editor.focus();
});
// Trigger autosave
this.autoSave();
},
// 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();
},
// Format selected text or insert formatting at cursor
formatText(type) {
// Simple wrap cases - reuse wrapSelection()
const wrapFormats = {
'bold': ['**', '**', 'bold'],
'italic': ['*', '*', 'italic'],
'strikethrough': ['~~', '~~', 'strikethrough'],
'code': ['`', '`', 'code']
};
if (wrapFormats[type]) {
const [before, after, placeholder] = wrapFormats[type];
this.wrapSelection(before, after, placeholder);
return;
}
// Special cases that need custom handling
switch (type) {
case 'heading':
this.insertLinePrefix('## ', 'Heading');
break;
case 'quote':
this.insertLinePrefix('> ', 'quote');
break;
case 'bullet':
this.insertLinePrefix('- ', 'item');
break;
case 'numbered':
this.insertLinePrefix('1. ', 'item');
break;
case 'checkbox':
this.insertLinePrefix('- [ ] ', 'task');
break;
case 'link':
this.insertLink();
break;
case 'image':
this.wrapSelection('![', '](image-url)', 'alt text');
break;
case 'codeblock':
this.wrapSelection('```\n', '\n```', 'code');
break;
case 'table':
this.insertTable();
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;
}
this.noteContent = beforeText + replacement + afterText;
this.$nextTick(() => {
if (selectedText) {
editor.setSelectionRange(start + newline.length, start + replacement.length);
} else {
const placeholderStart = start + newline.length + prefix.length;
editor.setSelectionRange(placeholderStart, placeholderStart + placeholder.length);
}
editor.focus();
});
this.autoSave();
},
// 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();
note.size = new Blob([this.noteContent]).size;
// 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();
}
// 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) {
this.toast(this.t('notes.empty_name'), { type: 'warning' });
return;
}
// Validate the new name (single segment, no path separators)
const validation = FilenameValidator.validateFilename(newName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
// Reset the name in the UI
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
return;
}
const validatedName = validation.sanitized;
const folder = oldPath.split('/').slice(0, -1).join('/');
const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`;
if (oldPath === newPath) return;
// Check if a note with the new name already exists
const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
if (existingNote) {
this.toast(this.t('notes.already_exists', { name: validatedName }), { type: 'warning' });
// Reset the name in the UI
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
return;
}
// 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' });
// 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();
}
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;
// Just call deleteNote with current note details
await this.deleteNote(this.currentNote, this.currentNoteName);
},
// Delete any note from sidebar
async deleteNote(notePath, noteName) {
const ok = await this.confirmModalAsk({
message: this.t('notes.confirm_delete', { name: noteName }),
});
if (!ok) return;
try {
const response = await fetch(`/api/notes/${notePath}`, {
method: 'DELETE'
});
if (response.ok) {
// 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();
}
// If the deleted note is currently open, clear it
if (this.currentNote === notePath) {
this.currentNote = '';
this.noteContent = '';
this.currentNoteName = '';
this._lastRenderedContent = ''; // Clear render cache
this._lastRenderedNote = '';
this._cachedRenderedHTML = '';
document.title = this.appName;
// 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
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);
},
// Search notes by text (calls unified filter logic)
async searchNotes() {
await this.applyFilters();
},
// 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);
}
},
// 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
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 }
});
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';
},
// Computed property for rendered markdown
get renderedMarkdown() {
if (!this.noteContent) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>';
// Performance: Return cached HTML if content hasn't changed
if (this.noteContent === this._lastRenderedContent &&
this.currentNote === this._lastRenderedNote &&
this._cachedRenderedHTML) {
return this._cachedRenderedHTML;
}
// 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();
}
}
}
// Convert Obsidian-style wikilinks: [[note]] or [[note|display text]]
// Must be done before marked.parse() to avoid conflicts with markdown syntax
// BUT we need to protect code blocks first to avoid converting [[text]] inside code
const self = this; // Reference for closure
// 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 `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
});
// Protect inline code (`...`)
contentToRender = contentToRender.replace(/`[^`]+`/g, (match) => {
codeBlocks.push(match);
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
});
// 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
contentToRender = contentToRender.replace(
/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, mediaName, altText) => {
const filename = mediaName.trim();
const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, '');
// Resolve media path using O(1) lookup
const mediaPath = self.resolveMediaWikilink(filename);
if (mediaPath) {
// URL-encode path segments for the API
const encodedPath = mediaPath.split('/').map(segment => {
try {
return encodeURIComponent(decodeURIComponent(segment));
} catch (e) {
return encodeURIComponent(segment);
}
}).join('/');
const safeAlt = alt.replace(/"/g, '&quot;');
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':
// Local PDFs: show iframe preview
return `<div class="media-embed media-pdf"><iframe src="${mediaSrc}" title="${safeAlt}"></iframe></div>`;
default: // image
return `<img src="${mediaSrc}" alt="${safeAlt}" title="${safeAlt}">`;
}
}
// Media not found - return broken indicator
const safeFilename = filename.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>`;
}
);
// Step 2b: Convert note wikilinks: [[note]] or [[note|display text]]
contentToRender = contentToRender.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, target, displayText) => {
const linkTarget = target.trim();
const linkText = displayText ? displayText.trim() : linkTarget;
// 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);
// Escape special chars: href needs quote escaping, text needs HTML escaping
const safeHref = linkTarget.replace(/"/g, '%22');
const safeText = linkText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// 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>`;
}
);
// Step 3: Restore code blocks
contentToRender = contentToRender.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => {
return codeBlocks[parseInt(index)];
});
// 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]
};
}
}
}]
});
// 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
let html = marked.parse(contentToRender);
// 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);
// Post-process: Add target="_blank" to external links and title attributes to images
// 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');
}
}
});
// Find all images and transform paths for display
// Also convert non-image media (audio, video, PDF) to appropriate elements
const images = tempDiv.querySelectorAll('img');
images.forEach(img => {
let src = img.getAttribute('src');
if (src) {
const isExternal = src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//');
const isLocal = !isExternal && !src.startsWith('data:');
// Transform relative paths to /api/media/ for serving
if (isLocal && !src.startsWith('/api/media/')) {
const vaultRelative = self.currentNote
? self.resolveMarkdownMediaPathForNote(self.currentNote, src)
: src.split('#')[0].split('?')[0];
const encodedPath = self.encodeVaultRelativePathForMediaApi(vaultRelative);
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, '&quot;');
// 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);
return;
}
// External audio/video: leave as broken image for security
}
// For regular images, set title attribute
const altText = img.getAttribute('alt');
if (altText) {
img.setAttribute('title', altText);
}
});
html = tempDiv.innerHTML;
// Debounced MathJax rendering (avoid re-running on every keystroke)
if (this._mathDebounceTimeout) clearTimeout(this._mathDebounceTimeout);
this._mathDebounceTimeout = setTimeout(() => this.typesetMath(), 300);
// Debounced Mermaid rendering
if (this._mermaidDebounceTimeout) clearTimeout(this._mermaidDebounceTimeout);
this._mermaidDebounceTimeout = setTimeout(() => this.renderMermaid(), 300);
// 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) {
// 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) => {
// 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);
}
});
// 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';
}
});
}
}, 0);
// Cache the result for performance
this._lastRenderedContent = this.noteContent;
this._lastRenderedNote = this.currentNote;
this._cachedRenderedHTML = html;
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) {
// 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
const button = document.createElement('button');
button.className = 'copy-code-button';
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');
// Style the button
button.style.position = 'absolute';
button.style.top = '8px';
button.style.right = '8px';
button.style.padding = '4px 10px';
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';
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';
// 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;
const originalText = button.dataset.originalText;
const copiedText = this.t('common.copied');
const copyTitle = this.t('common.copy_to_clipboard');
try {
await navigator.clipboard.writeText(code);
// Visual feedback - show localized "Copied!"
button.innerHTML = `<span>${copiedText}</span>`;
button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
button.title = copiedText;
// Reset after 2 seconds
setTimeout(() => {
button.innerHTML = `<span>${originalText}</span>`;
button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
button.title = copyTitle;
}, 2000);
} catch (err) {
console.error('Failed to copy code:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = code;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
button.innerHTML = `<span>${copiedText}</span>`;
button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
setTimeout(() => {
button.innerHTML = `<span>${originalText}</span>`;
button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
}, 2000);
} catch (fallbackErr) {
console.error('Fallback copy failed:', fallbackErr);
}
document.body.removeChild(textArea);
}
});
// Add button to pre element
preElement.appendChild(button);
},
// Setup scroll synchronization
setupScrollSync() {
// Use cached references (refresh if not available)
if (!this._domCache.editor || !this._domCache.previewContainer) {
this.refreshDOMCache();
}
const editor = this._domCache.editor;
const preview = this._domCache.previewContainer;
if (!editor || !preview) {
// If elements don't exist yet, retry with limit
if (!this._setupScrollSyncRetries) this._setupScrollSyncRetries = 0;
if (this._setupScrollSyncRetries < CONFIG.SCROLL_SYNC_MAX_RETRIES) {
this._setupScrollSyncRetries++;
setTimeout(() => this.setupScrollSync(), CONFIG.SCROLL_SYNC_RETRY_INTERVAL);
} else {
console.warn(`setupScrollSync: Failed to find editor/preview elements after ${CONFIG.SCROLL_SYNC_MAX_RETRIES} retries`);
}
return;
}
// Reset retry counter on success
this._setupScrollSyncRetries = 0;
// Remove old listeners if they exist
if (this._editorScrollHandler) {
editor.removeEventListener('scroll', this._editorScrollHandler);
}
if (this._previewScrollHandler) {
preview.removeEventListener('scroll', this._previewScrollHandler);
}
// Create new scroll handlers
this._editorScrollHandler = () => {
if (this.isScrolling) {
this.isScrolling = false;
return;
}
const scrollableHeight = editor.scrollHeight - editor.clientHeight;
if (scrollableHeight <= 0) return; // No scrolling needed
const scrollPercentage = editor.scrollTop / scrollableHeight;
const previewScrollableHeight = preview.scrollHeight - preview.clientHeight;
if (previewScrollableHeight > 0) {
this.isScrolling = true;
preview.scrollTop = scrollPercentage * previewScrollableHeight;
}
};
this._previewScrollHandler = () => {
if (this.isScrolling) {
this.isScrolling = false;
return;
}
const scrollableHeight = preview.scrollHeight - preview.clientHeight;
if (scrollableHeight <= 0) return; // No scrolling needed
const scrollPercentage = preview.scrollTop / scrollableHeight;
const editorScrollableHeight = editor.scrollHeight - editor.clientHeight;
if (editorScrollableHeight > 0) {
this.isScrolling = true;
editor.scrollTop = scrollPercentage * editorScrollableHeight;
}
};
// Attach new listeners
editor.addEventListener('scroll', this._editorScrollHandler);
preview.addEventListener('scroll', this._previewScrollHandler);
},
// Check if stats plugin is enabled
async checkStatsPlugin() {
try {
const response = await fetch('/api/plugins');
const data = await response.json();
const statsPlugin = data.plugins.find(p => p.id === 'note_stats');
this.statsPluginEnabled = statsPlugin && statsPlugin.enabled;
// Calculate stats for current note if enabled
if (this.statsPluginEnabled && this.noteContent) {
this.calculateStats();
}
} catch (error) {
console.error('Failed to check stats plugin:', error);
this.statsPluginEnabled = false;
}
},
// Calculate note statistics (client-side)
calculateStats() {
if (!this.statsPluginEnabled || !this.noteContent) {
this.noteStats = null;
return;
}
const content = this.noteContent;
// Word count
const words = (content.match(/\S+/g) || []).length;
// Character count
const chars = content.replace(/\s/g, '').length;
const totalChars = content.length;
// Reading time (200 words per minute)
const readingTime = Math.max(1, Math.round(words / 200));
// Line count
const lines = content.split('\n').length;
// Paragraph count
const paragraphs = content.split('\n\n').filter(p => p.trim()).length;
// 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;
// Tables: markdown table separator rows (| --- | --- |)
const tables = (content.match(/^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm) || []).length;
// 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
// 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,
sentences,
characters: chars,
total_characters: totalChars,
reading_time_minutes: readingTime,
lines,
paragraphs,
list_items: listItems,
tables,
links,
internal_links: internalLinks,
external_links: links - internalLinks,
wikilinks,
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
};
},
// 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') {
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);
}
if (!isNaN(date.getTime())) {
return date.toLocaleDateString(this.currentLocale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}
// Booleans
if (typeof value === 'boolean') {
return value ? this.t('common.yes') : this.t('common.no');
}
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];
},
// 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);
}
},
saveTagsExpanded() {
try {
localStorage.setItem('tagsExpanded', this.tagsExpanded.toString());
} catch (error) {
console.error('Error saving tags expanded state:', error);
}
},
// 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);
},
// 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);
},
// 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';
}
},
// Save editor width to localStorage
saveEditorWidth() {
localStorage.setItem('editorWidth', this.editorWidth.toString());
},
// 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);
},
// Export current note as HTML via backend API
async exportToHTML() {
if (!this.currentNote || !this.noteContent) {
this.toast(this.t('notes.no_content'), { type: 'info' });
return;
}
try {
// Build API URL with current theme
const currentTheme = this.currentTheme || 'light';
const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}`;
// 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');
}
// 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];
}
}
// Download as blob
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
// Cleanup
URL.revokeObjectURL(blobUrl);
document.body.removeChild(a);
} catch (error) {
console.error('HTML export failed:', error);
this.toast(this.t('export.failed', { error: error.message }), { type: 'error' });
}
},
// Open print preview in new window
printPreview() {
if (!this.currentNote || !this.noteContent) {
this.toast(this.t('notes.no_content'), { type: 'info' });
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');
},
// Copy current note link to clipboard
async copyNoteLink() {
if (!this.currentNote) return;
// 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);
}
// Show brief "Copied!" feedback
this.linkCopied = true;
setTimeout(() => {
this.linkCopied = false;
}, 1500);
},
// ============================================================================
// Share Functions
// ============================================================================
// Load list of shared note paths (for visual indicators and Shared sidebar panel)
async loadSharedNotePaths() {
try {
const response = await fetch('/api/shared-notes');
if (response.ok) {
const data = await response.json();
this._sharedNotePaths = new Set(data.paths || []);
this._syncSharedNotePathsList();
}
} catch (error) {
console.error('Failed to load shared note paths:', error);
this._sharedNotePaths = new Set();
this._sharedNotePathsList = [];
}
},
_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' };
});
},
// Check if a note is currently shared (O(1) lookup)
isNoteShared(notePath) {
return this._sharedNotePaths.has(notePath);
},
// ============================================
// 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() {
this.$nextTick(() => {
const items = document.querySelectorAll('[data-quick-switcher-item]');
if (items[this.quickSwitcherIndex]) {
items[this.quickSwitcherIndex].scrollIntoView({ block: 'nearest' });
}
});
},
// Select note from quick switcher by click
selectQuickSwitcherNote(note) {
this.loadNote(note.path);
this.closeQuickSwitcher();
},
// 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 '';
}
},
// Open share modal and fetch current share status
async openShareModal() {
if (!this.currentNote) return;
// Reset state BEFORE showing modal to prevent flicker
this.showShareQR = false;
this.shareInfo = null;
this.shareLoading = true;
this.showShareModal = true;
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);
this._syncSharedNotePathsList();
} else {
const error = await response.json();
this.toast(this.t('share.error_creating', { error: error.detail || 'Unknown error' }), { type: 'error' });
}
} catch (error) {
console.error('Failed to create share link:', error);
this.toast(this.t('share.error_creating', { error: error.message }), { type: 'error' });
} 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;
const ok = await this.confirmModalAsk({
message: this.t('share.confirm_revoke'),
danger: false,
confirmLabel: this.t('common.yes'),
});
if (!ok) 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: 'DELETE'
});
if (response.ok) {
this.shareInfo = { shared: false };
// Update the shared paths set
this._sharedNotePaths.delete(this.currentNote);
this._syncSharedNotePathsList();
} else {
const error = await response.json();
this.toast(this.t('share.error_revoking', { error: error.detail || 'Unknown error' }), { type: 'error' });
}
} catch (error) {
console.error('Failed to revoke share link:', error);
this.toast(this.t('share.error_revoking', { error: error.message }), { type: 'error' });
} finally {
this.shareLoading = false;
}
},
// 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);
}
}
},
// Homepage folder navigation methods
goToHomepageFolder(folderPath) {
this.showGraph = false; // Close graph when navigating
this.selectedHomepageFolder = folderPath || '';
// Clear editor state to show landing page
this.currentNote = '';
this.currentNoteName = '';
this.noteContent = '';
this.currentMedia = '';
this.outline = [];
this.backlinks = [];
document.title = this.appName;
// 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() {
this.showGraph = false; // Close graph when going home
this.selectedHomepageFolder = '';
this.currentNote = '';
this.currentNoteName = '';
this.noteContent = '';
this.currentMedia = '';
this.outline = [];
this.backlinks = [];
this.mobileSidebarOpen = false;
document.title = this.appName;
// Clear undo/redo history
this.undoHistory = [];
this.redoHistory = [];
this.hasPendingHistoryChanges = false;
// Invalidate cache to force recalculation
this._homepageCache = {
folderPath: null,
notes: null,
folders: null,
breadcrumb: null
};
window.history.pushState({ homepageFolder: '' }, '', '/');
},
// Mobile files/home tab - context-aware behavior
mobileFilesTabClick() {
if (this.currentNote || this.currentMedia || this.showGraph) {
// Viewing content → go home
this.goHome();
} else {
// On homepage → toggle files sidebar
this.activePanel = 'files';
this.mobileSidebarOpen = !this.mobileSidebarOpen;
}
},
// ==================== 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};"></span>
<span style="color: ${textColor};">Wikilinks</span>
</div>
<div class="graph-legend-item">
<span class="graph-legend-dot" style="background: ${mdColor};"></span>
<span style="color: ${textColor};">${this.t('graph.markdown_links')}</span>
</div>
<div style="margin-top: 8px; font-size: 10px; color: ${textColor}; opacity: 0.7;">
${this.t('graph.click_hint')}
</div>
`;
container.appendChild(legend);
},
// Refresh graph when theme changes
refreshGraph() {
if (this.viewMode === 'graph' && this.graphInstance) {
this.initGraph();
}
}
}
}