2025-11-05 16:48:41 +00:00
|
|
|
|
// NoteDiscovery Frontend Application
|
|
|
|
|
|
|
|
|
|
|
|
// Configuration constants
|
|
|
|
|
|
const CONFIG = {
|
2026-04-21 14:00:58 +00:00
|
|
|
|
AUTOSAVE_DELAY: 1000, // ms - Debounce before note save (autoSave) and drawing PNG autosave (_drawingScheduleAutosave)
|
2026-04-21 15:39:22 +00:00
|
|
|
|
/** Must match drawingRedraw() fill and eraser stroke color (opaque “whiteboard”). */
|
|
|
|
|
|
DRAWING_BACKGROUND: '#ffffff',
|
2026-02-23 16:51:11 +00:00
|
|
|
|
SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing
|
2025-11-05 16:48:41 +00:00
|
|
|
|
SAVE_INDICATOR_DURATION: 2000, // ms - How long to show "saved" indicator
|
|
|
|
|
|
SCROLL_SYNC_DELAY: 50, // ms - Delay to prevent scroll sync interference
|
|
|
|
|
|
SCROLL_SYNC_MAX_RETRIES: 10, // Maximum attempts to find editor/preview elements
|
|
|
|
|
|
SCROLL_SYNC_RETRY_INTERVAL: 100, // ms - Time between setupScrollSync retries
|
|
|
|
|
|
MAX_UNDO_HISTORY: 50, // Maximum number of undo steps to keep
|
|
|
|
|
|
DEFAULT_SIDEBAR_WIDTH: 256, // px - Default sidebar width (w-64 in Tailwind)
|
2026-04-16 16:17:34 +00:00
|
|
|
|
TOAST_MAX_VISIBLE: 4, // Max stacked toasts; oldest dropped when exceeded
|
|
|
|
|
|
TOAST_DURATION_ERROR_MS: 7000,
|
|
|
|
|
|
TOAST_DURATION_WARNING_MS: 5500,
|
|
|
|
|
|
TOAST_DURATION_INFO_MS: 4500,
|
|
|
|
|
|
TOAST_DURATION_SUCCESS_MS: 3500,
|
2025-11-05 16:48:41 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-26 09:22:00 +00:00
|
|
|
|
// localStorage settings configuration - centralized definition of all persisted settings
|
|
|
|
|
|
const LOCAL_SETTINGS = {
|
|
|
|
|
|
// Boolean settings
|
|
|
|
|
|
syntaxHighlightEnabled: { key: 'syntaxHighlightEnabled', type: 'boolean', default: false },
|
|
|
|
|
|
readableLineLength: { key: 'readableLineLength', type: 'boolean', default: true },
|
|
|
|
|
|
favoritesExpanded: { key: 'favoritesExpanded', type: 'boolean', default: true },
|
|
|
|
|
|
tagsExpanded: { key: 'tagsExpanded', type: 'boolean', default: false },
|
2026-02-23 09:59:58 +00:00
|
|
|
|
hideUnderscoreFolders: { key: 'hideUnderscoreFolders', type: 'boolean', default: false },
|
2026-04-06 09:25:21 +00:00
|
|
|
|
tabInsertsTab: { key: 'tabInsertsTab', type: 'boolean', default: false },
|
2026-04-06 14:00:00 +00:00
|
|
|
|
// String settings
|
|
|
|
|
|
sortMode: { key: 'sortMode', type: 'string', default: 'a-z' },
|
2026-01-26 09:22:00 +00:00
|
|
|
|
// Number settings with validation
|
|
|
|
|
|
sidebarWidth: { key: 'sidebarWidth', type: 'number', default: CONFIG.DEFAULT_SIDEBAR_WIDTH, min: 200, max: 600 },
|
|
|
|
|
|
editorWidth: { key: 'editorWidth', type: 'number', default: 50, min: 20, max: 80 },
|
|
|
|
|
|
// String settings with validation
|
|
|
|
|
|
viewMode: { key: 'viewMode', type: 'string', default: 'split', valid: ['edit', 'split', 'preview'] },
|
|
|
|
|
|
// JSON settings
|
|
|
|
|
|
favorites: { key: 'noteFavorites', type: 'json', default: [] },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Centralized error handling
|
|
|
|
|
|
const ErrorHandler = {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Handle errors consistently across the app
|
|
|
|
|
|
* @param {string} operation - The operation that failed (e.g., "load notes", "save note")
|
|
|
|
|
|
* @param {Error} error - The error object
|
2026-04-16 16:17:34 +00:00
|
|
|
|
* @param {boolean} notify - Whether to show a toast (listens on notediscovery:toast)
|
2025-11-05 16:48:41 +00:00
|
|
|
|
*/
|
2026-04-16 16:17:34 +00:00
|
|
|
|
handle(operation, error, notify = true) {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
console.error(`Failed to ${operation}:`, error);
|
2026-04-16 16:17:34 +00:00
|
|
|
|
if (notify) {
|
|
|
|
|
|
const message = `Failed to ${operation}. Please try again.`;
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('notediscovery:toast', {
|
|
|
|
|
|
detail: { message, type: 'error' }
|
|
|
|
|
|
}));
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-17 09:03:40 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Centralized filename validation
|
|
|
|
|
|
* Supports Unicode characters (international text) but blocks dangerous filesystem characters.
|
|
|
|
|
|
* Does NOT silently modify filenames - validates and returns status.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const FilenameValidator = {
|
|
|
|
|
|
// Characters that are forbidden in filenames across Windows/macOS/Linux
|
|
|
|
|
|
// Windows: \ / : * ? " < > |
|
|
|
|
|
|
// macOS: / :
|
|
|
|
|
|
// Linux: / \0
|
|
|
|
|
|
// Common set to block (including control characters)
|
|
|
|
|
|
FORBIDDEN_CHARS: /[\\/:*?"<>|\x00-\x1f]/,
|
|
|
|
|
|
|
|
|
|
|
|
// For display purposes - human readable list
|
|
|
|
|
|
FORBIDDEN_CHARS_DISPLAY: '\\ / : * ? " < > |',
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Validate a filename (single segment, no path separators)
|
|
|
|
|
|
* @param {string} name - The filename to validate
|
|
|
|
|
|
* @returns {{ valid: boolean, error?: string, sanitized?: string }}
|
|
|
|
|
|
*/
|
|
|
|
|
|
validateFilename(name) {
|
|
|
|
|
|
if (!name || typeof name !== 'string') {
|
|
|
|
|
|
return { valid: false, error: 'empty' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const trimmed = name.trim();
|
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
|
return { valid: false, error: 'empty' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for forbidden characters
|
|
|
|
|
|
if (this.FORBIDDEN_CHARS.test(trimmed)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
valid: false,
|
|
|
|
|
|
error: 'forbidden_chars',
|
|
|
|
|
|
forbiddenChars: this.FORBIDDEN_CHARS_DISPLAY
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for reserved Windows names (case-insensitive)
|
|
|
|
|
|
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
|
|
|
|
|
|
if (reservedNames.test(trimmed)) {
|
|
|
|
|
|
return { valid: false, error: 'reserved_name' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for names starting/ending with dots or spaces (problematic on some systems)
|
|
|
|
|
|
if (trimmed.startsWith('.') && trimmed.length === 1) {
|
|
|
|
|
|
return { valid: false, error: 'invalid_dot' };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (trimmed.endsWith('.') || trimmed.endsWith(' ')) {
|
|
|
|
|
|
return { valid: false, error: 'trailing_dot_space' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { valid: true, sanitized: trimmed };
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Validate a path (may contain forward slashes for folder separators)
|
|
|
|
|
|
* @param {string} path - The path to validate
|
|
|
|
|
|
* @returns {{ valid: boolean, error?: string, sanitized?: string }}
|
|
|
|
|
|
*/
|
|
|
|
|
|
validatePath(path) {
|
|
|
|
|
|
if (!path || typeof path !== 'string') {
|
|
|
|
|
|
return { valid: false, error: 'empty' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const trimmed = path.trim();
|
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
|
return { valid: false, error: 'empty' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Split by forward slash and validate each segment
|
|
|
|
|
|
const segments = trimmed.split('/').filter(s => s.length > 0);
|
|
|
|
|
|
if (segments.length === 0) {
|
|
|
|
|
|
return { valid: false, error: 'empty' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const segment of segments) {
|
|
|
|
|
|
const result = this.validateFilename(segment);
|
|
|
|
|
|
if (!result.valid) {
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Rebuild path without empty segments
|
|
|
|
|
|
return { valid: true, sanitized: segments.join('/') };
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
function noteApp() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
// App state
|
|
|
|
|
|
appName: 'NoteDiscovery',
|
2025-11-23 15:45:47 +00:00
|
|
|
|
appVersion: '0.0.0',
|
2025-12-06 17:40:28 +00:00
|
|
|
|
authEnabled: false,
|
2025-12-17 09:32:02 +00:00
|
|
|
|
demoMode: false,
|
2026-01-22 09:43:47 +00:00
|
|
|
|
alreadyDonated: false,
|
2025-11-05 16:48:41 +00:00
|
|
|
|
notes: [],
|
|
|
|
|
|
currentNote: '',
|
|
|
|
|
|
currentNoteName: '',
|
|
|
|
|
|
noteContent: '',
|
|
|
|
|
|
viewMode: 'split', // 'edit', 'split', 'preview'
|
|
|
|
|
|
searchQuery: '',
|
2025-12-04 17:05:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Graph state (separate overlay, doesn't affect viewMode)
|
|
|
|
|
|
showGraph: false,
|
|
|
|
|
|
graphInstance: null,
|
|
|
|
|
|
graphLoaded: false,
|
|
|
|
|
|
graphData: null,
|
2025-11-05 16:48:41 +00:00
|
|
|
|
searchResults: [],
|
|
|
|
|
|
currentSearchHighlight: '', // Track current highlighted search term
|
|
|
|
|
|
currentMatchIndex: 0, // Current match being viewed
|
|
|
|
|
|
totalMatches: 0, // Total number of matches in the note
|
|
|
|
|
|
isSaving: false,
|
|
|
|
|
|
lastSaved: false,
|
2025-12-12 15:42:14 +00:00
|
|
|
|
linkCopied: false,
|
2025-12-12 16:18:14 +00:00
|
|
|
|
zenMode: false,
|
|
|
|
|
|
previousViewMode: 'split',
|
2025-12-12 17:11:29 +00:00
|
|
|
|
favorites: [],
|
|
|
|
|
|
favoritesSet: new Set(), // For O(1) lookups
|
|
|
|
|
|
favoritesExpanded: true,
|
2025-11-05 16:48:41 +00:00
|
|
|
|
saveTimeout: null,
|
|
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// Note lookup maps for O(1) wikilink resolution (built on loadNotes)
|
|
|
|
|
|
_noteLookup: {
|
|
|
|
|
|
byPath: new Map(), // path -> true
|
|
|
|
|
|
byPathLower: new Map(), // path.toLowerCase() -> true
|
|
|
|
|
|
byName: new Map(), // name (without .md) -> true
|
|
|
|
|
|
byNameLower: new Map(), // name.toLowerCase() -> true
|
|
|
|
|
|
byEndPath: new Map(), // '/filename' and '/filename.md' -> true
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Media lookup map for O(1) media wikilink resolution (built on loadNotes)
|
|
|
|
|
|
// Maps media filename (case-insensitive) -> full path
|
|
|
|
|
|
_mediaLookup: new Map(),
|
2026-01-05 11:51:24 +00:00
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// Preview rendering debounce
|
|
|
|
|
|
_previewDebounceTimeout: null,
|
|
|
|
|
|
_lastRenderedContent: '',
|
2026-04-13 14:11:55 +00:00
|
|
|
|
_lastRenderedNote: '',
|
2025-12-12 17:11:29 +00:00
|
|
|
|
_cachedRenderedHTML: '',
|
|
|
|
|
|
_mathDebounceTimeout: null,
|
|
|
|
|
|
_mermaidDebounceTimeout: null,
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Theme state
|
|
|
|
|
|
currentTheme: 'light',
|
|
|
|
|
|
availableThemes: [],
|
|
|
|
|
|
|
2025-12-14 17:02:57 +00:00
|
|
|
|
// Locale/i18n state
|
2025-12-15 13:39:59 +00:00
|
|
|
|
currentLocale: localStorage.getItem('locale') || 'en-US',
|
2025-12-14 17:02:57 +00:00
|
|
|
|
availableLocales: [],
|
2025-12-15 13:39:59 +00:00
|
|
|
|
// Translations loaded from backend (preloaded before Alpine init via window.__preloadedTranslations)
|
|
|
|
|
|
translations: window.__preloadedTranslations || {},
|
2025-12-14 17:02:57 +00:00
|
|
|
|
|
2025-12-09 14:33:49 +00:00
|
|
|
|
// Syntax highlighting
|
|
|
|
|
|
syntaxHighlightEnabled: false,
|
|
|
|
|
|
syntaxHighlightTimeout: null,
|
|
|
|
|
|
|
2026-01-26 09:22:00 +00:00
|
|
|
|
// Readable line length (preview max-width)
|
|
|
|
|
|
readableLineLength: true,
|
|
|
|
|
|
|
2026-02-23 09:59:58 +00:00
|
|
|
|
// Hide underscore-prefixed folders (_attachments, _templates) from sidebar
|
2026-02-23 16:51:11 +00:00
|
|
|
|
// Read synchronously to prevent flash on initial render
|
|
|
|
|
|
hideUnderscoreFolders: localStorage.getItem('hideUnderscoreFolders') === 'true',
|
2026-04-06 09:25:21 +00:00
|
|
|
|
|
|
|
|
|
|
// Tab key inserts tab character instead of changing focus
|
|
|
|
|
|
tabInsertsTab: localStorage.getItem('tabInsertsTab') === 'true',
|
|
|
|
|
|
|
2026-04-06 14:00:00 +00:00
|
|
|
|
// Note sorting mode (a-z, z-a, newest, oldest, largest, smallest)
|
|
|
|
|
|
sortMode: localStorage.getItem('sortMode') || 'a-z',
|
|
|
|
|
|
|
2025-12-06 17:06:07 +00:00
|
|
|
|
// Icon rail / panel state
|
|
|
|
|
|
activePanel: 'files', // 'files', 'search', 'tags', 'settings'
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Folder state
|
|
|
|
|
|
folderTree: [],
|
|
|
|
|
|
allFolders: [],
|
|
|
|
|
|
expandedFolders: new Set(),
|
2025-11-18 09:03:47 +00:00
|
|
|
|
dragOverFolder: null, // Track which folder is being hovered during drag
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
|
// Tags state
|
|
|
|
|
|
allTags: {},
|
|
|
|
|
|
selectedTags: [],
|
|
|
|
|
|
tagsExpanded: false,
|
|
|
|
|
|
tagReloadTimeout: null, // For debouncing tag reloads
|
2026-02-20 07:26:52 +00:00
|
|
|
|
|
|
|
|
|
|
// Search state
|
|
|
|
|
|
searchDebounceTimeout: null,
|
|
|
|
|
|
isSearching: false,
|
2025-11-24 16:11:22 +00:00
|
|
|
|
|
2025-12-16 15:55:05 +00:00
|
|
|
|
// Outline (TOC) state
|
|
|
|
|
|
outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...]
|
2026-03-24 11:02:34 +00:00
|
|
|
|
|
|
|
|
|
|
// Backlinks state
|
|
|
|
|
|
backlinks: [], // [{path: 'note.md', name: 'Note', references: [{line_number: 5, context: '...', type: 'wikilink'}]}]
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Scroll sync state
|
|
|
|
|
|
isScrolling: false,
|
|
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
// Unified drag state for notes, folders, and media
|
|
|
|
|
|
draggedItem: null, // { path: string, type: 'note' | 'folder' | 'image' | 'audio' | 'video' | 'document' }
|
2025-11-20 13:10:17 +00:00
|
|
|
|
dropTarget: null, // 'editor' | 'folder' | null
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Undo/Redo history
|
|
|
|
|
|
undoHistory: [],
|
|
|
|
|
|
redoHistory: [],
|
|
|
|
|
|
maxHistorySize: CONFIG.MAX_UNDO_HISTORY,
|
|
|
|
|
|
isUndoRedo: false,
|
2025-12-18 08:50:08 +00:00
|
|
|
|
hasPendingHistoryChanges: false,
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Stats plugin state
|
|
|
|
|
|
statsPluginEnabled: false,
|
|
|
|
|
|
noteStats: null,
|
|
|
|
|
|
statsExpanded: false,
|
|
|
|
|
|
|
2025-12-04 09:07:07 +00:00
|
|
|
|
// Note metadata (frontmatter) state
|
|
|
|
|
|
noteMetadata: null,
|
|
|
|
|
|
metadataExpanded: false,
|
|
|
|
|
|
_lastFrontmatter: null, // Cache to avoid re-parsing unchanged frontmatter
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Sidebar resize state
|
|
|
|
|
|
sidebarWidth: CONFIG.DEFAULT_SIDEBAR_WIDTH,
|
|
|
|
|
|
isResizing: false,
|
|
|
|
|
|
|
2025-11-13 14:09:26 +00:00
|
|
|
|
// Mobile sidebar state
|
|
|
|
|
|
mobileSidebarOpen: false,
|
|
|
|
|
|
|
2025-11-09 09:17:09 +00:00
|
|
|
|
// Split view resize state
|
|
|
|
|
|
editorWidth: 50, // percentage
|
|
|
|
|
|
isResizingSplit: false,
|
|
|
|
|
|
|
2025-11-17 13:20:57 +00:00
|
|
|
|
// Dropdown state
|
|
|
|
|
|
showNewDropdown: false,
|
2025-11-27 13:13:39 +00:00
|
|
|
|
dropdownTargetFolder: null, // Folder context for "New" dropdown ('' = root, null = not set)
|
2025-11-26 13:38:47 +00:00
|
|
|
|
dropdownPosition: { top: 0, left: 0 }, // Position for contextual dropdown
|
2025-11-17 13:20:57 +00:00
|
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
|
// Template state
|
|
|
|
|
|
showTemplateModal: false,
|
|
|
|
|
|
availableTemplates: [],
|
|
|
|
|
|
selectedTemplate: '',
|
|
|
|
|
|
newTemplateNoteName: '',
|
|
|
|
|
|
|
2026-04-17 07:27:36 +00:00
|
|
|
|
// New note / folder name modal (replaces window.prompt)
|
|
|
|
|
|
showCreateNameModal: false,
|
|
|
|
|
|
createNameModalKind: 'note',
|
|
|
|
|
|
createNameModalTargetFolder: '',
|
|
|
|
|
|
createNameModalInput: '',
|
|
|
|
|
|
|
|
|
|
|
|
// Rename folder modal (replaces window.prompt)
|
|
|
|
|
|
showRenameFolderModal: false,
|
|
|
|
|
|
renameFolderPath: '',
|
|
|
|
|
|
renameFolderOldName: '',
|
|
|
|
|
|
renameFolderInput: '',
|
|
|
|
|
|
|
2026-04-17 07:39:52 +00:00
|
|
|
|
// Generic confirm dialog (replaces window.confirm)
|
|
|
|
|
|
showConfirmModal: false,
|
|
|
|
|
|
confirmModalTitle: '',
|
|
|
|
|
|
confirmModalMessage: '',
|
|
|
|
|
|
confirmModalDanger: true,
|
|
|
|
|
|
confirmModalConfirmLabel: '',
|
|
|
|
|
|
confirmModalCancelLabel: '',
|
|
|
|
|
|
_confirmModalResolve: null,
|
|
|
|
|
|
|
2026-01-15 09:07:51 +00:00
|
|
|
|
// Share state
|
|
|
|
|
|
showShareModal: false,
|
|
|
|
|
|
shareInfo: null,
|
|
|
|
|
|
shareLoading: false,
|
2026-02-09 17:55:55 +00:00
|
|
|
|
showShareQR: false,
|
2026-01-15 09:07:51 +00:00
|
|
|
|
shareLinkCopied: false,
|
|
|
|
|
|
_sharedNotePaths: new Set(), // O(1) lookup for shared note indicators
|
|
|
|
|
|
|
2026-01-15 15:32:35 +00:00
|
|
|
|
// Quick Switcher state (Ctrl+Alt+P)
|
|
|
|
|
|
showQuickSwitcher: false,
|
|
|
|
|
|
quickSwitcherQuery: '',
|
|
|
|
|
|
quickSwitcherIndex: 0,
|
|
|
|
|
|
quickSwitcherResults: [],
|
|
|
|
|
|
|
2026-04-16 16:17:34 +00:00
|
|
|
|
// Non-blocking notifications (replaces window.alert)
|
|
|
|
|
|
toasts: [],
|
|
|
|
|
|
_toastIdSeq: 0,
|
|
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Homepage state
|
2025-11-20 03:01:27 +00:00
|
|
|
|
selectedHomepageFolder: '',
|
2025-11-24 09:04:20 +00:00
|
|
|
|
_homepageCache: {
|
|
|
|
|
|
folderPath: null,
|
|
|
|
|
|
notes: null,
|
|
|
|
|
|
folders: null,
|
|
|
|
|
|
breadcrumb: null
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Homepage constants
|
|
|
|
|
|
HOMEPAGE_MAX_NOTES: 50,
|
2025-11-20 03:01:27 +00:00
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Computed-like helpers for homepage (cached for performance)
|
2025-11-20 14:31:36 +00:00
|
|
|
|
homepageNotes() {
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Return cached result if folder hasn't changed
|
|
|
|
|
|
if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.notes) {
|
|
|
|
|
|
return this._homepageCache.notes;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 14:31:36 +00:00
|
|
|
|
if (!this.folderTree || typeof this.folderTree !== 'object') {
|
2025-11-20 03:01:27 +00:00
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 14:31:36 +00:00
|
|
|
|
const folderNode = this.getFolderNode(this.selectedHomepageFolder || '');
|
2025-11-24 09:04:20 +00:00
|
|
|
|
const result = (folderNode && Array.isArray(folderNode.notes)) ? folderNode.notes : [];
|
2025-11-20 14:31:36 +00:00
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Cache the result
|
|
|
|
|
|
this._homepageCache.notes = result;
|
|
|
|
|
|
this._homepageCache.folderPath = this.selectedHomepageFolder;
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
2025-11-20 03:01:27 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-20 14:31:36 +00:00
|
|
|
|
homepageFolders() {
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Return cached result if folder hasn't changed
|
|
|
|
|
|
if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.folders) {
|
|
|
|
|
|
return this._homepageCache.folders;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 14:31:36 +00:00
|
|
|
|
if (!this.folderTree || typeof this.folderTree !== 'object') {
|
2025-11-20 03:01:27 +00:00
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Get child folders
|
2025-11-20 14:31:36 +00:00
|
|
|
|
let childFolders = [];
|
2025-11-20 03:01:27 +00:00
|
|
|
|
if (!this.selectedHomepageFolder) {
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Root level: all top-level folders
|
2025-11-20 14:31:36 +00:00
|
|
|
|
childFolders = Object.entries(this.folderTree)
|
|
|
|
|
|
.filter(([key]) => key !== '__root__')
|
|
|
|
|
|
.map(([, folder]) => folder);
|
2025-11-20 03:01:27 +00:00
|
|
|
|
} else {
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Inside a folder: get its children
|
2025-11-20 14:31:36 +00:00
|
|
|
|
const parentFolder = this.getFolderNode(this.selectedHomepageFolder);
|
2025-11-24 09:04:20 +00:00
|
|
|
|
if (parentFolder && parentFolder.children) {
|
|
|
|
|
|
childFolders = Object.values(parentFolder.children);
|
2025-11-20 14:31:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Map to simplified structure (note count already cached in folder node)
|
|
|
|
|
|
const result = childFolders
|
2025-11-20 14:31:36 +00:00
|
|
|
|
.map(folder => ({
|
|
|
|
|
|
name: folder.name,
|
|
|
|
|
|
path: folder.path,
|
2025-11-24 09:04:20 +00:00
|
|
|
|
noteCount: folder.noteCount || 0 // Use pre-calculated count
|
2025-11-20 14:31:36 +00:00
|
|
|
|
}))
|
|
|
|
|
|
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
2025-11-24 09:04:20 +00:00
|
|
|
|
|
|
|
|
|
|
// Cache the result
|
|
|
|
|
|
this._homepageCache.folders = result;
|
|
|
|
|
|
this._homepageCache.folderPath = this.selectedHomepageFolder;
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
2025-11-20 03:01:27 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-20 14:31:36 +00:00
|
|
|
|
homepageBreadcrumb() {
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Return cached result if folder hasn't changed
|
|
|
|
|
|
if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.breadcrumb) {
|
|
|
|
|
|
return this._homepageCache.breadcrumb;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 17:02:57 +00:00
|
|
|
|
const breadcrumb = [{ name: this.t('homepage.title'), path: '' }];
|
2025-11-24 09:04:20 +00:00
|
|
|
|
|
|
|
|
|
|
if (this.selectedHomepageFolder) {
|
|
|
|
|
|
const parts = this.selectedHomepageFolder.split('/').filter(Boolean);
|
2025-11-20 03:01:27 +00:00
|
|
|
|
let currentPath = '';
|
2025-11-24 09:04:20 +00:00
|
|
|
|
|
2025-11-20 03:01:27 +00:00
|
|
|
|
parts.forEach(part => {
|
2025-11-24 09:04:20 +00:00
|
|
|
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
2025-11-20 03:01:27 +00:00
|
|
|
|
breadcrumb.push({ name: part, path: currentPath });
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-11-24 09:04:20 +00:00
|
|
|
|
|
|
|
|
|
|
// Cache the result
|
|
|
|
|
|
this._homepageCache.breadcrumb = breadcrumb;
|
|
|
|
|
|
this._homepageCache.folderPath = this.selectedHomepageFolder;
|
|
|
|
|
|
|
|
|
|
|
|
return breadcrumb;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Helper: Format file size nicely
|
|
|
|
|
|
formatSize(bytes) {
|
|
|
|
|
|
if (!bytes) return '0 B';
|
|
|
|
|
|
const k = 1024;
|
|
|
|
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
2025-11-20 14:31:36 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-14 17:02:57 +00:00
|
|
|
|
// Helper: Format date using current locale
|
|
|
|
|
|
formatDate(dateStr) {
|
|
|
|
|
|
if (!dateStr) return '';
|
|
|
|
|
|
const date = new Date(dateStr);
|
|
|
|
|
|
if (isNaN(date.getTime())) return '';
|
|
|
|
|
|
return date.toLocaleDateString(this.currentLocale, {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: 'short',
|
|
|
|
|
|
day: 'numeric'
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-20 14:31:36 +00:00
|
|
|
|
getFolderNode(folderPath = '') {
|
|
|
|
|
|
if (!this.folderTree || typeof this.folderTree !== 'object') {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!folderPath) {
|
2025-11-24 09:04:20 +00:00
|
|
|
|
return this.folderTree['__root__'] || { name: '', path: '', children: {}, notes: [], noteCount: 0 };
|
2025-11-20 14:31:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parts = folderPath.split('/').filter(Boolean);
|
|
|
|
|
|
let currentLevel = this.folderTree;
|
|
|
|
|
|
let node = null;
|
|
|
|
|
|
|
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
|
if (!currentLevel[part]) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
node = currentLevel[part];
|
|
|
|
|
|
currentLevel = node.children || {};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return node;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Check if app is empty (no notes and no folders)
|
|
|
|
|
|
get isAppEmpty() {
|
|
|
|
|
|
const notesArray = Array.isArray(this.notes) ? this.notes : [];
|
|
|
|
|
|
const foldersArray = Array.isArray(this.allFolders) ? this.allFolders : [];
|
|
|
|
|
|
return notesArray.length === 0 && foldersArray.length === 0;
|
2025-11-20 03:01:27 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-17 16:29:00 +00:00
|
|
|
|
// Mermaid state cache
|
|
|
|
|
|
lastMermaidTheme: null,
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Media viewer state
|
|
|
|
|
|
currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility)
|
2026-04-21 13:22:09 +00:00
|
|
|
|
currentMediaType: 'image', // 'image', 'audio', 'video', 'document', 'drawing'
|
|
|
|
|
|
|
|
|
|
|
|
// Drawing canvas (drawing-*.png only) — ops are session-only until Save flattens to PNG
|
|
|
|
|
|
drawingTool: 'freehand',
|
|
|
|
|
|
drawingColor: '#1a1a1a',
|
|
|
|
|
|
drawingLineWidth: 4,
|
|
|
|
|
|
drawingOps: [],
|
|
|
|
|
|
drawingRedoStack: [],
|
|
|
|
|
|
drawingDraft: null,
|
|
|
|
|
|
drawingIsPointerDown: false,
|
2026-04-21 15:39:22 +00:00
|
|
|
|
/** True after the PNG from disk has been decoded into _drawingBaseImage; false after Clear. */
|
|
|
|
|
|
drawingHasRasterFromFile: false,
|
2026-04-21 16:00:50 +00:00
|
|
|
|
/** Prevents overlapping drawingSave() runs (Ctrl+S + autosave + fast retries). */
|
|
|
|
|
|
_drawingSaveInFlight: false,
|
|
|
|
|
|
/** If true, run drawingSave again after the current one finishes (coalesce). */
|
|
|
|
|
|
_drawingSaveQueued: false,
|
2026-04-21 14:00:58 +00:00
|
|
|
|
_drawingAutosaveTimeout: null,
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// DOM element cache (to avoid repeated querySelector calls)
|
|
|
|
|
|
_domCache: {
|
|
|
|
|
|
editor: null,
|
|
|
|
|
|
previewContainer: null,
|
|
|
|
|
|
previewContent: null
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize app
|
|
|
|
|
|
async init() {
|
2025-12-10 08:41:54 +00:00
|
|
|
|
// Prevent double initialization (Alpine.js may call x-init twice in some cases)
|
|
|
|
|
|
if (window.__noteapp_initialized) return;
|
|
|
|
|
|
window.__noteapp_initialized = true;
|
|
|
|
|
|
|
2025-12-09 11:08:12 +00:00
|
|
|
|
// Store global reference for native event handlers in x-html content
|
|
|
|
|
|
window.$root = this;
|
|
|
|
|
|
|
|
|
|
|
|
// ESC key to cancel drag operations
|
|
|
|
|
|
document.addEventListener('keydown', (e) => {
|
2026-01-19 08:42:32 +00:00
|
|
|
|
if (e.key === 'Escape' && this.draggedItem) {
|
2025-12-09 11:08:12 +00:00
|
|
|
|
this.cancelDrag();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
await this.loadConfig();
|
|
|
|
|
|
await this.loadThemes();
|
|
|
|
|
|
await this.initTheme();
|
2025-12-14 17:02:57 +00:00
|
|
|
|
await this.loadAvailableLocales();
|
2025-12-15 13:39:59 +00:00
|
|
|
|
// Note: Translations are preloaded synchronously before Alpine init (see index.html)
|
|
|
|
|
|
// loadLocale() is only called when user changes language from settings
|
2025-11-05 16:48:41 +00:00
|
|
|
|
await this.loadNotes();
|
2026-01-15 09:07:51 +00:00
|
|
|
|
await this.loadSharedNotePaths();
|
2025-11-26 10:28:09 +00:00
|
|
|
|
await this.loadTemplates();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
await this.checkStatsPlugin();
|
2026-01-26 09:22:00 +00:00
|
|
|
|
this.loadLocalSettings();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Parse URL and load specific note if provided
|
2026-01-19 09:14:37 +00:00
|
|
|
|
this.loadItemFromURL();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2025-11-24 16:23:05 +00:00
|
|
|
|
// Set initial homepage state ONLY if we're actually on the homepage
|
|
|
|
|
|
if (window.location.pathname === '/') {
|
2025-11-20 03:01:27 +00:00
|
|
|
|
window.history.replaceState({ homepageFolder: '' }, '', '/');
|
2026-01-05 10:05:08 +00:00
|
|
|
|
document.title = this.appName;
|
2025-11-20 03:01:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Listen for browser back/forward navigation
|
|
|
|
|
|
window.addEventListener('popstate', (e) => {
|
|
|
|
|
|
if (e.state && e.state.notePath) {
|
2025-11-20 03:01:27 +00:00
|
|
|
|
// Navigating to a note
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const searchQuery = e.state.searchQuery || '';
|
|
|
|
|
|
this.loadNote(e.state.notePath, false, searchQuery); // false = don't update history
|
|
|
|
|
|
|
|
|
|
|
|
// Update search box and trigger search if needed
|
|
|
|
|
|
if (searchQuery) {
|
|
|
|
|
|
this.searchQuery = searchQuery;
|
|
|
|
|
|
this.searchNotes();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.searchQuery = '';
|
|
|
|
|
|
this.searchResults = [];
|
|
|
|
|
|
this.clearSearchHighlights();
|
|
|
|
|
|
}
|
2026-01-19 09:14:37 +00:00
|
|
|
|
} else if (e.state && e.state.mediaPath) {
|
|
|
|
|
|
// Navigating to a media file
|
|
|
|
|
|
this.viewMedia(e.state.mediaPath, null, false);
|
2025-11-20 03:01:27 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
// Navigating back to homepage
|
|
|
|
|
|
this.currentNote = '';
|
|
|
|
|
|
this.noteContent = '';
|
|
|
|
|
|
this.currentNoteName = '';
|
2025-12-16 15:55:05 +00:00
|
|
|
|
this.outline = [];
|
2026-03-24 11:02:34 +00:00
|
|
|
|
this.backlinks = [];
|
2026-01-15 09:07:51 +00:00
|
|
|
|
this.shareInfo = null; // Reset share info
|
2026-01-05 10:05:08 +00:00
|
|
|
|
document.title = this.appName;
|
2025-11-20 03:01:27 +00:00
|
|
|
|
|
|
|
|
|
|
// Restore homepage folder state if it was saved
|
|
|
|
|
|
if (e.state && e.state.homepageFolder !== undefined) {
|
|
|
|
|
|
this.selectedHomepageFolder = e.state.homepageFolder || '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// No folder state in history, go to root
|
|
|
|
|
|
this.selectedHomepageFolder = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Invalidate cache to force recalculation
|
|
|
|
|
|
this._homepageCache = {
|
|
|
|
|
|
folderPath: null,
|
|
|
|
|
|
notes: null,
|
|
|
|
|
|
folders: null,
|
|
|
|
|
|
breadcrumb: null
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-20 03:01:27 +00:00
|
|
|
|
// Clear search
|
|
|
|
|
|
this.searchQuery = '';
|
|
|
|
|
|
this.searchResults = [];
|
|
|
|
|
|
this.clearSearchHighlights();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Cache DOM references after initial render
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
this.refreshDOMCache();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-13 14:09:26 +00:00
|
|
|
|
// Setup mobile view mode handler
|
|
|
|
|
|
this.setupMobileViewMode();
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Watch view mode changes and auto-save
|
|
|
|
|
|
this.$watch('viewMode', (newValue) => {
|
|
|
|
|
|
this.saveViewMode();
|
|
|
|
|
|
// Scroll to top when switching modes
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
this.scrollToTop();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Watch for changes in note content to re-apply search highlights
|
|
|
|
|
|
this.$watch('noteContent', () => {
|
|
|
|
|
|
if (this.currentSearchHighlight) {
|
|
|
|
|
|
// Re-apply highlights after content changes (with small delay for render)
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
// Don't focus editor during content changes (false)
|
|
|
|
|
|
this.highlightSearchTerm(this.currentSearchHighlight, false);
|
|
|
|
|
|
}, 50);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
|
// Watch tags panel expanded state and save to localStorage
|
|
|
|
|
|
this.$watch('tagsExpanded', () => {
|
|
|
|
|
|
this.saveTagsExpanded();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// Watch favorites expanded state and save to localStorage
|
|
|
|
|
|
this.$watch('favoritesExpanded', () => {
|
|
|
|
|
|
this.saveFavoritesExpanded();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Setup keyboard shortcuts (only once to prevent double triggers)
|
|
|
|
|
|
if (!window.__noteapp_shortcuts_initialized) {
|
|
|
|
|
|
window.__noteapp_shortcuts_initialized = true;
|
|
|
|
|
|
window.addEventListener('keydown', (e) => {
|
2026-02-23 09:16:52 +00:00
|
|
|
|
// Use e.key (not e.code) for letter keys to support non-QWERTY keyboard layouts
|
2025-12-14 14:33:53 +00:00
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
// Ctrl/Cmd + S to save (drawing saves PNG; notes save markdown)
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (this.currentMedia && this.currentMediaType === 'drawing') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.drawingSave();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.saveNote();
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 15:32:35 +00:00
|
|
|
|
// Ctrl/Cmd + Alt + P for Quick Switcher
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'p') {
|
2026-01-15 15:32:35 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.openQuickSwitcher();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 14:33:53 +00:00
|
|
|
|
// Ctrl/Cmd + Alt/Option + N for new note
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'n') {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
e.preventDefault();
|
2025-11-17 13:24:40 +00:00
|
|
|
|
this.createNote();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 14:33:53 +00:00
|
|
|
|
// Ctrl/Cmd + Alt/Option + F for new folder
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'f') {
|
2025-11-17 13:38:07 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.createFolder();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
// Ctrl/Cmd + Z for undo (drawing vs note editor)
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (this.currentMedia && this.currentMediaType === 'drawing') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.drawingUndo();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.undo();
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 14:33:53 +00:00
|
|
|
|
// Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (this.currentMedia && this.currentMediaType === 'drawing') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.drawingRedo();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.redo();
|
|
|
|
|
|
}
|
2025-12-14 14:33:53 +00:00
|
|
|
|
}
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (this.currentMedia && this.currentMediaType === 'drawing') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.drawingRedo();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.redo();
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// F3 for next search match
|
2025-12-14 14:33:53 +00:00
|
|
|
|
if (e.code === 'F3' && !e.shiftKey) {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.nextMatch();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Shift + F3 for previous search match
|
2025-12-14 14:33:53 +00:00
|
|
|
|
if (e.code === 'F3' && e.shiftKey) {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.previousMatch();
|
|
|
|
|
|
}
|
2025-11-24 09:17:21 +00:00
|
|
|
|
|
|
|
|
|
|
// Only apply markdown shortcuts when editor is focused and a note is open
|
|
|
|
|
|
const isEditorFocused = document.activeElement?.id === 'note-editor';
|
|
|
|
|
|
if (isEditorFocused && this.currentNote) {
|
|
|
|
|
|
// Ctrl/Cmd + B for bold
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
|
2025-11-24 09:17:21 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.wrapSelection('**', '**', 'bold text');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Ctrl/Cmd + I for italic
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'i') {
|
2025-11-24 09:17:21 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.wrapSelection('*', '*', 'italic text');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Ctrl/Cmd + K for link
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
2025-11-24 09:17:21 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.insertLink();
|
|
|
|
|
|
}
|
2025-12-11 17:11:25 +00:00
|
|
|
|
|
2025-12-14 14:33:53 +00:00
|
|
|
|
// Ctrl/Cmd + Alt/Option + T for table
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 't') {
|
2025-12-11 17:11:25 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.insertTable();
|
|
|
|
|
|
}
|
2025-12-12 16:18:14 +00:00
|
|
|
|
|
2025-12-14 14:33:53 +00:00
|
|
|
|
// Ctrl/Cmd + Alt/Option + Z for Zen mode
|
2026-02-23 09:16:52 +00:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'z') {
|
2025-12-12 16:18:14 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.toggleZenMode();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Escape to exit Zen mode (works anywhere)
|
|
|
|
|
|
if (e.key === 'Escape' && this.zenMode) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.toggleZenMode();
|
2025-11-24 09:17:21 +00:00
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Note: setupScrollSync() is called when a note is loaded (see loadNote())
|
|
|
|
|
|
|
|
|
|
|
|
// Listen for system theme changes
|
|
|
|
|
|
if (window.matchMedia) {
|
|
|
|
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
|
|
|
|
if (this.currentTheme === 'system') {
|
|
|
|
|
|
this.applyTheme('system');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-12-12 16:18:14 +00:00
|
|
|
|
|
|
|
|
|
|
// Listen for fullscreen changes (to sync zen mode state)
|
|
|
|
|
|
document.addEventListener('fullscreenchange', () => {
|
|
|
|
|
|
if (!document.fullscreenElement && this.zenMode) {
|
|
|
|
|
|
// User exited fullscreen manually, exit zen mode too
|
|
|
|
|
|
this.zenMode = false;
|
|
|
|
|
|
this.viewMode = this.previousViewMode;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-04-16 16:17:34 +00:00
|
|
|
|
|
|
|
|
|
|
// Toasts from ErrorHandler and code paths without Alpine `this`
|
|
|
|
|
|
if (!window.__noteapp_toast_listener) {
|
|
|
|
|
|
window.__noteapp_toast_listener = true;
|
|
|
|
|
|
window.addEventListener('notediscovery:toast', (e) => {
|
|
|
|
|
|
const d = e.detail || {};
|
|
|
|
|
|
if (typeof d.message !== 'string' || !d.message) return;
|
|
|
|
|
|
this.toast(d.message, {
|
|
|
|
|
|
type: d.type || 'error',
|
|
|
|
|
|
durationMs: d.durationMs
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Load app configuration
|
|
|
|
|
|
async loadConfig() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/config');
|
|
|
|
|
|
const config = await response.json();
|
|
|
|
|
|
this.appName = config.name;
|
2025-11-23 15:45:47 +00:00
|
|
|
|
this.appVersion = config.version || '0.0.0';
|
2025-12-06 17:40:28 +00:00
|
|
|
|
this.authEnabled = config.authentication?.enabled || false;
|
2025-12-17 09:32:02 +00:00
|
|
|
|
this.demoMode = config.demoMode || false;
|
2026-01-22 09:43:47 +00:00
|
|
|
|
this.alreadyDonated = config.alreadyDonated || false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load config:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Load available themes from backend
|
|
|
|
|
|
async loadThemes() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/themes');
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
// Use theme names directly from backend (already include emojis)
|
|
|
|
|
|
this.availableThemes = data.themes;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load themes:', error);
|
|
|
|
|
|
// Fallback to default themes
|
|
|
|
|
|
this.availableThemes = [
|
|
|
|
|
|
{ id: 'light', name: '🌞 Light' },
|
|
|
|
|
|
{ id: 'dark', name: '🌙 Dark' }
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize theme system
|
|
|
|
|
|
async initTheme() {
|
|
|
|
|
|
// Load saved theme preference from localStorage
|
|
|
|
|
|
const savedTheme = localStorage.getItem('noteDiscoveryTheme') || 'light';
|
|
|
|
|
|
this.currentTheme = savedTheme;
|
|
|
|
|
|
await this.applyTheme(savedTheme);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Set and apply theme
|
|
|
|
|
|
async setTheme(themeId) {
|
|
|
|
|
|
this.currentTheme = themeId;
|
|
|
|
|
|
localStorage.setItem('noteDiscoveryTheme', themeId);
|
|
|
|
|
|
await this.applyTheme(themeId);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-09 14:33:49 +00:00
|
|
|
|
// Syntax highlighting toggle
|
|
|
|
|
|
toggleSyntaxHighlight() {
|
|
|
|
|
|
this.syntaxHighlightEnabled = !this.syntaxHighlightEnabled;
|
|
|
|
|
|
localStorage.setItem('syntaxHighlightEnabled', this.syntaxHighlightEnabled);
|
|
|
|
|
|
if (this.syntaxHighlightEnabled) {
|
|
|
|
|
|
this.updateSyntaxHighlight();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-26 09:22:00 +00:00
|
|
|
|
// Load all localStorage settings at once using centralized config
|
|
|
|
|
|
loadLocalSettings() {
|
|
|
|
|
|
for (const [prop, config] of Object.entries(LOCAL_SETTINGS)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const saved = localStorage.getItem(config.key);
|
|
|
|
|
|
|
|
|
|
|
|
if (saved === null) {
|
|
|
|
|
|
// Use default value if not set
|
|
|
|
|
|
this[prop] = config.default;
|
|
|
|
|
|
} else if (config.type === 'boolean') {
|
|
|
|
|
|
this[prop] = saved === 'true';
|
|
|
|
|
|
} else if (config.type === 'number') {
|
|
|
|
|
|
const num = parseFloat(saved);
|
|
|
|
|
|
// Validate range if specified
|
|
|
|
|
|
if (!isNaN(num) &&
|
|
|
|
|
|
(config.min === undefined || num >= config.min) &&
|
|
|
|
|
|
(config.max === undefined || num <= config.max)) {
|
|
|
|
|
|
this[prop] = num;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this[prop] = config.default;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (config.type === 'string') {
|
|
|
|
|
|
// Validate against allowed values if specified
|
|
|
|
|
|
if (!config.valid || config.valid.includes(saved)) {
|
|
|
|
|
|
this[prop] = saved;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this[prop] = config.default;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (config.type === 'json') {
|
|
|
|
|
|
this[prop] = JSON.parse(saved);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(`Error loading setting ${prop}:`, error);
|
|
|
|
|
|
this[prop] = config.default;
|
|
|
|
|
|
}
|
2025-12-09 14:33:49 +00:00
|
|
|
|
}
|
2026-01-26 09:22:00 +00:00
|
|
|
|
|
|
|
|
|
|
// Special case: favorites also needs to update the Set for O(1) lookups
|
|
|
|
|
|
this.favoritesSet = new Set(this.favorites);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Readable line length toggle (for preview max-width)
|
|
|
|
|
|
toggleReadableLineLength() {
|
|
|
|
|
|
this.readableLineLength = !this.readableLineLength;
|
|
|
|
|
|
localStorage.setItem('readableLineLength', this.readableLineLength);
|
2025-12-09 14:33:49 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-23 09:59:58 +00:00
|
|
|
|
// Hide underscore folders toggle (hides _attachments, _templates, etc. from sidebar)
|
|
|
|
|
|
toggleHideUnderscoreFolders() {
|
|
|
|
|
|
this.hideUnderscoreFolders = !this.hideUnderscoreFolders;
|
|
|
|
|
|
localStorage.setItem('hideUnderscoreFolders', this.hideUnderscoreFolders);
|
|
|
|
|
|
},
|
2026-04-06 09:25:21 +00:00
|
|
|
|
|
|
|
|
|
|
// Tab inserts tab toggle (Tab key inserts tab character instead of changing focus)
|
|
|
|
|
|
toggleTabInsertsTab() {
|
|
|
|
|
|
this.tabInsertsTab = !this.tabInsertsTab;
|
|
|
|
|
|
localStorage.setItem('tabInsertsTab', this.tabInsertsTab);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-12 16:46:35 +00:00
|
|
|
|
/**
|
2026-04-13 15:12:04 +00:00
|
|
|
|
* Enter: continue blockquote / bullet / ordered list / task list; second Enter on empty item exits (see editor-markdown-continue.js).
|
2026-04-12 16:46:35 +00:00
|
|
|
|
*/
|
|
|
|
|
|
handleEditorEnterKey(event) {
|
|
|
|
|
|
if (typeof EditorMarkdownContinue === 'undefined') return;
|
|
|
|
|
|
const textarea = event.target;
|
|
|
|
|
|
if (!textarea || textarea.id !== 'note-editor') return;
|
|
|
|
|
|
|
|
|
|
|
|
const result = EditorMarkdownContinue.tryEnter(
|
|
|
|
|
|
this.noteContent,
|
|
|
|
|
|
textarea.selectionStart,
|
|
|
|
|
|
textarea.selectionEnd,
|
|
|
|
|
|
event
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!result.handled) return;
|
|
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
this.noteContent = result.text;
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
textarea.selectionStart = textarea.selectionEnd = result.cursor;
|
|
|
|
|
|
});
|
|
|
|
|
|
this.autoSave();
|
|
|
|
|
|
this.updateSyntaxHighlight();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-06 14:00:00 +00:00
|
|
|
|
// Sort mode configuration
|
|
|
|
|
|
sortModes: ['a-z', 'z-a', 'newest', 'oldest', 'largest', 'smallest'],
|
|
|
|
|
|
sortModeIcons: {
|
|
|
|
|
|
'a-z': 'A↓',
|
|
|
|
|
|
'z-a': 'Z↓',
|
|
|
|
|
|
'newest': '🕐↓',
|
|
|
|
|
|
'oldest': '🕐↑',
|
|
|
|
|
|
'largest': '📄↓',
|
|
|
|
|
|
'smallest': '📄↑'
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Cycle through sort modes
|
|
|
|
|
|
cycleSortMode() {
|
|
|
|
|
|
const currentIndex = this.sortModes.indexOf(this.sortMode);
|
|
|
|
|
|
const nextIndex = (currentIndex + 1) % this.sortModes.length;
|
|
|
|
|
|
this.sortMode = this.sortModes[nextIndex];
|
|
|
|
|
|
localStorage.setItem('sortMode', this.sortMode);
|
|
|
|
|
|
// Rebuild tree to apply new sort order
|
|
|
|
|
|
this.buildFolderTree();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get current sort icon
|
|
|
|
|
|
getSortIcon() {
|
|
|
|
|
|
return this.sortModeIcons[this.sortMode] || 'A↓';
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get sort comparator based on current mode (for notes)
|
|
|
|
|
|
getSortComparator() {
|
|
|
|
|
|
switch (this.sortMode) {
|
|
|
|
|
|
case 'z-a':
|
|
|
|
|
|
return (a, b) => b.name.toLowerCase().localeCompare(a.name.toLowerCase());
|
|
|
|
|
|
case 'newest':
|
|
|
|
|
|
return (a, b) => (b.modified || '').localeCompare(a.modified || '');
|
|
|
|
|
|
case 'oldest':
|
|
|
|
|
|
return (a, b) => (a.modified || '').localeCompare(b.modified || '');
|
|
|
|
|
|
case 'largest':
|
|
|
|
|
|
return (a, b) => (b.size || 0) - (a.size || 0);
|
|
|
|
|
|
case 'smallest':
|
|
|
|
|
|
return (a, b) => (a.size || 0) - (b.size || 0);
|
|
|
|
|
|
case 'a-z':
|
|
|
|
|
|
default:
|
|
|
|
|
|
return (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get sort comparator for folders (only A-Z/Z-A, others default to A-Z)
|
|
|
|
|
|
getFolderSortComparator() {
|
|
|
|
|
|
if (this.sortMode === 'z-a') {
|
|
|
|
|
|
return (a, b) => b.name.toLowerCase().localeCompare(a.name.toLowerCase());
|
|
|
|
|
|
}
|
|
|
|
|
|
// Default: A-Z for all other modes
|
|
|
|
|
|
return (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-09 14:33:49 +00:00
|
|
|
|
// Update syntax highlight overlay (debounced, called on input)
|
|
|
|
|
|
updateSyntaxHighlight() {
|
|
|
|
|
|
if (!this.syntaxHighlightEnabled) return;
|
|
|
|
|
|
|
|
|
|
|
|
clearTimeout(this.syntaxHighlightTimeout);
|
|
|
|
|
|
this.syntaxHighlightTimeout = setTimeout(() => {
|
|
|
|
|
|
const overlay = document.getElementById('syntax-overlay');
|
|
|
|
|
|
if (overlay) {
|
|
|
|
|
|
overlay.innerHTML = this.highlightMarkdown(this.noteContent);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 50); // 50ms debounce
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Sync overlay scroll with textarea
|
|
|
|
|
|
syncOverlayScroll() {
|
|
|
|
|
|
const textarea = document.getElementById('note-editor');
|
|
|
|
|
|
const overlay = document.getElementById('syntax-overlay');
|
|
|
|
|
|
if (textarea && overlay) {
|
|
|
|
|
|
overlay.scrollTop = textarea.scrollTop;
|
|
|
|
|
|
overlay.scrollLeft = textarea.scrollLeft;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Highlight markdown syntax
|
|
|
|
|
|
highlightMarkdown(text) {
|
|
|
|
|
|
if (!text) return '';
|
|
|
|
|
|
|
|
|
|
|
|
// Escape HTML first
|
|
|
|
|
|
let html = this.escapeHtml(text);
|
|
|
|
|
|
|
2025-12-10 08:10:58 +00:00
|
|
|
|
// Store code blocks and inline code with placeholders to protect from other patterns
|
|
|
|
|
|
const codePlaceholders = [];
|
2025-12-09 14:33:49 +00:00
|
|
|
|
|
2025-12-13 11:24:43 +00:00
|
|
|
|
// Code blocks FIRST - protect them before anything else
|
2025-12-10 08:10:58 +00:00
|
|
|
|
html = html.replace(/(```[\s\S]*?```)/g, (match) => {
|
|
|
|
|
|
codePlaceholders.push('<span class="md-codeblock">' + match + '</span>');
|
|
|
|
|
|
return `\x00CODE${codePlaceholders.length - 1}\x00`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-13 11:24:43 +00:00
|
|
|
|
// Frontmatter (must be at VERY start of document, not any line)
|
|
|
|
|
|
if (html.startsWith('---\n')) {
|
|
|
|
|
|
html = html.replace(/^(---\n[\s\S]*?\n---)/, (match) => {
|
|
|
|
|
|
codePlaceholders.push('<span class="md-frontmatter">' + match + '</span>');
|
|
|
|
|
|
return `\x00CODE${codePlaceholders.length - 1}\x00`;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 08:10:58 +00:00
|
|
|
|
// Inline code - protect it
|
|
|
|
|
|
html = html.replace(/`([^`\n]+)`/g, (match) => {
|
|
|
|
|
|
codePlaceholders.push('<span class="md-code">' + match + '</span>');
|
|
|
|
|
|
return `\x00CODE${codePlaceholders.length - 1}\x00`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Now apply other patterns (they won't match inside protected code)
|
2025-12-09 14:33:49 +00:00
|
|
|
|
|
2026-02-02 17:28:30 +00:00
|
|
|
|
// Headings - capture the whitespace to preserve exact characters (tabs vs spaces)
|
|
|
|
|
|
// This prevents cursor/selection misalignment
|
|
|
|
|
|
html = html.replace(/^(#{1,6})(\s)(.*)$/gm, '<span class="md-heading">$1$2$3</span>');
|
2025-12-09 14:33:49 +00:00
|
|
|
|
|
|
|
|
|
|
// Bold (must come before italic)
|
|
|
|
|
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<span class="md-bold">**$1**</span>');
|
|
|
|
|
|
html = html.replace(/__([^_]+)__/g, '<span class="md-bold">__$1__</span>');
|
|
|
|
|
|
|
|
|
|
|
|
// Italic
|
|
|
|
|
|
html = html.replace(/(?<![*\\])\*([^*\n]+)\*(?!\*)/g, '<span class="md-italic">*$1*</span>');
|
|
|
|
|
|
html = html.replace(/(?<![_\\])_([^_\n]+)_(?!_)/g, '<span class="md-italic">_$1_</span>');
|
|
|
|
|
|
|
|
|
|
|
|
// Wikilinks [[...]]
|
|
|
|
|
|
html = html.replace(/\[\[([^\]]+)\]\]/g, '<span class="md-wikilink">[[$1]]</span>');
|
|
|
|
|
|
|
|
|
|
|
|
// Links [text](url)
|
|
|
|
|
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<span class="md-link">[$1]</span><span class="md-link-url">($2)</span>');
|
|
|
|
|
|
|
2026-02-02 17:28:30 +00:00
|
|
|
|
// Lists - use ([ \t]) to capture the space/tab and preserve exact characters
|
|
|
|
|
|
// IMPORTANT: Don't add any characters (like \u200B) that aren't in the original,
|
|
|
|
|
|
// as this breaks cursor/selection alignment between textarea and overlay
|
|
|
|
|
|
html = html.replace(/^(\s*)([-*+])([ \t])(.*)$/gm, (match, indent, bullet, space, rest) => {
|
|
|
|
|
|
return `${indent}<span class="md-list">${bullet}</span>${space}${rest}`;
|
2026-01-09 09:57:34 +00:00
|
|
|
|
});
|
2026-02-02 17:28:30 +00:00
|
|
|
|
html = html.replace(/^(\s*)(\d+\.)([ \t])(.*)$/gm, (match, indent, bullet, space, rest) => {
|
|
|
|
|
|
return `${indent}<span class="md-list">${bullet}</span>${space}${rest}`;
|
2026-01-09 09:57:34 +00:00
|
|
|
|
});
|
2025-12-09 14:33:49 +00:00
|
|
|
|
|
|
|
|
|
|
// Blockquotes
|
|
|
|
|
|
html = html.replace(/^(>.*)$/gm, '<span class="md-blockquote">$1</span>');
|
|
|
|
|
|
|
|
|
|
|
|
// Horizontal rules
|
|
|
|
|
|
html = html.replace(/^([-*_]{3,})$/gm, '<span class="md-hr">$1</span>');
|
|
|
|
|
|
|
2025-12-10 08:10:58 +00:00
|
|
|
|
// Restore protected code blocks
|
|
|
|
|
|
html = html.replace(/\x00CODE(\d+)\x00/g, (match, index) => codePlaceholders[parseInt(index)]);
|
|
|
|
|
|
|
2025-12-14 11:19:49 +00:00
|
|
|
|
// Add trailing space to match textarea's phantom line for cursor
|
|
|
|
|
|
// This ensures the overlay and textarea have the same content height
|
|
|
|
|
|
html += '\n ';
|
|
|
|
|
|
|
2025-12-09 14:33:49 +00:00
|
|
|
|
return html;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Apply theme to document
|
|
|
|
|
|
async applyTheme(themeId) {
|
|
|
|
|
|
// Load theme CSS from file
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/themes/${themeId}`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
// Create or update style element
|
|
|
|
|
|
let styleEl = document.getElementById('dynamic-theme');
|
|
|
|
|
|
if (!styleEl) {
|
|
|
|
|
|
styleEl = document.createElement('style');
|
|
|
|
|
|
styleEl.id = 'dynamic-theme';
|
|
|
|
|
|
document.head.appendChild(styleEl);
|
|
|
|
|
|
}
|
|
|
|
|
|
styleEl.textContent = data.css;
|
|
|
|
|
|
|
|
|
|
|
|
// Set data attribute for theme-specific selectors
|
|
|
|
|
|
document.documentElement.setAttribute('data-theme', themeId);
|
|
|
|
|
|
|
|
|
|
|
|
// Load appropriate Highlight.js theme for code syntax highlighting
|
|
|
|
|
|
const highlightTheme = document.getElementById('highlight-theme');
|
|
|
|
|
|
if (highlightTheme) {
|
|
|
|
|
|
if (themeId === 'light') {
|
|
|
|
|
|
highlightTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Use dark theme for dark/custom themes
|
|
|
|
|
|
highlightTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-17 16:29:00 +00:00
|
|
|
|
|
|
|
|
|
|
// Re-render Mermaid diagrams with new theme if there's a current note
|
|
|
|
|
|
if (this.currentNote) {
|
|
|
|
|
|
// Small delay to allow theme CSS to load
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
// Clear existing Mermaid renders
|
|
|
|
|
|
const previewContent = document.querySelector('.markdown-preview');
|
|
|
|
|
|
if (previewContent) {
|
|
|
|
|
|
const mermaidContainers = previewContent.querySelectorAll('.mermaid-rendered');
|
|
|
|
|
|
mermaidContainers.forEach(container => {
|
|
|
|
|
|
// Replace with the original code block for re-rendering
|
|
|
|
|
|
const parent = container.parentElement;
|
|
|
|
|
|
if (parent && container.dataset.originalCode) {
|
|
|
|
|
|
const pre = document.createElement('pre');
|
|
|
|
|
|
const code = document.createElement('code');
|
|
|
|
|
|
code.className = 'language-mermaid';
|
|
|
|
|
|
code.textContent = container.dataset.originalCode;
|
|
|
|
|
|
pre.appendChild(code);
|
|
|
|
|
|
parent.replaceChild(pre, container);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
// Re-render with new theme
|
|
|
|
|
|
this.renderMermaid();
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
}
|
2025-12-04 17:05:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Refresh graph if visible (longer delay to ensure CSS is applied)
|
|
|
|
|
|
if (this.showGraph) {
|
|
|
|
|
|
setTimeout(() => this.initGraph(), 300);
|
|
|
|
|
|
}
|
2026-01-01 14:26:03 +00:00
|
|
|
|
|
|
|
|
|
|
// Update PWA theme-color meta tag to match current theme
|
|
|
|
|
|
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
|
|
|
|
|
if (themeColorMeta) {
|
|
|
|
|
|
// Get the accent color from CSS variables
|
|
|
|
|
|
const accentColor = getComputedStyle(document.documentElement)
|
|
|
|
|
|
.getPropertyValue('--accent-primary').trim() || '#667eea';
|
|
|
|
|
|
themeColorMeta.setAttribute('content', accentColor);
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load theme:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-14 17:02:57 +00:00
|
|
|
|
// ==================== INTERNATIONALIZATION ====================
|
|
|
|
|
|
|
|
|
|
|
|
// Translation function - get translated string by key
|
|
|
|
|
|
t(key, params = {}) {
|
|
|
|
|
|
const keys = key.split('.');
|
|
|
|
|
|
let value = this.translations;
|
|
|
|
|
|
|
|
|
|
|
|
for (const k of keys) {
|
|
|
|
|
|
value = value?.[k];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback to key if translation not found (silently - default translations are inline)
|
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
|
return key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Replace {{param}} placeholders
|
|
|
|
|
|
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-16 16:17:34 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Non-blocking toast (replaces window.alert). Types map to theme CSS variables.
|
|
|
|
|
|
* @param {string} message
|
|
|
|
|
|
* @param {{ type?: 'error'|'warning'|'info'|'success', durationMs?: number }} [options]
|
|
|
|
|
|
* @returns {number|undefined} toast id
|
|
|
|
|
|
*/
|
|
|
|
|
|
toast(message, options = {}) {
|
|
|
|
|
|
if (typeof message !== 'string' || !message) return;
|
|
|
|
|
|
const type = ['error', 'warning', 'info', 'success'].includes(options.type)
|
|
|
|
|
|
? options.type
|
|
|
|
|
|
: 'info';
|
|
|
|
|
|
let durationMs = options.durationMs;
|
|
|
|
|
|
if (durationMs == null || typeof durationMs !== 'number' || durationMs < 0) {
|
|
|
|
|
|
durationMs = type === 'error' ? CONFIG.TOAST_DURATION_ERROR_MS
|
|
|
|
|
|
: type === 'warning' ? CONFIG.TOAST_DURATION_WARNING_MS
|
|
|
|
|
|
: type === 'success' ? CONFIG.TOAST_DURATION_SUCCESS_MS
|
|
|
|
|
|
: CONFIG.TOAST_DURATION_INFO_MS;
|
|
|
|
|
|
}
|
|
|
|
|
|
const id = ++this._toastIdSeq;
|
|
|
|
|
|
const entry = { id, message, type, _tid: null };
|
|
|
|
|
|
this.toasts.push(entry);
|
|
|
|
|
|
while (this.toasts.length > CONFIG.TOAST_MAX_VISIBLE) {
|
|
|
|
|
|
const first = this.toasts.shift();
|
|
|
|
|
|
if (first && first._tid) clearTimeout(first._tid);
|
|
|
|
|
|
}
|
|
|
|
|
|
entry._tid = setTimeout(() => this.dismissToast(id), durationMs);
|
|
|
|
|
|
return id;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
dismissToast(id) {
|
|
|
|
|
|
const idx = this.toasts.findIndex((t) => t.id === id);
|
|
|
|
|
|
if (idx === -1) return;
|
|
|
|
|
|
const entry = this.toasts[idx];
|
|
|
|
|
|
if (entry._tid) {
|
|
|
|
|
|
clearTimeout(entry._tid);
|
|
|
|
|
|
entry._tid = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.toasts.splice(idx, 1);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-17 09:03:40 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Get localized error message from FilenameValidator result
|
|
|
|
|
|
* @param {object} validation - The validation result from FilenameValidator
|
|
|
|
|
|
* @param {string} type - 'note' or 'folder'
|
|
|
|
|
|
* @returns {string} Localized error message
|
|
|
|
|
|
*/
|
|
|
|
|
|
getValidationErrorMessage(validation, type = 'note') {
|
|
|
|
|
|
switch (validation.error) {
|
|
|
|
|
|
case 'empty':
|
|
|
|
|
|
return type === 'note'
|
|
|
|
|
|
? this.t('notes.empty_name')
|
|
|
|
|
|
: this.t('folders.invalid_name');
|
|
|
|
|
|
case 'forbidden_chars':
|
|
|
|
|
|
return this.t('validation.forbidden_chars', {
|
|
|
|
|
|
chars: validation.forbiddenChars
|
|
|
|
|
|
});
|
|
|
|
|
|
case 'reserved_name':
|
|
|
|
|
|
return this.t('validation.reserved_name');
|
|
|
|
|
|
case 'invalid_dot':
|
|
|
|
|
|
return this.t('validation.invalid_dot');
|
|
|
|
|
|
case 'trailing_dot_space':
|
|
|
|
|
|
return this.t('validation.trailing_dot_space');
|
|
|
|
|
|
default:
|
|
|
|
|
|
return type === 'note'
|
|
|
|
|
|
? this.t('notes.invalid_name')
|
|
|
|
|
|
: this.t('folders.invalid_name');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-14 17:02:57 +00:00
|
|
|
|
// Load available locales from backend
|
|
|
|
|
|
async loadAvailableLocales() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/locales');
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
this.availableLocales = data.locales || [];
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load available locales:', error);
|
|
|
|
|
|
this.availableLocales = [{ code: 'en-US', name: 'English', flag: '🇺🇸' }];
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Load translations for a specific locale
|
|
|
|
|
|
async loadLocale(localeCode = null) {
|
|
|
|
|
|
const targetLocale = localeCode || localStorage.getItem('locale') || 'en-US';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/locales/${targetLocale}`);
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.translations = await response.json();
|
|
|
|
|
|
this.currentLocale = targetLocale;
|
|
|
|
|
|
localStorage.setItem('locale', targetLocale);
|
|
|
|
|
|
} else if (targetLocale !== 'en-US') {
|
|
|
|
|
|
// Fallback to en-US if requested locale not found
|
|
|
|
|
|
await this.loadLocale('en-US');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load locale:', error);
|
|
|
|
|
|
// If en-US also fails, translations will be empty and t() will return keys
|
|
|
|
|
|
if (targetLocale !== 'en-US') {
|
|
|
|
|
|
await this.loadLocale('en-US');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Change locale and reload translations
|
|
|
|
|
|
async changeLocale(localeCode) {
|
|
|
|
|
|
await this.loadLocale(localeCode);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== END INTERNATIONALIZATION ====================
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Load all notes
|
|
|
|
|
|
async loadNotes() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/notes');
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
this.notes = data.notes;
|
|
|
|
|
|
this.allFolders = data.folders || [];
|
2025-12-12 17:11:29 +00:00
|
|
|
|
this.buildNoteLookupMaps(); // Build O(1) lookup maps
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.buildFolderTree();
|
2025-11-24 16:11:22 +00:00
|
|
|
|
await this.loadTags(); // Load tags after notes are loaded
|
2025-11-05 16:48:41 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('load notes', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// Build lookup maps for O(1) wikilink resolution
|
|
|
|
|
|
buildNoteLookupMaps() {
|
|
|
|
|
|
// Clear existing maps
|
|
|
|
|
|
this._noteLookup.byPath.clear();
|
|
|
|
|
|
this._noteLookup.byPathLower.clear();
|
|
|
|
|
|
this._noteLookup.byName.clear();
|
|
|
|
|
|
this._noteLookup.byNameLower.clear();
|
|
|
|
|
|
this._noteLookup.byEndPath.clear();
|
2026-01-18 15:00:01 +00:00
|
|
|
|
this._mediaLookup.clear();
|
2025-12-12 17:11:29 +00:00
|
|
|
|
|
|
|
|
|
|
for (const note of this.notes) {
|
|
|
|
|
|
const path = note.path;
|
|
|
|
|
|
const pathLower = path.toLowerCase();
|
|
|
|
|
|
const name = note.name;
|
|
|
|
|
|
const nameLower = name.toLowerCase();
|
2026-01-05 11:51:24 +00:00
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Handle media files separately - build media lookup map
|
|
|
|
|
|
if (note.type !== 'note') {
|
2026-02-23 09:52:25 +00:00
|
|
|
|
// Map filename WITH extension (case-insensitive) to full path
|
|
|
|
|
|
// Use path to get filename with extension (note.name is stem without extension)
|
|
|
|
|
|
const filenameWithExt = path.split('/').pop().toLowerCase();
|
2026-01-05 11:51:24 +00:00
|
|
|
|
// First match wins if there are duplicates
|
2026-02-23 09:52:25 +00:00
|
|
|
|
if (!this._mediaLookup.has(filenameWithExt)) {
|
|
|
|
|
|
this._mediaLookup.set(filenameWithExt, path);
|
2026-01-05 11:51:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Notes only from here
|
2025-12-12 17:11:29 +00:00
|
|
|
|
const nameWithoutMd = name.replace(/\.md$/i, '');
|
|
|
|
|
|
const nameWithoutMdLower = nameWithoutMd.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
// Store all variations for fast lookup
|
|
|
|
|
|
this._noteLookup.byPath.set(path, true);
|
|
|
|
|
|
this._noteLookup.byPath.set(path.replace(/\.md$/i, ''), true);
|
|
|
|
|
|
this._noteLookup.byPathLower.set(pathLower, true);
|
|
|
|
|
|
this._noteLookup.byPathLower.set(pathLower.replace(/\.md$/i, ''), true);
|
|
|
|
|
|
this._noteLookup.byName.set(name, true);
|
|
|
|
|
|
this._noteLookup.byName.set(nameWithoutMd, true);
|
|
|
|
|
|
this._noteLookup.byNameLower.set(nameLower, true);
|
|
|
|
|
|
this._noteLookup.byNameLower.set(nameWithoutMdLower, true);
|
|
|
|
|
|
|
|
|
|
|
|
// End path matching (for /folder/note style links)
|
|
|
|
|
|
this._noteLookup.byEndPath.set('/' + nameWithoutMdLower, true);
|
|
|
|
|
|
this._noteLookup.byEndPath.set('/' + nameLower, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Fast O(1) check if a wikilink target exists
|
|
|
|
|
|
wikiLinkExists(linkTarget) {
|
|
|
|
|
|
const targetLower = linkTarget.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
// Check all lookup maps
|
|
|
|
|
|
return (
|
|
|
|
|
|
this._noteLookup.byPath.has(linkTarget) ||
|
|
|
|
|
|
this._noteLookup.byPath.has(linkTarget + '.md') ||
|
|
|
|
|
|
this._noteLookup.byPathLower.has(targetLower) ||
|
|
|
|
|
|
this._noteLookup.byPathLower.has(targetLower + '.md') ||
|
|
|
|
|
|
this._noteLookup.byName.has(linkTarget) ||
|
|
|
|
|
|
this._noteLookup.byNameLower.has(targetLower) ||
|
|
|
|
|
|
this._noteLookup.byEndPath.has('/' + targetLower) ||
|
|
|
|
|
|
this._noteLookup.byEndPath.has('/' + targetLower + '.md')
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Resolve media wikilink to full path (O(1) lookup)
|
2026-01-05 11:51:24 +00:00
|
|
|
|
// Returns the full path if found, null otherwise
|
2026-01-18 15:00:01 +00:00
|
|
|
|
resolveMediaWikilink(mediaName) {
|
|
|
|
|
|
const nameLower = mediaName.toLowerCase();
|
|
|
|
|
|
return this._mediaLookup.get(nameLower) || null;
|
2026-01-05 11:51:24 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-13 14:11:55 +00:00
|
|
|
|
// Resolve a Markdown image path to a vault-relative path (forward slashes).
|
|
|
|
|
|
// Relative paths use the note file's directory as base (same rules as CommonMark / browsers).
|
|
|
|
|
|
// A leading "/" (not "//") means vault-root-relative.
|
|
|
|
|
|
resolveMarkdownMediaPathForNote(noteVaultPath, rawSrc) {
|
|
|
|
|
|
const pathPart = rawSrc.split('#')[0].split('?')[0];
|
|
|
|
|
|
if (!pathPart) return '';
|
|
|
|
|
|
if (pathPart.startsWith('/') && !pathPart.startsWith('//')) {
|
|
|
|
|
|
return pathPart.replace(/^\/+/, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!noteVaultPath) {
|
|
|
|
|
|
return pathPart;
|
|
|
|
|
|
}
|
|
|
|
|
|
const dir = noteVaultPath.includes('/')
|
|
|
|
|
|
? noteVaultPath.slice(0, noteVaultPath.lastIndexOf('/'))
|
|
|
|
|
|
: '';
|
|
|
|
|
|
const base = `https://vn.invalid/${dir ? `${dir}/` : ''}`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const u = new URL(pathPart, base);
|
|
|
|
|
|
let p = u.pathname;
|
|
|
|
|
|
if (p.startsWith('/')) p = p.slice(1);
|
|
|
|
|
|
return p.split('/').filter(seg => seg !== '').map(seg => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return decodeURIComponent(seg);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return seg;
|
|
|
|
|
|
}
|
|
|
|
|
|
}).join('/');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return pathPart;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
encodeVaultRelativePathForMediaApi(vaultRelativePath) {
|
|
|
|
|
|
if (!vaultRelativePath) return '';
|
|
|
|
|
|
return vaultRelativePath.split('/').map(segment => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return encodeURIComponent(decodeURIComponent(segment));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return encodeURIComponent(segment);
|
|
|
|
|
|
}
|
|
|
|
|
|
}).join('/');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
|
// Load all tags
|
|
|
|
|
|
async loadTags() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/tags');
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
this.allTags = data.tags || {};
|
|
|
|
|
|
} catch (error) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
ErrorHandler.handle('load tags', error, false); // Optional: no toast
|
2025-11-24 16:11:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Debounced tag reload (prevents excessive API calls during typing)
|
|
|
|
|
|
loadTagsDebounced() {
|
|
|
|
|
|
// Clear existing timeout
|
|
|
|
|
|
if (this.tagReloadTimeout) {
|
|
|
|
|
|
clearTimeout(this.tagReloadTimeout);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set new timeout - reload tags 2 seconds after last save
|
|
|
|
|
|
this.tagReloadTimeout = setTimeout(() => {
|
|
|
|
|
|
this.loadTags();
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Toggle tag selection for filtering
|
|
|
|
|
|
toggleTag(tag) {
|
|
|
|
|
|
const index = this.selectedTags.indexOf(tag);
|
|
|
|
|
|
if (index > -1) {
|
|
|
|
|
|
this.selectedTags.splice(index, 1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.selectedTags.push(tag);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Apply unified filtering
|
|
|
|
|
|
this.applyFilters();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// Template Methods
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// Load available templates from _templates folder
|
|
|
|
|
|
async loadTemplates() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/templates');
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
this.availableTemplates = data.templates || [];
|
|
|
|
|
|
} catch (error) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
ErrorHandler.handle('load templates', error, false); // Optional: no toast
|
2025-11-26 10:28:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Create a new note from a template
|
|
|
|
|
|
async createNoteFromTemplate() {
|
|
|
|
|
|
if (!this.selectedTemplate || !this.newTemplateNoteName.trim()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-17 09:03:40 +00:00
|
|
|
|
// Validate the note name
|
|
|
|
|
|
const validation = FilenameValidator.validateFilename(this.newTemplateNoteName);
|
|
|
|
|
|
if (!validation.valid) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
|
2025-12-17 09:03:40 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 11:43:37 +00:00
|
|
|
|
// Determine the note path based on dropdown context
|
2025-12-17 09:03:40 +00:00
|
|
|
|
let notePath = validation.sanitized;
|
2025-11-26 10:28:09 +00:00
|
|
|
|
if (!notePath.endsWith('.md')) {
|
|
|
|
|
|
notePath += '.md';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
const targetFolder = this.inferredNewItemTargetFolder();
|
2025-11-27 13:13:39 +00:00
|
|
|
|
|
|
|
|
|
|
// If we have a target folder, create note in that folder
|
|
|
|
|
|
if (targetFolder) {
|
2025-11-26 11:43:37 +00:00
|
|
|
|
notePath = `${targetFolder}/${notePath}`;
|
2025-11-26 10:28:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 18:20:02 +00:00
|
|
|
|
// CRITICAL: Check if note already exists
|
|
|
|
|
|
const existingNote = this.notes.find(note => note.path === notePath);
|
|
|
|
|
|
if (existingNote) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('notes.already_exists', { name: validation.sanitized }), { type: 'warning' });
|
2025-11-26 18:20:02 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 10:28:09 +00:00
|
|
|
|
// Create note from template
|
|
|
|
|
|
const response = await fetch('/api/templates/create-note', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
templateName: this.selectedTemplate,
|
|
|
|
|
|
notePath: notePath
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json();
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(error.detail || this.t('templates.create_failed'), { type: 'error' });
|
2025-11-26 10:28:09 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
// Close modal and reset state
|
|
|
|
|
|
this.showTemplateModal = false;
|
|
|
|
|
|
this.selectedTemplate = '';
|
|
|
|
|
|
this.newTemplateNoteName = '';
|
|
|
|
|
|
|
|
|
|
|
|
// Reload notes and open the new note
|
|
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
await this.loadNote(data.path);
|
2026-02-09 17:17:12 +00:00
|
|
|
|
this.focusEditorForNewNote();
|
2025-11-26 10:28:09 +00:00
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('create note from template', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
|
// Clear all tag filters
|
|
|
|
|
|
clearTagFilters() {
|
|
|
|
|
|
this.selectedTags = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Apply unified filtering
|
|
|
|
|
|
this.applyFilters();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-16 15:55:05 +00:00
|
|
|
|
// ========================================================================
|
|
|
|
|
|
// Outline (TOC) Methods
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// Extract headings from markdown content for the outline
|
|
|
|
|
|
extractOutline(content) {
|
|
|
|
|
|
if (!content) {
|
|
|
|
|
|
this.outline = [];
|
2026-03-24 11:02:34 +00:00
|
|
|
|
this.backlinks = [];
|
2025-12-16 15:55:05 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const headings = [];
|
|
|
|
|
|
const lines = content.split('\n');
|
|
|
|
|
|
const slugCounts = {}; // Track duplicate slugs
|
|
|
|
|
|
|
2025-12-18 11:01:01 +00:00
|
|
|
|
// Skip frontmatter and code blocks
|
2025-12-16 15:55:05 +00:00
|
|
|
|
let inFrontmatter = false;
|
2025-12-18 11:01:01 +00:00
|
|
|
|
let inCodeBlock = false;
|
2025-12-16 15:55:05 +00:00
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
|
const line = lines[i];
|
|
|
|
|
|
|
|
|
|
|
|
// Handle frontmatter
|
|
|
|
|
|
if (i === 0 && line.trim() === '---') {
|
|
|
|
|
|
inFrontmatter = true;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (inFrontmatter) {
|
|
|
|
|
|
if (line.trim() === '---') {
|
|
|
|
|
|
inFrontmatter = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 11:01:01 +00:00
|
|
|
|
// Handle fenced code blocks (``` or ~~~)
|
|
|
|
|
|
if (line.trim().startsWith('```') || line.trim().startsWith('~~~')) {
|
|
|
|
|
|
inCodeBlock = !inCodeBlock;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (inCodeBlock) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 15:55:05 +00:00
|
|
|
|
// Match heading lines (# to ######)
|
|
|
|
|
|
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
const level = match[1].length;
|
|
|
|
|
|
const text = match[2].trim();
|
|
|
|
|
|
|
|
|
|
|
|
// Generate slug (GitHub-style)
|
|
|
|
|
|
let slug = text
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.replace(/[^\w\s-]/g, '') // Remove special chars
|
|
|
|
|
|
.replace(/\s+/g, '-') // Spaces to dashes
|
|
|
|
|
|
.replace(/-+/g, '-'); // Multiple dashes to single
|
|
|
|
|
|
|
|
|
|
|
|
// Handle duplicate slugs
|
|
|
|
|
|
if (slugCounts[slug] !== undefined) {
|
|
|
|
|
|
slugCounts[slug]++;
|
|
|
|
|
|
slug = `${slug}-${slugCounts[slug]}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
slugCounts[slug] = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
headings.push({
|
|
|
|
|
|
level,
|
|
|
|
|
|
text,
|
|
|
|
|
|
slug,
|
|
|
|
|
|
line: i + 1 // 1-indexed line number
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.outline = headings;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll to a heading in the editor or preview
|
|
|
|
|
|
scrollToHeading(heading) {
|
|
|
|
|
|
if (this.viewMode === 'preview' || this.viewMode === 'split') {
|
|
|
|
|
|
// In preview/split mode, scroll the preview pane
|
|
|
|
|
|
const preview = document.querySelector('.markdown-preview');
|
|
|
|
|
|
if (preview) {
|
|
|
|
|
|
// Find the heading element by text content (more reliable than ID)
|
|
|
|
|
|
const headingElements = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
|
|
|
|
for (const el of headingElements) {
|
|
|
|
|
|
if (el.textContent.trim() === heading.text) {
|
|
|
|
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
|
// Add a brief highlight effect
|
|
|
|
|
|
el.style.transition = 'background-color 0.3s';
|
|
|
|
|
|
el.style.backgroundColor = 'var(--accent-light)';
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
el.style.backgroundColor = '';
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.viewMode === 'edit' || this.viewMode === 'split') {
|
|
|
|
|
|
// In edit/split mode, scroll the editor to the line
|
|
|
|
|
|
const textarea = document.querySelector('.editor-textarea');
|
|
|
|
|
|
if (textarea && heading.line) {
|
|
|
|
|
|
const lines = textarea.value.split('\n');
|
|
|
|
|
|
let charPos = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate character position of the heading line
|
|
|
|
|
|
for (let i = 0; i < heading.line - 1 && i < lines.length; i++) {
|
|
|
|
|
|
charPos += lines[i].length + 1; // +1 for newline
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set cursor position and scroll
|
|
|
|
|
|
textarea.focus();
|
|
|
|
|
|
textarea.setSelectionRange(charPos, charPos);
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate scroll position (approximate)
|
|
|
|
|
|
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24;
|
|
|
|
|
|
const scrollTop = (heading.line - 1) * lineHeight - textarea.clientHeight / 3;
|
|
|
|
|
|
textarea.scrollTop = Math.max(0, scrollTop);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-03-24 11:02:34 +00:00
|
|
|
|
|
|
|
|
|
|
// Navigate to a backlink (note that links to current note)
|
|
|
|
|
|
navigateToBacklink(backlinkPath) {
|
|
|
|
|
|
this.loadNote(backlinkPath);
|
|
|
|
|
|
},
|
2025-12-16 15:55:05 +00:00
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
|
// Unified filtering logic combining tags and text search
|
|
|
|
|
|
async applyFilters() {
|
|
|
|
|
|
const hasTextSearch = this.searchQuery.trim().length > 0;
|
|
|
|
|
|
const hasTagFilter = this.selectedTags.length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Case 1: No filters at all → show full folder tree
|
|
|
|
|
|
if (!hasTextSearch && !hasTagFilter) {
|
2026-02-20 07:26:52 +00:00
|
|
|
|
this.isSearching = false;
|
2025-11-24 16:11:22 +00:00
|
|
|
|
this.searchResults = [];
|
|
|
|
|
|
this.currentSearchHighlight = '';
|
|
|
|
|
|
this.clearSearchHighlights();
|
|
|
|
|
|
this.buildFolderTree();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Case 2: Only tag filter → convert to flat list of matching notes
|
|
|
|
|
|
if (hasTagFilter && !hasTextSearch) {
|
2026-02-20 07:26:52 +00:00
|
|
|
|
this.isSearching = false;
|
2025-11-24 16:11:22 +00:00
|
|
|
|
this.searchResults = this.notes.filter(note =>
|
|
|
|
|
|
note.type === 'note' && this.noteMatchesTags(note)
|
|
|
|
|
|
);
|
|
|
|
|
|
this.currentSearchHighlight = '';
|
|
|
|
|
|
this.clearSearchHighlights();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Case 3: Text search (with or without tag filter)
|
|
|
|
|
|
if (hasTextSearch) {
|
2026-02-20 07:26:52 +00:00
|
|
|
|
this.isSearching = true;
|
2025-11-24 16:11:22 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/search?q=${encodeURIComponent(this.searchQuery)}`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
// Apply tag filtering to search results if tags are selected
|
|
|
|
|
|
let results = data.results;
|
|
|
|
|
|
if (hasTagFilter) {
|
|
|
|
|
|
results = results.filter(result => {
|
|
|
|
|
|
const note = this.notes.find(n => n.path === result.path);
|
|
|
|
|
|
return note ? this.noteMatchesTags(note) : false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.searchResults = results;
|
|
|
|
|
|
|
|
|
|
|
|
// Highlight search term in current note if open
|
|
|
|
|
|
if (this.currentNote && this.noteContent) {
|
|
|
|
|
|
this.currentSearchHighlight = this.searchQuery;
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
this.highlightSearchTerm(this.searchQuery, false);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Search failed:', error);
|
2026-02-20 07:26:52 +00:00
|
|
|
|
this.searchResults = [];
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.isSearching = false;
|
2025-11-24 16:11:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Check if a note matches selected tags (AND logic)
|
|
|
|
|
|
noteMatchesTags(note) {
|
|
|
|
|
|
if (this.selectedTags.length === 0) {
|
|
|
|
|
|
return true; // No filter active
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!note.tags || note.tags.length === 0) {
|
|
|
|
|
|
return false; // Note has no tags but filter is active
|
|
|
|
|
|
}
|
|
|
|
|
|
// Check if note has ALL selected tags (AND logic)
|
|
|
|
|
|
return this.selectedTags.every(tag => note.tags.includes(tag));
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get all tags sorted by name
|
|
|
|
|
|
get sortedTags() {
|
|
|
|
|
|
return Object.entries(this.allTags).sort((a, b) => a[0].localeCompare(b[0]));
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get tags for current note
|
|
|
|
|
|
get currentNoteTags() {
|
|
|
|
|
|
if (!this.currentNote) return [];
|
|
|
|
|
|
const note = this.notes.find(n => n.path === this.currentNote);
|
|
|
|
|
|
return note && note.tags ? note.tags : [];
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// ==================== FAVORITES ====================
|
|
|
|
|
|
|
|
|
|
|
|
// Save favorites to localStorage
|
|
|
|
|
|
saveFavorites() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem('noteFavorites', JSON.stringify(this.favorites));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('Could not save favorites to localStorage');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Check if a note is favorited (O(1) lookup)
|
|
|
|
|
|
isFavorite(notePath) {
|
|
|
|
|
|
return this.favoritesSet.has(notePath);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Toggle favorite status for a note
|
|
|
|
|
|
toggleFavorite(notePath = null) {
|
|
|
|
|
|
const path = notePath || this.currentNote;
|
|
|
|
|
|
if (!path) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (this.favoritesSet.has(path)) {
|
|
|
|
|
|
// Remove from favorites
|
|
|
|
|
|
this.favorites = this.favorites.filter(f => f !== path);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Add to favorites
|
2025-12-15 11:18:14 +00:00
|
|
|
|
this.favorites = [...this.favorites, path];
|
2025-12-12 17:11:29 +00:00
|
|
|
|
}
|
2025-12-15 11:18:14 +00:00
|
|
|
|
// Recreate Set from array for consistency
|
|
|
|
|
|
this.favoritesSet = new Set(this.favorites);
|
2025-12-12 17:11:29 +00:00
|
|
|
|
this.saveFavorites();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get favorite notes with full details (for display)
|
|
|
|
|
|
get favoriteNotes() {
|
|
|
|
|
|
return this.favorites
|
|
|
|
|
|
.map(path => {
|
2025-12-15 11:18:14 +00:00
|
|
|
|
// Find note by exact path or case-insensitive match
|
|
|
|
|
|
let note = this.notes.find(n => n.path === path);
|
|
|
|
|
|
if (!note) {
|
|
|
|
|
|
note = this.notes.find(n => n.path.toLowerCase() === path.toLowerCase());
|
|
|
|
|
|
}
|
2025-12-12 17:11:29 +00:00
|
|
|
|
if (!note) return null;
|
|
|
|
|
|
return {
|
2025-12-15 11:18:14 +00:00
|
|
|
|
path: note.path, // Use actual path from notes (fixes case issues)
|
2025-12-12 17:11:29 +00:00
|
|
|
|
name: note.path.split('/').pop().replace('.md', ''),
|
|
|
|
|
|
folder: note.folder || ''
|
|
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean); // Remove nulls (deleted notes)
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
saveFavoritesExpanded() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem('favoritesExpanded', this.favoritesExpanded.toString());
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('Error saving favorites expanded state:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-12 16:35:04 +00:00
|
|
|
|
// Get current note's last modified time as relative string
|
|
|
|
|
|
get lastEditedText() {
|
|
|
|
|
|
if (!this.currentNote) return '';
|
|
|
|
|
|
const note = this.notes.find(n => n.path === this.currentNote);
|
|
|
|
|
|
if (!note || !note.modified) return '';
|
|
|
|
|
|
|
|
|
|
|
|
const modified = new Date(note.modified);
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const diffMs = now - modified;
|
|
|
|
|
|
const diffSecs = Math.floor(diffMs / 1000);
|
|
|
|
|
|
const diffMins = Math.floor(diffSecs / 60);
|
|
|
|
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
|
|
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
|
|
|
|
|
2025-12-14 17:02:57 +00:00
|
|
|
|
if (diffSecs < 60) return this.t('editor.just_now');
|
|
|
|
|
|
if (diffMins < 60) return this.t('editor.minutes_ago', { count: diffMins });
|
|
|
|
|
|
if (diffHours < 24) return this.t('editor.hours_ago', { count: diffHours });
|
|
|
|
|
|
if (diffDays < 7) return this.t('editor.days_ago', { count: diffDays });
|
2025-12-12 16:35:04 +00:00
|
|
|
|
|
2025-12-14 17:02:57 +00:00
|
|
|
|
// For older dates, show the date in selected locale
|
|
|
|
|
|
return modified.toLocaleDateString(this.currentLocale, { month: 'short', day: 'numeric' });
|
2025-12-12 16:35:04 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
|
// Parse tags from markdown content (matches backend logic)
|
|
|
|
|
|
parseTagsFromContent(content) {
|
|
|
|
|
|
if (!content || !content.trim().startsWith('---')) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const lines = content.split('\n');
|
|
|
|
|
|
if (lines[0].trim() !== '---') return [];
|
|
|
|
|
|
|
|
|
|
|
|
// Find closing ---
|
|
|
|
|
|
let endIdx = -1;
|
|
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
|
|
|
|
if (lines[i].trim() === '---') {
|
|
|
|
|
|
endIdx = i;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (endIdx === -1) return [];
|
|
|
|
|
|
|
|
|
|
|
|
const frontmatterLines = lines.slice(1, endIdx);
|
|
|
|
|
|
const tags = [];
|
|
|
|
|
|
let inTagsList = false;
|
|
|
|
|
|
|
|
|
|
|
|
for (const line of frontmatterLines) {
|
|
|
|
|
|
const stripped = line.trim();
|
|
|
|
|
|
|
|
|
|
|
|
// Check for inline array: tags: [tag1, tag2]
|
|
|
|
|
|
if (stripped.startsWith('tags:')) {
|
|
|
|
|
|
const rest = stripped.substring(5).trim();
|
|
|
|
|
|
if (rest.startsWith('[') && rest.endsWith(']')) {
|
|
|
|
|
|
const tagsStr = rest.substring(1, rest.length - 1);
|
|
|
|
|
|
const rawTags = tagsStr.split(',').map(t => t.trim());
|
|
|
|
|
|
tags.push(...rawTags.filter(t => t).map(t => t.toLowerCase()));
|
|
|
|
|
|
break;
|
|
|
|
|
|
} else if (rest) {
|
|
|
|
|
|
tags.push(rest.toLowerCase());
|
|
|
|
|
|
break;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
inTagsList = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (inTagsList) {
|
|
|
|
|
|
if (stripped.startsWith('-')) {
|
|
|
|
|
|
const tag = stripped.substring(1).trim();
|
|
|
|
|
|
if (tag && !tag.startsWith('#')) {
|
|
|
|
|
|
tags.push(tag.toLowerCase());
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (stripped && !stripped.startsWith('#')) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [...new Set(tags)].sort();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('Error parsing tags:', e);
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Build folder tree structure
|
|
|
|
|
|
buildFolderTree() {
|
|
|
|
|
|
const tree = {};
|
|
|
|
|
|
|
|
|
|
|
|
// Add ALL folders from backend (including empty ones)
|
|
|
|
|
|
this.allFolders.forEach(folderPath => {
|
|
|
|
|
|
const parts = folderPath.split('/');
|
|
|
|
|
|
let current = tree;
|
|
|
|
|
|
|
|
|
|
|
|
parts.forEach((part, index) => {
|
|
|
|
|
|
const fullPath = parts.slice(0, index + 1).join('/');
|
|
|
|
|
|
|
|
|
|
|
|
if (!current[part]) {
|
|
|
|
|
|
current[part] = {
|
|
|
|
|
|
name: part,
|
|
|
|
|
|
path: fullPath,
|
|
|
|
|
|
children: {},
|
|
|
|
|
|
notes: []
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
current = current[part].children;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
|
// Add ALL notes to their folders (no filtering - tree only shown when no filters active)
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.notes.forEach(note => {
|
|
|
|
|
|
if (!note.folder) {
|
|
|
|
|
|
// Root level note
|
|
|
|
|
|
if (!tree['__root__']) {
|
|
|
|
|
|
tree['__root__'] = {
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
path: '',
|
|
|
|
|
|
children: {},
|
|
|
|
|
|
notes: []
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
tree['__root__'].notes.push(note);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Navigate to the folder and add note
|
|
|
|
|
|
const parts = note.folder.split('/');
|
|
|
|
|
|
let current = tree;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
|
|
|
|
if (!current[parts[i]]) {
|
|
|
|
|
|
current[parts[i]] = {
|
|
|
|
|
|
name: parts[i],
|
|
|
|
|
|
path: parts.slice(0, i + 1).join('/'),
|
|
|
|
|
|
children: {},
|
|
|
|
|
|
notes: []
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (i === parts.length - 1) {
|
|
|
|
|
|
current[parts[i]].notes.push(note);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
current = current[parts[i]].children;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Sort all notes arrays alphabetically (create new sorted arrays for reactivity)
|
|
|
|
|
|
const sortNotes = (obj) => {
|
|
|
|
|
|
if (obj.notes && obj.notes.length > 0) {
|
|
|
|
|
|
// Create a new sorted array instead of mutating for Alpine reactivity
|
2026-04-06 14:00:00 +00:00
|
|
|
|
obj.notes = [...obj.notes].sort(this.getSortComparator());
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
if (obj.children && Object.keys(obj.children).length > 0) {
|
|
|
|
|
|
Object.values(obj.children).forEach(child => sortNotes(child));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Sort notes in root (create new array for reactivity)
|
|
|
|
|
|
if (tree['__root__'] && tree['__root__'].notes) {
|
2026-04-06 14:00:00 +00:00
|
|
|
|
tree['__root__'].notes = [...tree['__root__'].notes].sort(this.getSortComparator());
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sort notes in all folders
|
|
|
|
|
|
Object.values(tree).forEach(folder => {
|
|
|
|
|
|
if (folder.path !== undefined) { // Skip __root__ as it was already sorted
|
|
|
|
|
|
sortNotes(folder);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Calculate and cache note counts recursively (for performance)
|
|
|
|
|
|
const calculateNoteCounts = (folderNode) => {
|
|
|
|
|
|
const directNotes = folderNode.notes ? folderNode.notes.length : 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (!folderNode.children || Object.keys(folderNode.children).length === 0) {
|
|
|
|
|
|
folderNode.noteCount = directNotes;
|
|
|
|
|
|
return directNotes;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const childNotesCount = Object.values(folderNode.children).reduce(
|
|
|
|
|
|
(total, child) => total + calculateNoteCounts(child),
|
|
|
|
|
|
0
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
folderNode.noteCount = directNotes + childNotesCount;
|
|
|
|
|
|
return folderNode.noteCount;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate note counts for all folders
|
|
|
|
|
|
Object.values(tree).forEach(folder => {
|
|
|
|
|
|
if (folder.path !== undefined || folder === tree['__root__']) {
|
|
|
|
|
|
calculateNoteCounts(folder);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Invalidate homepage cache when tree is rebuilt
|
|
|
|
|
|
this._homepageCache = {
|
|
|
|
|
|
folderPath: null,
|
|
|
|
|
|
notes: null,
|
|
|
|
|
|
folders: null,
|
|
|
|
|
|
breadcrumb: null
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Assign new tree (Alpine will detect the change)
|
|
|
|
|
|
this.folderTree = tree;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-17 15:46:45 +00:00
|
|
|
|
// =====================================================================
|
|
|
|
|
|
// DATA-ATTRIBUTE BASED HANDLERS
|
|
|
|
|
|
// These read path/name/type from data-* attributes, avoiding JS escaping issues
|
|
|
|
|
|
// =====================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// Escape strings for HTML attributes (simpler than JS escaping)
|
|
|
|
|
|
escapeHtmlAttr(str) {
|
|
|
|
|
|
if (!str) return '';
|
|
|
|
|
|
return str
|
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
|
.replace(/"/g, '"')
|
2026-01-18 09:43:20 +00:00
|
|
|
|
.replace(/'/g, ''')
|
2026-01-17 15:46:45 +00:00
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>');
|
2026-01-17 15:34:33 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-17 15:46:45 +00:00
|
|
|
|
// Folder handlers - read from dataset
|
|
|
|
|
|
handleFolderClick(el) {
|
|
|
|
|
|
this.toggleFolder(el.dataset.path);
|
|
|
|
|
|
},
|
|
|
|
|
|
handleFolderDragOver(el, event) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
this.dragOverFolder = el.dataset.path;
|
|
|
|
|
|
el.classList.add('drag-over');
|
|
|
|
|
|
},
|
|
|
|
|
|
handleFolderDragLeave(el) {
|
|
|
|
|
|
this.dragOverFolder = null;
|
|
|
|
|
|
el.classList.remove('drag-over');
|
|
|
|
|
|
},
|
|
|
|
|
|
handleFolderDrop(el, event) {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
el.classList.remove('drag-over');
|
|
|
|
|
|
this.onFolderDrop(el.dataset.path);
|
|
|
|
|
|
},
|
|
|
|
|
|
handleNewItemClick(el, event) {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
this.dropdownTargetFolder = el.dataset.path;
|
|
|
|
|
|
this.toggleNewDropdown(event);
|
|
|
|
|
|
},
|
|
|
|
|
|
handleRenameFolderClick(el, event) {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
this.renameFolder(el.dataset.path, el.dataset.name);
|
|
|
|
|
|
},
|
|
|
|
|
|
handleDeleteFolderClick(el, event) {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
this.deleteFolder(el.dataset.path, el.dataset.name);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-19 08:58:04 +00:00
|
|
|
|
// Item (note/media) handlers - read from dataset
|
2026-01-17 15:46:45 +00:00
|
|
|
|
handleItemClick(el) {
|
|
|
|
|
|
this.openItem(el.dataset.path, el.dataset.type);
|
|
|
|
|
|
},
|
|
|
|
|
|
handleItemHover(el, isEnter) {
|
|
|
|
|
|
const path = el.dataset.path;
|
2026-01-18 15:00:01 +00:00
|
|
|
|
if (path !== this.currentNote && path !== this.currentMedia) {
|
2026-01-17 15:46:45 +00:00
|
|
|
|
el.style.backgroundColor = isEnter ? 'var(--bg-hover)' : 'transparent';
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
handleDeleteItemClick(el, event) {
|
|
|
|
|
|
event.stopPropagation();
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (el.dataset.type !== 'note') {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
this.deleteMedia(el.dataset.path);
|
2026-01-17 15:46:45 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
this.deleteNote(el.dataset.path, el.dataset.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// =====================================================================
|
|
|
|
|
|
// FOLDER TREE RENDERING
|
|
|
|
|
|
// =====================================================================
|
|
|
|
|
|
|
2025-11-08 11:23:07 +00:00
|
|
|
|
// Render folder recursively (helper for deep nesting)
|
2026-01-17 15:46:45 +00:00
|
|
|
|
// Uses data-* attributes to store path/name, avoiding JS string escaping issues
|
2026-04-06 14:02:23 +00:00
|
|
|
|
renderFolderRecursive(folder, level = 0, isTopLevel = false) {
|
2025-11-08 11:23:07 +00:00
|
|
|
|
if (!folder) return '';
|
|
|
|
|
|
|
|
|
|
|
|
let html = '';
|
2025-11-08 11:28:31 +00:00
|
|
|
|
const isExpanded = this.expandedFolders.has(folder.path);
|
2026-01-17 15:46:45 +00:00
|
|
|
|
const esc = (s) => this.escapeHtmlAttr(s); // Shorthand for HTML escaping
|
2025-11-08 11:23:07 +00:00
|
|
|
|
|
2025-11-08 11:28:31 +00:00
|
|
|
|
// Render this folder's header
|
2026-01-17 15:46:45 +00:00
|
|
|
|
// Note: Using native event handlers with data-* attributes instead of Alpine directives
|
2025-12-09 11:08:12 +00:00
|
|
|
|
// because x-html doesn't process Alpine directives in dynamically generated content
|
2025-11-08 11:28:31 +00:00
|
|
|
|
html += `
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div
|
2026-01-17 15:46:45 +00:00
|
|
|
|
data-path="${esc(folder.path)}"
|
|
|
|
|
|
data-name="${esc(folder.name)}"
|
2025-11-08 11:28:31 +00:00
|
|
|
|
draggable="true"
|
2026-01-19 08:58:04 +00:00
|
|
|
|
ondragstart="window.$root.onItemDragStart(this.dataset.path, 'folder', event)"
|
2026-01-19 08:42:32 +00:00
|
|
|
|
ondragend="window.$root.onItemDragEnd()"
|
2026-01-17 15:46:45 +00:00
|
|
|
|
ondragover="window.$root.handleFolderDragOver(this, event)"
|
|
|
|
|
|
ondragenter="window.$root.handleFolderDragOver(this, event)"
|
|
|
|
|
|
ondragleave="window.$root.handleFolderDragLeave(this)"
|
|
|
|
|
|
ondrop="window.$root.handleFolderDrop(this, event)"
|
|
|
|
|
|
onclick="window.$root.handleFolderClick(this)"
|
2026-02-23 09:59:58 +00:00
|
|
|
|
class="folder-item hover-accent px-2 py-1 text-sm relative"
|
2025-11-08 11:28:31 +00:00
|
|
|
|
style="color: var(--text-primary); cursor: pointer;"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="flex-shrink-0 w-4 h-4 flex items-center justify-center"
|
2025-12-08 15:40:40 +00:00
|
|
|
|
style="color: var(--text-tertiary); cursor: pointer; transition: transform 0.2s; pointer-events: none; margin-left: -5px; ${isExpanded ? 'transform: rotate(90deg);' : ''}"
|
2025-11-08 11:28:31 +00:00
|
|
|
|
>
|
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
|
|
|
|
|
<path d="M6 4l4 4-4 4V4z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
2026-01-18 17:04:32 +00:00
|
|
|
|
<span class="flex items-center gap-1 flex-1" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; pointer-events: none;" title="${esc(folder.name)}">
|
2026-01-17 15:46:45 +00:00
|
|
|
|
<span>${esc(folder.name)}</span>
|
2025-12-15 14:45:10 +00:00
|
|
|
|
${folder.notes.length === 0 && (!folder.children || Object.keys(folder.children).length === 0) ? `<span class="text-xs" style="color: var(--text-tertiary); font-weight: 400;">(${this.t('folders.empty')})</span>` : ''}
|
2025-11-08 11:28:31 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2025-12-09 11:08:12 +00:00
|
|
|
|
<div class="hover-buttons flex gap-1 transition-opacity absolute right-2 top-1/2 transform -translate-y-1/2" style="opacity: 0; pointer-events: none; background: linear-gradient(to right, transparent, var(--bg-hover) 20%, var(--bg-hover)); padding-left: 20px;" onclick="event.stopPropagation()">
|
2025-11-08 11:28:31 +00:00
|
|
|
|
<button
|
2026-01-17 15:46:45 +00:00
|
|
|
|
data-path="${esc(folder.path)}"
|
|
|
|
|
|
onclick="window.$root.handleNewItemClick(this, event)"
|
2025-11-08 11:28:31 +00:00
|
|
|
|
class="px-1.5 py-0.5 text-xs rounded hover:brightness-110"
|
|
|
|
|
|
style="background-color: var(--bg-tertiary); color: var(--text-secondary);"
|
2026-04-20 10:17:15 +00:00
|
|
|
|
title="${esc(this.t('sidebar.new_note'))}"
|
2025-11-26 11:43:37 +00:00
|
|
|
|
>+</button>
|
2025-11-08 11:28:31 +00:00
|
|
|
|
<button
|
2026-01-17 15:46:45 +00:00
|
|
|
|
data-path="${esc(folder.path)}"
|
|
|
|
|
|
data-name="${esc(folder.name)}"
|
|
|
|
|
|
onclick="window.$root.handleRenameFolderClick(this, event)"
|
2025-11-08 11:28:31 +00:00
|
|
|
|
class="px-1.5 py-0.5 text-xs rounded hover:brightness-110"
|
|
|
|
|
|
style="background-color: var(--bg-tertiary); color: var(--text-secondary);"
|
2026-04-20 10:17:15 +00:00
|
|
|
|
title="${esc(this.t('sidebar.rename_folder'))}"
|
2025-11-08 11:28:31 +00:00
|
|
|
|
>✏️</button>
|
|
|
|
|
|
<button
|
2026-01-17 15:46:45 +00:00
|
|
|
|
data-path="${esc(folder.path)}"
|
|
|
|
|
|
data-name="${esc(folder.name)}"
|
|
|
|
|
|
onclick="window.$root.handleDeleteFolderClick(this, event)"
|
2025-11-08 11:28:31 +00:00
|
|
|
|
class="px-1 py-0.5 text-xs rounded hover:brightness-110"
|
|
|
|
|
|
style="color: var(--error);"
|
2026-04-20 10:17:15 +00:00
|
|
|
|
title="${esc(this.t('sidebar.delete_folder'))}"
|
2025-11-08 11:28:31 +00:00
|
|
|
|
>
|
|
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
// If expanded, render folder contents (child folders + notes)
|
|
|
|
|
|
if (isExpanded) {
|
2025-12-05 13:31:52 +00:00
|
|
|
|
html += `<div class="folder-contents" style="padding-left: 10px;">`;
|
2025-11-08 11:23:07 +00:00
|
|
|
|
|
2025-11-08 11:28:31 +00:00
|
|
|
|
// First, render child folders (if any)
|
|
|
|
|
|
if (folder.children && Object.keys(folder.children).length > 0) {
|
2026-02-23 09:59:58 +00:00
|
|
|
|
const children = Object.entries(folder.children)
|
|
|
|
|
|
.filter(([k, v]) => !this.hideUnderscoreFolders || !v.name.startsWith('_'))
|
2026-04-06 14:00:00 +00:00
|
|
|
|
.sort((a, b) => this.getFolderSortComparator()(a[1], b[1]));
|
|
|
|
|
|
|
2025-11-08 11:28:31 +00:00
|
|
|
|
children.forEach(([childKey, childFolder]) => {
|
|
|
|
|
|
html += this.renderFolderRecursive(childFolder, 0, false);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-11-08 11:23:07 +00:00
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
|
// Then, render notes and images in this folder (after subfolders)
|
2025-11-08 11:28:31 +00:00
|
|
|
|
if (folder.notes && folder.notes.length > 0) {
|
|
|
|
|
|
folder.notes.forEach(note => {
|
2026-01-19 09:14:37 +00:00
|
|
|
|
html += this.renderNoteItem(note);
|
2025-11-08 11:28:31 +00:00
|
|
|
|
});
|
2025-11-08 11:23:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 11:28:31 +00:00
|
|
|
|
html += `</div>`; // Close folder-contents
|
2025-11-08 11:23:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 11:28:31 +00:00
|
|
|
|
html += `</div>`; // Close folder wrapper
|
2025-11-08 11:23:07 +00:00
|
|
|
|
return html;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-19 09:14:37 +00:00
|
|
|
|
// Render a single note/media item (used by both folders and root level)
|
|
|
|
|
|
renderNoteItem(note) {
|
|
|
|
|
|
const esc = (s) => this.escapeHtmlAttr(s);
|
|
|
|
|
|
const isMediaFile = note.type !== 'note';
|
|
|
|
|
|
const isCurrentNote = this.currentNote === note.path;
|
|
|
|
|
|
const isCurrentMedia = this.currentMedia === note.path;
|
|
|
|
|
|
const isCurrent = isMediaFile ? isCurrentMedia : isCurrentNote;
|
|
|
|
|
|
|
|
|
|
|
|
// Share icon for shared notes
|
|
|
|
|
|
const isShared = !isMediaFile && this.isNoteShared(note.path);
|
2026-04-20 10:17:15 +00:00
|
|
|
|
const shareIcon = isShared ? '<svg aria-hidden="true" style="display: inline-block; width: 12px; height: 12px; vertical-align: middle; margin-right: 2px; opacity: 0.7;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"></path></svg>' : '';
|
2026-01-19 09:14:37 +00:00
|
|
|
|
const icon = this.getMediaIcon(note.type);
|
2026-04-21 13:22:09 +00:00
|
|
|
|
const deleteTitle = !isMediaFile
|
|
|
|
|
|
? this.t('toolbar.delete_note')
|
|
|
|
|
|
: note.type === 'drawing'
|
|
|
|
|
|
? this.t('toolbar.delete_drawing')
|
|
|
|
|
|
: this.t('toolbar.delete_image');
|
2026-01-19 09:14:37 +00:00
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div
|
|
|
|
|
|
data-path="${esc(note.path)}"
|
|
|
|
|
|
data-name="${esc(note.name)}"
|
|
|
|
|
|
data-type="${note.type}"
|
|
|
|
|
|
draggable="true"
|
|
|
|
|
|
ondragstart="window.$root.onItemDragStart(this.dataset.path, this.dataset.type || 'note', event)"
|
|
|
|
|
|
ondragend="window.$root.onItemDragEnd()"
|
|
|
|
|
|
onclick="window.$root.handleItemClick(this)"
|
|
|
|
|
|
class="note-item px-2 py-1 text-sm relative"
|
|
|
|
|
|
style="${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isMediaFile ? 'opacity: 0.85;' : ''} cursor: pointer;"
|
|
|
|
|
|
onmouseover="window.$root.handleItemHover(this, true)"
|
|
|
|
|
|
onmouseout="window.$root.handleItemHover(this, false)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="truncate" style="display: block; padding-right: 30px;" title="${esc(note.name)}">${shareIcon}${icon}${icon ? ' ' : ''}${esc(note.name)}</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);"
|
2026-04-21 13:22:09 +00:00
|
|
|
|
title="${esc(deleteTitle)}"
|
2026-01-19 09:14:37 +00:00
|
|
|
|
>
|
|
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Render root-level items (notes and media not in any folder)
|
2026-04-06 14:02:23 +00:00
|
|
|
|
renderRootItems() {
|
2026-01-19 09:14:37 +00:00
|
|
|
|
const root = this.folderTree['__root__'];
|
|
|
|
|
|
if (!root || !root.notes || root.notes.length === 0) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
return root.notes.map(note => this.renderNoteItem(note)).join('');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Toggle folder expansion
|
|
|
|
|
|
toggleFolder(folderPath) {
|
|
|
|
|
|
if (this.expandedFolders.has(folderPath)) {
|
|
|
|
|
|
this.expandedFolders.delete(folderPath);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.expandedFolders.add(folderPath);
|
|
|
|
|
|
}
|
2025-11-08 11:23:07 +00:00
|
|
|
|
// Force Alpine reactivity by creating new Set reference
|
|
|
|
|
|
this.expandedFolders = new Set(this.expandedFolders);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Check if folder is expanded
|
|
|
|
|
|
isFolderExpanded(folderPath) {
|
|
|
|
|
|
return this.expandedFolders.has(folderPath);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Expand all folders
|
|
|
|
|
|
expandAllFolders() {
|
|
|
|
|
|
this.allFolders.forEach(folder => {
|
|
|
|
|
|
this.expandedFolders.add(folder);
|
|
|
|
|
|
});
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Force Alpine reactivity
|
|
|
|
|
|
this.expandedFolders = new Set(this.expandedFolders);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Collapse all folders
|
|
|
|
|
|
collapseAllFolders() {
|
|
|
|
|
|
this.expandedFolders.clear();
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Force Alpine reactivity
|
|
|
|
|
|
this.expandedFolders = new Set(this.expandedFolders);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Expand folder tree to show a specific note
|
|
|
|
|
|
expandFolderForNote(notePath) {
|
|
|
|
|
|
const parts = notePath.split('/');
|
|
|
|
|
|
|
|
|
|
|
|
// If note is in root, no folders to expand
|
|
|
|
|
|
if (parts.length <= 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Remove the note name (last part)
|
|
|
|
|
|
parts.pop();
|
|
|
|
|
|
|
|
|
|
|
|
// Build and expand all parent folders
|
|
|
|
|
|
let currentPath = '';
|
|
|
|
|
|
parts.forEach((part, index) => {
|
|
|
|
|
|
currentPath = index === 0 ? part : `${currentPath}/${part}`;
|
|
|
|
|
|
this.expandedFolders.add(currentPath);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Force Alpine reactivity
|
|
|
|
|
|
this.expandedFolders = new Set(this.expandedFolders);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll note into view in the sidebar navigation
|
|
|
|
|
|
scrollNoteIntoView(notePath) {
|
|
|
|
|
|
// Find the note element in the sidebar
|
|
|
|
|
|
// Use a slight delay to ensure DOM is fully rendered with Alpine bindings applied
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const sidebar = document.querySelector('.flex-1.overflow-y-auto.custom-scrollbar');
|
|
|
|
|
|
if (!sidebar) return;
|
|
|
|
|
|
|
2025-12-05 13:31:52 +00:00
|
|
|
|
const noteElements = sidebar.querySelectorAll('.note-item');
|
2025-11-05 16:48:41 +00:00
|
|
|
|
let targetElement = null;
|
|
|
|
|
|
const noteName = notePath.split('/').pop().replace('.md', '');
|
|
|
|
|
|
|
|
|
|
|
|
// Find the element that corresponds to this note
|
|
|
|
|
|
noteElements.forEach(el => {
|
|
|
|
|
|
// Check if this is a note element (not folder) by checking if it has the note name
|
|
|
|
|
|
if (el.textContent.trim().startsWith(noteName) || el.textContent.includes(noteName)) {
|
|
|
|
|
|
// Check computed style to see if it's highlighted
|
|
|
|
|
|
const computedStyle = window.getComputedStyle(el);
|
|
|
|
|
|
const bgColor = computedStyle.backgroundColor;
|
|
|
|
|
|
|
|
|
|
|
|
// Check if background has the accent color (not transparent or default)
|
|
|
|
|
|
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' && !bgColor.includes('255, 255, 255')) {
|
|
|
|
|
|
targetElement = el;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// If found, scroll it into view
|
|
|
|
|
|
if (targetElement) {
|
|
|
|
|
|
targetElement.scrollIntoView({
|
|
|
|
|
|
behavior: 'smooth',
|
|
|
|
|
|
block: 'center',
|
|
|
|
|
|
inline: 'nearest'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 200); // Increased delay to ensure Alpine has finished rendering
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
// Unified drag and drop handlers for notes, folders, and media
|
|
|
|
|
|
onItemDragStart(itemPath, itemType, event) {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
// Set unified drag state
|
2026-01-19 08:42:32 +00:00
|
|
|
|
this.draggedItem = { path: itemPath, type: itemType };
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
// Make drag image semi-transparent
|
|
|
|
|
|
if (event.target) {
|
|
|
|
|
|
event.target.style.opacity = '0.5';
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
|
|
|
|
|
event.dataTransfer.effectAllowed = 'all';
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
onItemDragEnd() {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
this.draggedItem = null;
|
|
|
|
|
|
this.dropTarget = null;
|
2025-11-18 09:03:47 +00:00
|
|
|
|
this.dragOverFolder = null;
|
2026-01-19 08:42:32 +00:00
|
|
|
|
// Reset opacity of all draggable items
|
|
|
|
|
|
document.querySelectorAll('.note-item, .folder-header').forEach(el => el.style.opacity = '1');
|
|
|
|
|
|
// Reset drag-over class
|
2025-12-09 11:08:12 +00:00
|
|
|
|
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Handle dragover on editor to show cursor position
|
|
|
|
|
|
onEditorDragOver(event) {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
if (!this.draggedItem) return;
|
|
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
this.dropTarget = 'editor';
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2026-01-15 17:19:02 +00:00
|
|
|
|
// Focus the textarea
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const textarea = event.target;
|
2026-01-15 17:19:02 +00:00
|
|
|
|
if (textarea.tagName !== 'TEXTAREA') return;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
textarea.focus();
|
|
|
|
|
|
|
2026-01-15 17:19:02 +00:00
|
|
|
|
// Calculate cursor position from mouse coordinates
|
|
|
|
|
|
const pos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
|
|
|
|
|
|
if (pos >= 0) {
|
|
|
|
|
|
textarea.setSelectionRange(pos, pos);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-15 17:19:02 +00:00
|
|
|
|
// Calculate textarea cursor position from mouse coordinates
|
|
|
|
|
|
getTextareaCursorFromPoint(textarea, x, y) {
|
|
|
|
|
|
const rect = textarea.getBoundingClientRect();
|
|
|
|
|
|
const style = window.getComputedStyle(textarea);
|
|
|
|
|
|
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
|
|
|
|
|
|
const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
|
|
|
|
const paddingLeft = parseFloat(style.paddingLeft) || 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate which line we're on
|
|
|
|
|
|
const relativeY = y - rect.top - paddingTop + textarea.scrollTop;
|
|
|
|
|
|
const lineIndex = Math.max(0, Math.floor(relativeY / lineHeight));
|
|
|
|
|
|
|
|
|
|
|
|
// Split content into lines
|
|
|
|
|
|
const lines = textarea.value.split('\n');
|
|
|
|
|
|
|
|
|
|
|
|
// Find the character position at the start of this line
|
|
|
|
|
|
let charPos = 0;
|
|
|
|
|
|
for (let i = 0; i < Math.min(lineIndex, lines.length); i++) {
|
|
|
|
|
|
charPos += lines[i].length + 1; // +1 for newline
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If we're beyond the last line, position at end
|
|
|
|
|
|
if (lineIndex >= lines.length) {
|
|
|
|
|
|
return textarea.value.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Approximate character position within the line based on X coordinate
|
|
|
|
|
|
const relativeX = x - rect.left - paddingLeft;
|
|
|
|
|
|
const charWidth = parseFloat(style.fontSize) * 0.6; // Approximate for monospace
|
|
|
|
|
|
const charInLine = Math.max(0, Math.floor(relativeX / charWidth));
|
|
|
|
|
|
const lineLength = lines[lineIndex]?.length || 0;
|
|
|
|
|
|
|
|
|
|
|
|
return charPos + Math.min(charInLine, lineLength);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Handle dragenter on editor
|
|
|
|
|
|
onEditorDragEnter(event) {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
if (!this.draggedItem) return;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
event.preventDefault();
|
2025-11-20 13:10:17 +00:00
|
|
|
|
this.dropTarget = 'editor';
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Handle dragleave on editor
|
|
|
|
|
|
onEditorDragLeave(event) {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
// Only clear dropTarget if we're actually leaving the editor
|
|
|
|
|
|
// (not just moving between child elements)
|
|
|
|
|
|
if (event.target.tagName === 'TEXTAREA') {
|
|
|
|
|
|
this.dropTarget = null;
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Handle drop into editor to create internal link or upload media
|
2025-11-20 13:10:17 +00:00
|
|
|
|
async onEditorDrop(event) {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
event.preventDefault();
|
2025-11-20 13:10:17 +00:00
|
|
|
|
this.dropTarget = null;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Check if files are being dropped (media from file system)
|
2025-11-20 13:10:17 +00:00
|
|
|
|
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
await this.handleMediaDrop(event);
|
2025-11-20 13:10:17 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Otherwise, handle note/media link drop from sidebar
|
2025-11-20 13:10:17 +00:00
|
|
|
|
if (!this.draggedItem) return;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
|
const notePath = this.draggedItem.path;
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const isMediaFile = this.draggedItem.type !== 'note';
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
|
let link;
|
2026-01-18 15:00:01 +00:00
|
|
|
|
if (isMediaFile) {
|
|
|
|
|
|
// For media files (images, audio, video, PDF), use wiki-style embed link
|
2026-01-05 11:51:24 +00:00
|
|
|
|
const filename = notePath.split('/').pop();
|
|
|
|
|
|
link = `![[${filename}]]`;
|
2025-11-20 13:10:17 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
// For notes, insert note link
|
|
|
|
|
|
const noteName = notePath.split('/').pop().replace('.md', '');
|
|
|
|
|
|
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
|
|
|
|
link = `[${noteName}](${encodedPath})`;
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2026-01-15 17:19:02 +00:00
|
|
|
|
// Insert at drop position
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const textarea = event.target;
|
2026-01-15 17:19:02 +00:00
|
|
|
|
// Recalculate position from drop coordinates for accuracy
|
|
|
|
|
|
let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
|
|
|
|
|
|
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const textBefore = this.noteContent.substring(0, cursorPos);
|
|
|
|
|
|
const textAfter = this.noteContent.substring(cursorPos);
|
|
|
|
|
|
|
|
|
|
|
|
this.noteContent = textBefore + link + textAfter;
|
|
|
|
|
|
|
|
|
|
|
|
// Move cursor after the link
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
textarea.selectionStart = textarea.selectionEnd = cursorPos + link.length;
|
|
|
|
|
|
textarea.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Trigger autosave
|
|
|
|
|
|
this.autoSave();
|
|
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
|
this.draggedItem = null;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Backend `get_attachment_dir` uses the parent folder of `note_path`.
|
|
|
|
|
|
* With no note open, infer a synthetic path from `currentMedia` so uploads go to the same
|
|
|
|
|
|
* `_attachments` folder as the file being viewed (or vault root when appropriate).
|
|
|
|
|
|
*/
|
|
|
|
|
|
resolveUploadNotePath() {
|
|
|
|
|
|
if (this.currentNote) return this.currentNote;
|
|
|
|
|
|
if (!this.currentMedia) return '';
|
|
|
|
|
|
const parts = this.currentMedia.split('/').filter(Boolean);
|
|
|
|
|
|
const ai = parts.indexOf('_attachments');
|
|
|
|
|
|
if (ai === -1 || ai === 0) return '';
|
|
|
|
|
|
return `${parts.slice(0, ai).join('/')}/_.md`;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Handle media files dropped into editor
|
|
|
|
|
|
async handleMediaDrop(event) {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
const files = Array.from(event.dataTransfer.files);
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Filter for allowed media types
|
|
|
|
|
|
const allowedTypes = [
|
|
|
|
|
|
// Images
|
|
|
|
|
|
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
|
|
|
|
|
|
// Audio
|
|
|
|
|
|
'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a',
|
|
|
|
|
|
// Video
|
|
|
|
|
|
'video/mp4', 'video/webm', 'video/quicktime',
|
|
|
|
|
|
// Documents
|
|
|
|
|
|
'application/pdf'
|
|
|
|
|
|
];
|
|
|
|
|
|
const mediaFiles = files.filter(file => allowedTypes.includes(file.type.toLowerCase()));
|
|
|
|
|
|
|
|
|
|
|
|
if (mediaFiles.length === 0) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('media.no_valid_files'), { type: 'warning' });
|
2025-11-20 13:10:17 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const textarea = event.target;
|
2026-04-21 13:22:09 +00:00
|
|
|
|
const notePath = this.resolveUploadNotePath();
|
|
|
|
|
|
// Calculate cursor position from drop coordinates (only meaningful when a note is open)
|
|
|
|
|
|
let cursorPos = 0;
|
|
|
|
|
|
if (this.currentNote && textarea && textarea.tagName === 'TEXTAREA') {
|
|
|
|
|
|
cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
|
|
|
|
|
|
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
|
|
|
|
|
|
}
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
let uploaded = false;
|
2026-01-18 15:00:01 +00:00
|
|
|
|
for (const file of mediaFiles) {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
try {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
const mediaPath = await this.uploadMedia(file, notePath);
|
2026-01-18 15:00:01 +00:00
|
|
|
|
if (mediaPath) {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
uploaded = true;
|
|
|
|
|
|
if (this.currentNote) {
|
|
|
|
|
|
await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
|
|
|
|
|
|
}
|
2025-11-20 13:10:17 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
ErrorHandler.handle(`upload file ${file.name}`, error);
|
2025-11-20 13:10:17 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (uploaded && !this.currentNote) {
|
|
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
}
|
2025-11-20 13:10:17 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Upload a media file (image, audio, video, PDF)
|
2026-04-21 14:00:58 +00:00
|
|
|
|
// Use options.nextToNotes + options.contentFolder to save a new drawing PNG next to .md files (not in _attachments).
|
|
|
|
|
|
async uploadMedia(file, notePath, options = {}) {
|
|
|
|
|
|
const { nextToNotes, contentFolder } = options;
|
2025-11-20 13:10:17 +00:00
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
formData.append('file', file);
|
2026-04-21 14:00:58 +00:00
|
|
|
|
if (nextToNotes) {
|
|
|
|
|
|
formData.append('next_to_notes', '1');
|
|
|
|
|
|
formData.append('content_folder', contentFolder != null ? contentFolder : '');
|
|
|
|
|
|
formData.append('note_path', '');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
formData.append('note_path', notePath || '');
|
|
|
|
|
|
}
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const response = await fetch('/api/upload-media', {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: formData
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
throw new Error(error.detail || 'Upload failed');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
return data.path;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Insert media markdown at cursor position using wiki-style syntax
|
|
|
|
|
|
// This ensures media links don't break when notes are moved
|
|
|
|
|
|
async insertMediaMarkdown(mediaPath, altText, cursorPos) {
|
2026-01-05 11:51:24 +00:00
|
|
|
|
// Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png")
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const filename = mediaPath.split('/').pop();
|
2026-01-05 11:51:24 +00:00
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Use wiki-style embed link: ![[filename.png]] or ![[filename.png|alt text]]
|
2026-01-05 11:51:24 +00:00
|
|
|
|
// The alt text is optional - only add if different from filename
|
|
|
|
|
|
const filenameWithoutExt = filename.replace(/\.[^/.]+$/, '');
|
|
|
|
|
|
const altWithoutExt = altText.replace(/\.[^/.]+$/, '');
|
|
|
|
|
|
|
|
|
|
|
|
// If alt text is meaningful (not just "pasted-image"), include it
|
|
|
|
|
|
const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image'))
|
|
|
|
|
|
? `![[${filename}|${altWithoutExt}]]`
|
|
|
|
|
|
: `![[${filename}]]`;
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
2026-01-15 17:38:30 +00:00
|
|
|
|
// Reload notes FIRST to update image lookup maps before preview renders
|
|
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
|
const textBefore = this.noteContent.substring(0, cursorPos);
|
|
|
|
|
|
const textAfter = this.noteContent.substring(cursorPos);
|
|
|
|
|
|
|
|
|
|
|
|
this.noteContent = textBefore + markdown + '\n' + textAfter;
|
|
|
|
|
|
|
|
|
|
|
|
// Trigger autosave
|
|
|
|
|
|
this.autoSave();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Handle paste event for clipboard media (images)
|
2025-11-20 13:10:17 +00:00
|
|
|
|
async handlePaste(event) {
|
|
|
|
|
|
if (!this.currentNote) return;
|
|
|
|
|
|
|
|
|
|
|
|
const items = event.clipboardData?.items;
|
|
|
|
|
|
if (!items) return;
|
|
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
|
if (item.type.startsWith('image/')) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const blob = item.getAsFile();
|
|
|
|
|
|
if (blob) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const textarea = event.target;
|
|
|
|
|
|
const cursorPos = textarea.selectionStart || 0;
|
|
|
|
|
|
|
2025-11-23 15:59:57 +00:00
|
|
|
|
// Create a simple filename - backend will add timestamp to prevent collisions
|
2025-11-20 13:10:17 +00:00
|
|
|
|
const ext = item.type.split('/')[1] || 'png';
|
2025-11-23 15:59:57 +00:00
|
|
|
|
const filename = `pasted-image.${ext}`;
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
|
|
|
|
|
// Create a File from the blob
|
|
|
|
|
|
const file = new File([blob], filename, { type: item.type });
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const mediaPath = await this.uploadMedia(file, this.currentNote);
|
|
|
|
|
|
if (mediaPath) {
|
|
|
|
|
|
await this.insertMediaMarkdown(mediaPath, filename, cursorPos);
|
2025-11-20 13:10:17 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
ErrorHandler.handle('paste media', error);
|
2025-11-20 13:10:17 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-18 15:00:01 +00:00
|
|
|
|
break; // Only handle first media item
|
2025-11-20 13:10:17 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
// Media type detection based on file extension (and drawing-*.png convention)
|
2026-01-18 15:00:01 +00:00
|
|
|
|
getMediaType(filename) {
|
|
|
|
|
|
if (!filename) return null;
|
2026-04-21 13:22:09 +00:00
|
|
|
|
const base = filename.split('/').pop().toLowerCase();
|
|
|
|
|
|
if (base.startsWith('drawing-') && base.endsWith('.png')) {
|
|
|
|
|
|
return 'drawing';
|
|
|
|
|
|
}
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
|
|
|
|
const mediaTypes = {
|
|
|
|
|
|
image: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
|
|
|
|
|
|
audio: ['mp3', 'wav', 'ogg', 'm4a'],
|
|
|
|
|
|
video: ['mp4', 'webm', 'mov', 'avi'],
|
|
|
|
|
|
document: ['pdf'],
|
|
|
|
|
|
};
|
|
|
|
|
|
for (const [type, extensions] of Object.entries(mediaTypes)) {
|
|
|
|
|
|
if (extensions.includes(ext)) return type;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get icon for media type
|
|
|
|
|
|
getMediaIcon(type) {
|
|
|
|
|
|
const icons = {
|
|
|
|
|
|
image: '🖼️',
|
2026-04-21 13:22:09 +00:00
|
|
|
|
drawing: '✏️',
|
2026-01-18 15:00:01 +00:00
|
|
|
|
audio: '🎵',
|
|
|
|
|
|
video: '🎬',
|
|
|
|
|
|
document: '📄',
|
|
|
|
|
|
};
|
|
|
|
|
|
return icons[type] || '';
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Open a note or media file (unified handler for sidebar/homepage clicks)
|
2025-12-04 17:05:41 +00:00
|
|
|
|
openItem(path, type = 'note', searchHighlight = '') {
|
|
|
|
|
|
this.showGraph = false;
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Check if it's a media file by type or extension
|
|
|
|
|
|
const mediaType = type !== 'note' ? type : this.getMediaType(path);
|
|
|
|
|
|
if (mediaType && mediaType !== 'note') {
|
|
|
|
|
|
this.viewMedia(path, mediaType);
|
2025-12-04 17:05:41 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
this.loadNote(path, true, searchHighlight);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// View a media file (image, audio, video, PDF) in the main pane
|
|
|
|
|
|
viewMedia(mediaPath, mediaType = null, updateHistory = true) {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (this.currentMediaType === 'drawing') {
|
|
|
|
|
|
this._drawingDisconnectResizeObserver();
|
2026-04-21 14:00:58 +00:00
|
|
|
|
this._drawingCancelAutosave();
|
2026-04-21 13:22:09 +00:00
|
|
|
|
}
|
2025-12-04 17:05:41 +00:00
|
|
|
|
this.showGraph = false; // Ensure graph is closed
|
2025-11-20 13:10:17 +00:00
|
|
|
|
this.currentNote = '';
|
|
|
|
|
|
this.currentNoteName = '';
|
|
|
|
|
|
this.noteContent = '';
|
2026-01-18 15:00:01 +00:00
|
|
|
|
this.currentMedia = mediaPath; // Reuse currentMedia for all media
|
|
|
|
|
|
this.currentMediaType = mediaType || this.getMediaType(mediaPath) || 'image';
|
2026-01-15 09:07:51 +00:00
|
|
|
|
this.shareInfo = null; // Reset share info
|
2026-01-18 15:00:01 +00:00
|
|
|
|
this.viewMode = 'preview'; // Use preview mode to show media
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Update browser tab title
|
|
|
|
|
|
const fileName = mediaPath.split('/').pop();
|
|
|
|
|
|
document.title = `${fileName} - ${this.appName}`;
|
2025-12-29 09:51:50 +00:00
|
|
|
|
|
2026-01-19 09:14:37 +00:00
|
|
|
|
// Expand folder tree to show the media file
|
|
|
|
|
|
this.expandFolderForNote(mediaPath);
|
|
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
|
// Update browser URL
|
|
|
|
|
|
if (updateHistory) {
|
|
|
|
|
|
// Encode each path segment to handle special characters
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const encodedPath = mediaPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
2025-11-20 13:10:17 +00:00
|
|
|
|
window.history.pushState(
|
2026-01-18 15:00:01 +00:00
|
|
|
|
{ mediaPath: mediaPath },
|
2025-11-20 13:10:17 +00:00
|
|
|
|
'',
|
|
|
|
|
|
`/${encodedPath}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-21 13:22:09 +00:00
|
|
|
|
|
|
|
|
|
|
// Drawing: Alpine x-init on the canvas runs only on first mount; switching from one drawing
|
|
|
|
|
|
// to another keeps currentMediaType === 'drawing', so we must reload the PNG here.
|
|
|
|
|
|
if (this.currentMediaType === 'drawing') {
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
this.initDrawingViewer();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-11-20 13:10:17 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Backward compatibility alias
|
|
|
|
|
|
viewImage(mediaPath, updateHistory = true) {
|
|
|
|
|
|
this.viewMedia(mediaPath, 'image', updateHistory);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Delete a media file (image, audio, video, PDF)
|
|
|
|
|
|
async deleteMedia(mediaPath) {
|
|
|
|
|
|
const filename = mediaPath.split('/').pop();
|
2026-04-17 07:39:52 +00:00
|
|
|
|
const ok = await this.confirmModalAsk({
|
|
|
|
|
|
message: this.t('media.confirm_delete', { name: filename }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!ok) return;
|
2025-11-20 13:10:17 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
method: 'DELETE'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
await this.loadNotes(); // Refresh tree
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Clear viewer if deleting currently viewed media
|
|
|
|
|
|
if (this.currentMedia === mediaPath) {
|
|
|
|
|
|
this.currentMedia = '';
|
2025-11-20 13:10:17 +00:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
throw new Error('Failed to delete media file');
|
2025-11-20 13:10:17 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
ErrorHandler.handle('delete media', error);
|
2025-11-20 13:10:17 +00:00
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Create a blank drawing PNG and open it for editing.
|
|
|
|
|
|
* Attachment folder matches "New note" / "New folder" from the same + menu (root vs folder row vs homepage folder).
|
|
|
|
|
|
*/
|
|
|
|
|
|
async createNewDrawing() {
|
|
|
|
|
|
const targetFolder = this.inferredNewItemTargetFolder();
|
|
|
|
|
|
this.closeDropdown();
|
|
|
|
|
|
const w = 1200;
|
|
|
|
|
|
const h = 675;
|
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
|
canvas.width = w;
|
|
|
|
|
|
canvas.height = h;
|
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
|
ctx.fillStyle = '#ffffff';
|
|
|
|
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
|
|
let blob;
|
|
|
|
|
|
try {
|
|
|
|
|
|
blob = await new Promise((resolve, reject) => {
|
|
|
|
|
|
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
ErrorHandler.handle('create drawing', e);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const file = new File([blob], 'drawing.png', { type: 'image/png' });
|
|
|
|
|
|
try {
|
2026-04-21 14:00:58 +00:00
|
|
|
|
const path = await this.uploadMedia(file, '', {
|
|
|
|
|
|
nextToNotes: true,
|
|
|
|
|
|
contentFolder: targetFolder,
|
|
|
|
|
|
});
|
2026-04-21 13:22:09 +00:00
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
this.viewMedia(path, 'drawing');
|
|
|
|
|
|
} catch (error) {
|
2026-04-21 14:00:58 +00:00
|
|
|
|
ErrorHandler.handle('create drawing', error);
|
2026-04-21 13:22:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_drawingEncodeMediaPath() {
|
|
|
|
|
|
return this.currentMedia.split('/').map((s) => encodeURIComponent(s)).join('/');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_drawingCanvasCoords(event) {
|
|
|
|
|
|
const canvas = this._drawingCanvasEl;
|
|
|
|
|
|
if (!canvas) return { x: 0, y: 0 };
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
|
const scaleX = this._drawingCssW / rect.width;
|
|
|
|
|
|
const scaleY = this._drawingCssH / rect.height;
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: (event.clientX - rect.left) * scaleX,
|
|
|
|
|
|
y: (event.clientY - rect.top) * scaleY,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_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();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
drawingRedraw() {
|
|
|
|
|
|
const canvas = this._drawingCanvasEl;
|
|
|
|
|
|
const ctx = this._drawingCtx;
|
|
|
|
|
|
if (!canvas || !ctx) return;
|
|
|
|
|
|
const w = this._drawingCssW;
|
|
|
|
|
|
const h = this._drawingCssH;
|
|
|
|
|
|
const dpr = this._drawingDpr;
|
|
|
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
2026-04-21 15:39:22 +00:00
|
|
|
|
ctx.fillStyle = CONFIG.DRAWING_BACKGROUND;
|
2026-04-21 13:22:09 +00:00
|
|
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
|
|
if (this._drawingBaseImage && this._drawingBaseImage.complete) {
|
|
|
|
|
|
ctx.drawImage(this._drawingBaseImage, 0, 0, w, h);
|
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-21 14:00:58 +00:00
|
|
|
|
_drawingCancelAutosave() {
|
|
|
|
|
|
if (this._drawingAutosaveTimeout) {
|
|
|
|
|
|
clearTimeout(this._drawingAutosaveTimeout);
|
|
|
|
|
|
this._drawingAutosaveTimeout = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Debounced PNG save (same delay as notes). Never runs while the primary button is held
|
|
|
|
|
|
* (active stroke/shape); pending timers are cleared when a new stroke starts.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_drawingScheduleAutosave() {
|
|
|
|
|
|
if (this.currentMediaType !== 'drawing' || !this.currentMedia) return;
|
|
|
|
|
|
if (this._drawingAutosaveTimeout) {
|
|
|
|
|
|
clearTimeout(this._drawingAutosaveTimeout);
|
|
|
|
|
|
this._drawingAutosaveTimeout = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const path = this.currentMedia;
|
|
|
|
|
|
const pollMs = 150;
|
|
|
|
|
|
const attemptSave = () => {
|
|
|
|
|
|
if (this.currentMedia !== path || this.currentMediaType !== 'drawing') {
|
|
|
|
|
|
this._drawingAutosaveTimeout = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.drawingIsPointerDown) {
|
|
|
|
|
|
this._drawingAutosaveTimeout = setTimeout(attemptSave, pollMs);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._drawingAutosaveTimeout = null;
|
2026-04-21 14:34:34 +00:00
|
|
|
|
this.drawingSave();
|
2026-04-21 14:00:58 +00:00
|
|
|
|
};
|
|
|
|
|
|
this._drawingAutosaveTimeout = setTimeout(attemptSave, CONFIG.AUTOSAVE_DELAY);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
/** Size canvas to drawingCanvasWrap and redraw (fills available pane). */
|
|
|
|
|
|
_drawingLayoutCanvas() {
|
|
|
|
|
|
const wrap = this.$refs.drawingCanvasWrap;
|
|
|
|
|
|
const canvas = this.$refs.drawingCanvas;
|
|
|
|
|
|
if (!wrap || !canvas || this.currentMediaType !== 'drawing') return;
|
|
|
|
|
|
let cssW = wrap.clientWidth;
|
|
|
|
|
|
let cssH = wrap.clientHeight;
|
|
|
|
|
|
if (cssW < 32) cssW = Math.max(320, wrap.parentElement ? wrap.parentElement.clientWidth : 320);
|
|
|
|
|
|
if (cssH < 32) {
|
|
|
|
|
|
const col = wrap.closest('.flex-1.flex.flex-col') || wrap.closest('.flex-1');
|
|
|
|
|
|
const h = col ? col.clientHeight : 0;
|
|
|
|
|
|
cssH = h > 64 ? h : Math.max(240, Math.floor((cssW || 400) * 0.5));
|
|
|
|
|
|
}
|
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
|
canvas.style.width = `${cssW}px`;
|
|
|
|
|
|
canvas.style.height = `${cssH}px`;
|
|
|
|
|
|
canvas.width = Math.round(cssW * dpr);
|
|
|
|
|
|
canvas.height = 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._drawingCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
|
|
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;
|
2026-04-21 15:39:22 +00:00
|
|
|
|
this.drawingHasRasterFromFile = false;
|
2026-04-21 13:22:09 +00:00
|
|
|
|
this._drawingLoadToken = Symbol();
|
|
|
|
|
|
const token = this._drawingLoadToken;
|
|
|
|
|
|
|
|
|
|
|
|
this._drawingResizeObserver = new ResizeObserver(() => {
|
|
|
|
|
|
if (this.currentMediaType !== 'drawing' || !this.currentMedia) return;
|
|
|
|
|
|
this._drawingLayoutCanvas();
|
|
|
|
|
|
});
|
|
|
|
|
|
this._drawingResizeObserver.observe(wrap);
|
|
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
|
if (token !== this._drawingLoadToken) return;
|
|
|
|
|
|
this._drawingLayoutCanvas();
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
|
if (token !== this._drawingLoadToken) return;
|
|
|
|
|
|
this._drawingLayoutCanvas();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const enc = this._drawingEncodeMediaPath();
|
|
|
|
|
|
const res = await fetch(`/api/media/${enc}`, { credentials: 'same-origin' });
|
|
|
|
|
|
if (!res.ok) throw new Error('Failed to load drawing');
|
|
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
|
if (token !== this._drawingLoadToken) return;
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
this._drawingObjectURL = url;
|
|
|
|
|
|
const img = new Image();
|
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
|
img.onload = resolve;
|
|
|
|
|
|
img.onerror = reject;
|
|
|
|
|
|
img.src = url;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (token !== this._drawingLoadToken) return;
|
|
|
|
|
|
this._drawingBaseImage = img;
|
2026-04-21 15:39:22 +00:00
|
|
|
|
this.drawingHasRasterFromFile = true;
|
2026-04-21 13:22:09 +00:00
|
|
|
|
this._drawingLayoutCanvas();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (token !== this._drawingLoadToken) return;
|
|
|
|
|
|
ErrorHandler.handle('load drawing', e);
|
2026-04-21 15:39:22 +00:00
|
|
|
|
this.drawingHasRasterFromFile = false;
|
2026-04-21 13:22:09 +00:00
|
|
|
|
this._drawingLayoutCanvas();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-21 15:39:22 +00:00
|
|
|
|
_drawingRgbToHex(r, g, b) {
|
|
|
|
|
|
const h = (n) => {
|
|
|
|
|
|
const s = n.toString(16);
|
|
|
|
|
|
return s.length === 1 ? `0${s}` : s;
|
|
|
|
|
|
};
|
|
|
|
|
|
return `#${h(r)}${h(g)}${h(b)}`;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** Sample visible canvas color at logical (css) coordinates; sets drawingColor. */
|
|
|
|
|
|
drawingSampleColor(e) {
|
|
|
|
|
|
const canvas = this._drawingCanvasEl;
|
|
|
|
|
|
const ctx = this._drawingCtx;
|
|
|
|
|
|
if (!canvas || !ctx) return;
|
|
|
|
|
|
const { x, y } = this._drawingCanvasCoords(e);
|
|
|
|
|
|
this.drawingRedraw();
|
|
|
|
|
|
const dpr = this._drawingDpr || 1;
|
|
|
|
|
|
let ix = Math.floor(x * dpr);
|
|
|
|
|
|
let iy = Math.floor(y * dpr);
|
|
|
|
|
|
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();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
drawingPointerDown(e) {
|
|
|
|
|
|
if (this.currentMediaType !== 'drawing' || e.button !== 0) return;
|
|
|
|
|
|
const canvas = this._drawingCanvasEl;
|
|
|
|
|
|
if (!canvas) return;
|
2026-04-21 15:39:22 +00:00
|
|
|
|
const tool = this.drawingTool;
|
|
|
|
|
|
if (tool === 'eyedropper') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.drawingSampleColor(e);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-21 14:00:58 +00:00
|
|
|
|
this._drawingCancelAutosave();
|
2026-04-21 13:22:09 +00:00
|
|
|
|
canvas.setPointerCapture(e.pointerId);
|
|
|
|
|
|
this._drawingPointerId = e.pointerId;
|
|
|
|
|
|
const { x, y } = this._drawingCanvasCoords(e);
|
|
|
|
|
|
this.drawingIsPointerDown = true;
|
|
|
|
|
|
this.drawingRedoStack = [];
|
|
|
|
|
|
const lw = this.drawingLineWidth;
|
2026-04-21 15:39:22 +00:00
|
|
|
|
const color = tool === 'eraser' ? CONFIG.DRAWING_BACKGROUND : this.drawingColor;
|
|
|
|
|
|
if (tool === 'freehand' || tool === 'eraser') {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
this.drawingDraft = { type: 'stroke', color, lineWidth: lw, points: [[x, y]] };
|
|
|
|
|
|
} else if (tool === 'line') {
|
|
|
|
|
|
this.drawingDraft = { type: 'line', color, lineWidth: lw, x1: x, y1: y, x2: x, y2: y };
|
|
|
|
|
|
} else if (tool === 'rect') {
|
|
|
|
|
|
this.drawingDraft = { type: 'rect', color, lineWidth: lw, x, y, w: 0, h: 0 };
|
|
|
|
|
|
} else if (tool === 'ellipse') {
|
|
|
|
|
|
this.drawingDraft = { type: 'ellipse', color, lineWidth: lw, x, y, w: 0, h: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
this.drawingRedraw();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
drawingPointerMove(e) {
|
|
|
|
|
|
if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return;
|
|
|
|
|
|
const { x, y } = this._drawingCanvasCoords(e);
|
|
|
|
|
|
const d = this.drawingDraft;
|
|
|
|
|
|
if (!d) return;
|
|
|
|
|
|
if (d.type === 'stroke') {
|
|
|
|
|
|
const pts = d.points;
|
|
|
|
|
|
const last = pts[pts.length - 1];
|
|
|
|
|
|
const dx = x - last[0];
|
|
|
|
|
|
const dy = y - last[1];
|
|
|
|
|
|
if (dx * dx + dy * dy < 1) return;
|
|
|
|
|
|
pts.push([x, y]);
|
|
|
|
|
|
this._drawingScheduleRedraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (d.type === 'line') {
|
|
|
|
|
|
d.x2 = x;
|
|
|
|
|
|
d.y2 = y;
|
|
|
|
|
|
} else if (d.type === 'rect' || d.type === 'ellipse') {
|
|
|
|
|
|
d.w = x - d.x;
|
|
|
|
|
|
d.h = y - d.y;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._drawingScheduleRedraw();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
drawingPointerUp(e) {
|
|
|
|
|
|
if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return;
|
|
|
|
|
|
const canvas = this._drawingCanvasEl;
|
|
|
|
|
|
if (canvas && this._drawingPointerId === e.pointerId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
canvas.releasePointerCapture(e.pointerId);
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
this._drawingPointerId = null;
|
|
|
|
|
|
this.drawingIsPointerDown = false;
|
|
|
|
|
|
const d = this.drawingDraft;
|
|
|
|
|
|
this.drawingDraft = null;
|
|
|
|
|
|
if (!d) {
|
|
|
|
|
|
this.drawingRedraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (d.type === 'stroke') {
|
|
|
|
|
|
if (!d.points || d.points.length < 2) {
|
|
|
|
|
|
this.drawingRedraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.drawingOps.push({
|
|
|
|
|
|
type: 'stroke',
|
|
|
|
|
|
color: d.color,
|
|
|
|
|
|
lineWidth: d.lineWidth,
|
|
|
|
|
|
points: d.points.slice(),
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (d.type === 'line') {
|
|
|
|
|
|
const dx = d.x2 - d.x1;
|
|
|
|
|
|
const dy = d.y2 - d.y1;
|
|
|
|
|
|
if (dx * dx + dy * dy < 4) {
|
|
|
|
|
|
this.drawingRedraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.drawingOps.push({
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
color: d.color,
|
|
|
|
|
|
lineWidth: d.lineWidth,
|
|
|
|
|
|
x1: d.x1,
|
|
|
|
|
|
y1: d.y1,
|
|
|
|
|
|
x2: d.x2,
|
|
|
|
|
|
y2: d.y2,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (d.type === 'rect' || d.type === 'ellipse') {
|
|
|
|
|
|
if (Math.abs(d.w) < 2 && Math.abs(d.h) < 2) {
|
|
|
|
|
|
this.drawingRedraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.drawingOps.push({
|
|
|
|
|
|
type: d.type,
|
|
|
|
|
|
color: d.color,
|
|
|
|
|
|
lineWidth: d.lineWidth,
|
|
|
|
|
|
x: d.x,
|
|
|
|
|
|
y: d.y,
|
|
|
|
|
|
w: d.w,
|
|
|
|
|
|
h: d.h,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
this.drawingRedraw();
|
2026-04-21 14:00:58 +00:00
|
|
|
|
this._drawingScheduleAutosave();
|
2026-04-21 13:22:09 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
drawingUndo() {
|
|
|
|
|
|
if (this.drawingOps.length === 0) return;
|
|
|
|
|
|
this.drawingRedoStack.push(this.drawingOps.pop());
|
|
|
|
|
|
this.drawingRedraw();
|
2026-04-21 14:00:58 +00:00
|
|
|
|
this._drawingScheduleAutosave();
|
2026-04-21 13:22:09 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
drawingRedo() {
|
|
|
|
|
|
if (this.drawingRedoStack.length === 0) return;
|
|
|
|
|
|
this.drawingOps.push(this.drawingRedoStack.pop());
|
|
|
|
|
|
this.drawingRedraw();
|
2026-04-21 14:00:58 +00:00
|
|
|
|
this._drawingScheduleAutosave();
|
2026-04-21 13:22:09 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-21 14:34:34 +00:00
|
|
|
|
/**
|
2026-04-21 15:39:22 +00:00
|
|
|
|
* Persist the flattened canvas to disk (Ctrl+S, autosave). Same feedback as saveNote:
|
2026-04-21 14:34:34 +00:00
|
|
|
|
* header "Saved" only — never clears stroke undo/redo or reloads the image; stacks reset when
|
|
|
|
|
|
* opening another drawing via initDrawingViewer().
|
|
|
|
|
|
*/
|
|
|
|
|
|
async drawingSave() {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (!this.currentMedia || this.currentMediaType !== 'drawing') return;
|
2026-04-21 16:00:50 +00:00
|
|
|
|
if (this._drawingSaveInFlight) {
|
|
|
|
|
|
this._drawingSaveQueued = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._drawingSaveInFlight = true;
|
2026-04-21 13:22:09 +00:00
|
|
|
|
try {
|
2026-04-21 16:00:50 +00:00
|
|
|
|
this._drawingCancelAutosave();
|
|
|
|
|
|
const canvas = this._drawingCanvasEl;
|
|
|
|
|
|
const ctx = this._drawingCtx;
|
|
|
|
|
|
if (!canvas || !ctx) return;
|
|
|
|
|
|
this.drawingDraft = null;
|
|
|
|
|
|
this.drawingIsPointerDown = false;
|
|
|
|
|
|
this.drawingRedraw();
|
|
|
|
|
|
await new Promise((r) => requestAnimationFrame(r));
|
|
|
|
|
|
const blob = await new Promise((resolve, reject) => {
|
|
|
|
|
|
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
|
2026-04-21 13:22:09 +00:00
|
|
|
|
});
|
2026-04-21 16:00:50 +00:00
|
|
|
|
this.isSaving = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const enc = this._drawingEncodeMediaPath();
|
|
|
|
|
|
const res = await fetch(`/api/media/${enc}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: blob,
|
|
|
|
|
|
headers: { 'Content-Type': 'image/png' },
|
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const err = await res.json().catch(() => ({}));
|
|
|
|
|
|
let detail = err.detail;
|
|
|
|
|
|
if (Array.isArray(detail)) {
|
|
|
|
|
|
detail = detail.map((d) => d.msg || d).join(', ');
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(detail || res.statusText);
|
2026-04-21 13:22:09 +00:00
|
|
|
|
}
|
2026-04-21 16:00:50 +00:00
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
this.lastSaved = true;
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.lastSaved = false;
|
|
|
|
|
|
}, CONFIG.SAVE_INDICATOR_DURATION);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('save drawing', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.isSaving = false;
|
2026-04-21 13:22:09 +00:00
|
|
|
|
}
|
2026-04-21 14:00:58 +00:00
|
|
|
|
} finally {
|
2026-04-21 16:00:50 +00:00
|
|
|
|
this._drawingSaveInFlight = false;
|
|
|
|
|
|
if (this._drawingSaveQueued) {
|
|
|
|
|
|
this._drawingSaveQueued = false;
|
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
|
if (this.currentMedia && this.currentMediaType === 'drawing') {
|
|
|
|
|
|
this.drawingSave();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-21 13:22:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Handle clicks on internal links in preview
|
|
|
|
|
|
handleInternalLink(event) {
|
|
|
|
|
|
// Check if clicked element is a link
|
|
|
|
|
|
const link = event.target.closest('a');
|
|
|
|
|
|
if (!link) return;
|
|
|
|
|
|
|
|
|
|
|
|
const href = link.getAttribute('href');
|
|
|
|
|
|
if (!href) return;
|
|
|
|
|
|
|
2026-01-18 18:04:05 +00:00
|
|
|
|
// Check if it's an external link or API path (media files, etc.)
|
2026-04-06 14:30:27 +00:00
|
|
|
|
// Safe external protocols: http, https, mailto, tel, ssh, ftp, sftp, and app deep links
|
|
|
|
|
|
const externalProtocols = ['http://', 'https://', '//', 'mailto:', 'tel:', 'ssh:', 'ftp:', 'sftp:', 'slack:', 'discord:', 'teams:', 'vscode:', 'zoom:', 'whatsapp:', 'telegram:', 'signal:', 'spotify:', 'steam:', 'magnet:', '/api/'];
|
|
|
|
|
|
if (externalProtocols.some(p => href.startsWith(p))) {
|
2026-01-18 18:04:05 +00:00
|
|
|
|
return; // Let external links and API paths work normally
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prevent default navigation for internal links
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
2025-12-16 16:09:36 +00:00
|
|
|
|
// Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section")
|
|
|
|
|
|
const decodedHref = decodeURIComponent(href);
|
|
|
|
|
|
const hashIndex = decodedHref.indexOf('#');
|
|
|
|
|
|
const notePath = hashIndex !== -1 ? decodedHref.substring(0, hashIndex) : decodedHref;
|
|
|
|
|
|
const anchor = hashIndex !== -1 ? decodedHref.substring(hashIndex + 1) : null;
|
|
|
|
|
|
|
|
|
|
|
|
// If it's just an anchor link (#heading), scroll within current note
|
|
|
|
|
|
if (!notePath && anchor) {
|
|
|
|
|
|
this.scrollToAnchor(anchor);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2025-12-16 16:09:36 +00:00
|
|
|
|
// Skip if no path
|
2025-11-05 16:48:41 +00:00
|
|
|
|
if (!notePath) return;
|
|
|
|
|
|
|
2025-12-03 15:00:25 +00:00
|
|
|
|
// Find the note by path (try exact match first, then with .md extension)
|
2025-12-16 16:09:36 +00:00
|
|
|
|
let targetNote = this.notes.find(n =>
|
2025-12-03 15:00:25 +00:00
|
|
|
|
n.path === notePath ||
|
|
|
|
|
|
n.path === notePath + '.md'
|
|
|
|
|
|
);
|
2025-12-16 16:09:36 +00:00
|
|
|
|
|
|
|
|
|
|
if (!targetNote) {
|
2025-12-03 15:00:25 +00:00
|
|
|
|
// Try to find by name (in case link uses just the note name without path)
|
2025-12-16 16:09:36 +00:00
|
|
|
|
targetNote = this.notes.find(n =>
|
2025-12-03 15:00:25 +00:00
|
|
|
|
n.name === notePath ||
|
|
|
|
|
|
n.name === notePath + '.md' ||
|
|
|
|
|
|
n.name.toLowerCase() === notePath.toLowerCase() ||
|
|
|
|
|
|
n.name.toLowerCase() === (notePath + '.md').toLowerCase()
|
|
|
|
|
|
);
|
2025-12-16 16:09:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!targetNote) {
|
|
|
|
|
|
// Last resort: case-insensitive path matching
|
|
|
|
|
|
targetNote = this.notes.find(n =>
|
|
|
|
|
|
n.path.toLowerCase() === notePath.toLowerCase() ||
|
|
|
|
|
|
n.path.toLowerCase() === (notePath + '.md').toLowerCase()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetNote) {
|
|
|
|
|
|
// Load the note, then scroll to anchor if present
|
|
|
|
|
|
this.loadNote(targetNote.path).then(() => {
|
|
|
|
|
|
if (anchor) {
|
|
|
|
|
|
// Small delay to ensure content is rendered
|
|
|
|
|
|
setTimeout(() => this.scrollToAnchor(anchor), 100);
|
2025-12-03 15:00:25 +00:00
|
|
|
|
}
|
2025-12-16 16:09:36 +00:00
|
|
|
|
});
|
2026-04-17 07:39:52 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
this.confirmModalAsk({
|
|
|
|
|
|
message: this.t('notes.create_from_link', { path: notePath }),
|
|
|
|
|
|
danger: false,
|
|
|
|
|
|
title: this.t('sidebar.new_note'),
|
|
|
|
|
|
confirmLabel: this.t('common.create'),
|
|
|
|
|
|
}).then((ok) => {
|
|
|
|
|
|
if (ok) this.createNote(null, notePath);
|
|
|
|
|
|
});
|
2025-12-16 16:09:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll to an anchor (heading) by slug - reuses outline data
|
|
|
|
|
|
scrollToAnchor(anchor) {
|
|
|
|
|
|
// Normalize the anchor (GitHub-style slug)
|
|
|
|
|
|
const targetSlug = anchor
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.replace(/[^\w\s-]/g, '')
|
|
|
|
|
|
.replace(/\s+/g, '-')
|
|
|
|
|
|
.replace(/-+/g, '-');
|
|
|
|
|
|
|
|
|
|
|
|
// Find matching heading in outline
|
|
|
|
|
|
const heading = this.outline.find(h => h.slug === targetSlug);
|
|
|
|
|
|
|
|
|
|
|
|
if (heading) {
|
|
|
|
|
|
this.scrollToHeading(heading);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Fallback: try to find heading by exact text match
|
|
|
|
|
|
const headingByText = this.outline.find(h =>
|
|
|
|
|
|
h.text.toLowerCase().replace(/\s+/g, '-') === anchor.toLowerCase()
|
|
|
|
|
|
);
|
|
|
|
|
|
if (headingByText) {
|
|
|
|
|
|
this.scrollToHeading(headingByText);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-09 11:08:12 +00:00
|
|
|
|
|
|
|
|
|
|
cancelDrag() {
|
|
|
|
|
|
// Cancel any active drag operation (triggered by ESC key)
|
|
|
|
|
|
this.draggedItem = null;
|
|
|
|
|
|
this.dropTarget = null;
|
|
|
|
|
|
this.dragOverFolder = null;
|
|
|
|
|
|
// Reset styles - only query elements with drag-over class (more efficient)
|
|
|
|
|
|
document.querySelectorAll('.folder-item').forEach(el => el.style.opacity = '1');
|
|
|
|
|
|
document.querySelectorAll('.note-item').forEach(el => el.style.opacity = '1');
|
|
|
|
|
|
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async onFolderDrop(targetFolderPath) {
|
2025-11-20 13:10:17 +00:00
|
|
|
|
// Ignore if we're dropping into the editor
|
|
|
|
|
|
if (this.dropTarget === 'editor') {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
// Capture dragged item info immediately (ondragend may clear it)
|
|
|
|
|
|
if (!this.draggedItem) return;
|
|
|
|
|
|
const { path: draggedPath, type: draggedType } = this.draggedItem;
|
2025-12-15 11:18:14 +00:00
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
// Determine item category for endpoint selection
|
|
|
|
|
|
const isFolder = draggedType === 'folder';
|
|
|
|
|
|
const isNote = draggedType === 'note';
|
|
|
|
|
|
const isMedia = !isFolder && !isNote; // image, audio, video, document
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
// Handle folder drop
|
|
|
|
|
|
if (isFolder) {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Prevent dropping folder into itself or its subfolders
|
2026-01-19 08:42:32 +00:00
|
|
|
|
if (targetFolderPath === draggedPath ||
|
|
|
|
|
|
targetFolderPath.startsWith(draggedPath + '/')) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('folders.cannot_move_into_self'), { type: 'warning' });
|
2025-11-05 16:48:41 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
const folderName = draggedPath.split('/').pop();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName;
|
|
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
if (newPath === draggedPath) return;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
|
// Capture favorites info before async call
|
2026-01-19 08:42:32 +00:00
|
|
|
|
const oldPrefix = draggedPath + '/';
|
2025-12-15 11:18:14 +00:00
|
|
|
|
const newPrefix = newPath + '/';
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/folders/move', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-01-19 08:42:32 +00:00
|
|
|
|
body: JSON.stringify({ oldPath: draggedPath, newPath })
|
2025-11-05 16:48:41 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
2026-01-19 08:42:32 +00:00
|
|
|
|
// Update favorites for notes inside moved folder
|
|
|
|
|
|
const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix));
|
|
|
|
|
|
if (favoritesInFolder.length > 0) {
|
|
|
|
|
|
const newFavorites = this.favorites.map(f =>
|
|
|
|
|
|
f.startsWith(oldPrefix) ? newPrefix + f.substring(oldPrefix.length) : f
|
|
|
|
|
|
);
|
2025-12-15 11:18:14 +00:00
|
|
|
|
this.favorites = newFavorites;
|
|
|
|
|
|
this.favoritesSet = new Set(newFavorites);
|
|
|
|
|
|
this.saveFavorites();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
// Keep folder expanded if it was
|
|
|
|
|
|
const wasExpanded = this.expandedFolders.has(draggedPath);
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
await this.loadNotes();
|
2026-01-19 08:42:32 +00:00
|
|
|
|
await this.loadSharedNotePaths();
|
|
|
|
|
|
|
|
|
|
|
|
if (wasExpanded) {
|
|
|
|
|
|
this.expandedFolders.delete(draggedPath);
|
|
|
|
|
|
this.expandedFolders.add(newPath);
|
|
|
|
|
|
this.saveExpandedFolders();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2025-12-15 11:18:14 +00:00
|
|
|
|
const errorData = await response.json().catch(() => ({}));
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(errorData.detail || this.t('move.failed_folder'), { type: 'error' });
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to move folder:', error);
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('move.failed_folder'), { type: 'error' });
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
2026-01-19 08:42:32 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Handle note or media drop into folder
|
|
|
|
|
|
const item = this.notes.find(n => n.path === draggedPath);
|
|
|
|
|
|
if (!item) return;
|
|
|
|
|
|
|
|
|
|
|
|
const filename = draggedPath.split('/').pop();
|
|
|
|
|
|
const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename;
|
|
|
|
|
|
|
|
|
|
|
|
if (newPath === draggedPath) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Check if note is favorited (only for notes)
|
|
|
|
|
|
const wasFavorited = isNote && this.favoritesSet.has(draggedPath);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Use different endpoint for media vs notes
|
|
|
|
|
|
const endpoint = isMedia ? '/api/media/move' : '/api/notes/move';
|
|
|
|
|
|
const response = await fetch(endpoint, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ oldPath: draggedPath, newPath })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
// Update favorites if the moved note was favorited
|
|
|
|
|
|
if (wasFavorited) {
|
|
|
|
|
|
const newFavorites = this.favorites.map(f => f === draggedPath ? newPath : f);
|
|
|
|
|
|
this.favorites = newFavorites;
|
|
|
|
|
|
this.favoritesSet = new Set(newFavorites);
|
|
|
|
|
|
this.saveFavorites();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Keep current item open if it was the moved one
|
|
|
|
|
|
const wasCurrentNote = this.currentNote === draggedPath;
|
|
|
|
|
|
const wasCurrentMedia = this.currentMedia === draggedPath;
|
|
|
|
|
|
|
|
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
if (isNote) {
|
|
|
|
|
|
await this.loadSharedNotePaths();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (wasCurrentNote) this.currentNote = newPath;
|
|
|
|
|
|
if (wasCurrentMedia) this.currentMedia = newPath;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const errorData = await response.json().catch(() => ({}));
|
|
|
|
|
|
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(errorData.detail || this.t(errorKey), { type: 'error' });
|
2026-01-19 08:42:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error);
|
|
|
|
|
|
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t(errorKey), { type: 'error' });
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-19 08:42:32 +00:00
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Load a specific note
|
|
|
|
|
|
async loadNote(notePath, updateHistory = true, searchQuery = '') {
|
|
|
|
|
|
try {
|
2025-11-13 14:09:26 +00:00
|
|
|
|
// Close mobile sidebar when a note is selected
|
|
|
|
|
|
this.mobileSidebarOpen = false;
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const response = await fetch(`/api/notes/${notePath}`);
|
|
|
|
|
|
|
|
|
|
|
|
// Check if note exists
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
if (response.status === 404) {
|
|
|
|
|
|
// Note not found - silently redirect to home
|
2025-11-20 03:01:27 +00:00
|
|
|
|
window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/');
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.currentNote = '';
|
|
|
|
|
|
this.noteContent = '';
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (this.currentMediaType === 'drawing') {
|
|
|
|
|
|
this._drawingDisconnectResizeObserver();
|
|
|
|
|
|
}
|
2026-01-18 15:00:01 +00:00
|
|
|
|
this.currentMedia = '';
|
2026-01-05 10:05:08 +00:00
|
|
|
|
document.title = this.appName;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
this.currentNote = notePath;
|
2025-12-12 17:11:29 +00:00
|
|
|
|
this._lastRenderedContent = ''; // Clear render cache for new note
|
2026-04-13 14:11:55 +00:00
|
|
|
|
this._lastRenderedNote = '';
|
2025-12-12 17:11:29 +00:00
|
|
|
|
this._cachedRenderedHTML = '';
|
2026-01-18 16:32:22 +00:00
|
|
|
|
this._initializedVideoSources = new Set(); // Clear video cache for new note
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.noteContent = data.content;
|
|
|
|
|
|
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
|
2026-04-21 13:22:09 +00:00
|
|
|
|
if (this.currentMediaType === 'drawing') {
|
|
|
|
|
|
this._drawingDisconnectResizeObserver();
|
|
|
|
|
|
}
|
2026-01-18 15:00:01 +00:00
|
|
|
|
this.currentMedia = ''; // Clear image viewer when loading a note
|
2026-01-15 09:07:51 +00:00
|
|
|
|
this.shareInfo = null; // Reset share info for new note
|
2025-12-29 09:51:50 +00:00
|
|
|
|
|
|
|
|
|
|
// Update browser tab title
|
2026-01-05 10:05:08 +00:00
|
|
|
|
document.title = `${this.currentNoteName} - ${this.appName}`;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.lastSaved = false;
|
|
|
|
|
|
|
2025-12-16 15:55:05 +00:00
|
|
|
|
// Extract outline for TOC panel
|
|
|
|
|
|
this.extractOutline(data.content);
|
2026-03-24 11:02:34 +00:00
|
|
|
|
|
|
|
|
|
|
// Store backlinks from API response
|
|
|
|
|
|
this.backlinks = data.backlinks || [];
|
|
|
|
|
|
|
2025-12-14 14:16:06 +00:00
|
|
|
|
// Initialize undo/redo history for this note (with cursor at start)
|
|
|
|
|
|
this.undoHistory = [{ content: data.content, cursorPos: 0 }];
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.redoHistory = [];
|
2025-12-18 08:50:08 +00:00
|
|
|
|
this.hasPendingHistoryChanges = false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Update browser URL and history
|
|
|
|
|
|
if (updateHistory) {
|
|
|
|
|
|
// Encode the path properly (spaces become %20, etc.)
|
|
|
|
|
|
const pathWithoutExtension = notePath.replace('.md', '');
|
|
|
|
|
|
// Encode each path segment to handle special characters
|
|
|
|
|
|
const encodedPath = pathWithoutExtension.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
|
|
|
|
let url = `/${encodedPath}`;
|
|
|
|
|
|
// Add search query parameter if present
|
|
|
|
|
|
if (searchQuery) {
|
|
|
|
|
|
url += `?search=${encodeURIComponent(searchQuery)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.history.pushState(
|
2025-11-20 03:01:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
notePath: notePath,
|
|
|
|
|
|
searchQuery: searchQuery,
|
|
|
|
|
|
homepageFolder: this.selectedHomepageFolder || '' // Save current folder state
|
|
|
|
|
|
},
|
2025-11-05 16:48:41 +00:00
|
|
|
|
'',
|
|
|
|
|
|
url
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate stats if plugin enabled
|
|
|
|
|
|
if (this.statsPluginEnabled) {
|
|
|
|
|
|
this.calculateStats();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 09:07:07 +00:00
|
|
|
|
// Parse frontmatter metadata
|
|
|
|
|
|
this.parseMetadata();
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Store search query for highlighting
|
|
|
|
|
|
if (searchQuery) {
|
|
|
|
|
|
this.currentSearchHighlight = searchQuery;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Clear highlights if no search query
|
|
|
|
|
|
this.currentSearchHighlight = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Expand folder tree to show the loaded note
|
|
|
|
|
|
this.expandFolderForNote(notePath);
|
|
|
|
|
|
|
|
|
|
|
|
// Use $nextTick twice to ensure Alpine.js has time to:
|
|
|
|
|
|
// 1. First tick: expand folders and update DOM
|
|
|
|
|
|
// 2. Second tick: highlight the note and setup everything else
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
this.refreshDOMCache();
|
|
|
|
|
|
this.setupScrollSync();
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-19 09:14:37 +00:00
|
|
|
|
// Load item (note or media) from URL path
|
|
|
|
|
|
loadItemFromURL() {
|
|
|
|
|
|
// Get path from URL (e.g., /folder/note or /folder/image.png)
|
2025-12-10 08:17:25 +00:00
|
|
|
|
let path = window.location.pathname;
|
|
|
|
|
|
|
|
|
|
|
|
// Strip .md extension if present (for MKdocs/Zensical integration)
|
|
|
|
|
|
if (path.toLowerCase().endsWith('.md')) {
|
|
|
|
|
|
path = path.slice(0, -3);
|
|
|
|
|
|
// Update URL bar to show clean path without .md
|
|
|
|
|
|
window.history.replaceState(null, '', path);
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Skip if root path or static assets
|
|
|
|
|
|
if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 13:10:17 +00:00
|
|
|
|
// Remove leading slash and decode URL encoding (e.g., %20 -> space)
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const decodedPath = decodeURIComponent(path.substring(1));
|
|
|
|
|
|
|
2026-01-19 09:14:37 +00:00
|
|
|
|
// Check if this is a media file (image, audio, video, PDF)
|
2025-11-20 13:10:17 +00:00
|
|
|
|
const matchedItem = this.notes.find(n => n.path === decodedPath);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2026-01-19 09:14:37 +00:00
|
|
|
|
if (matchedItem && matchedItem.type !== 'note') {
|
|
|
|
|
|
// It's a media file, view it
|
|
|
|
|
|
this.viewMedia(decodedPath, matchedItem.type, false); // false = don't update history
|
2025-11-20 13:10:17 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
// It's a note, add .md extension and load it
|
|
|
|
|
|
const notePath = decodedPath + '.md';
|
|
|
|
|
|
|
|
|
|
|
|
// Parse query string for search parameter
|
|
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
|
|
|
|
const searchParam = urlParams.get('search');
|
|
|
|
|
|
|
|
|
|
|
|
// Try to load the note directly - the backend will handle 404 if it doesn't exist
|
|
|
|
|
|
// This is more robust than checking the frontend notes list
|
|
|
|
|
|
this.loadNote(notePath, false, searchParam || '');
|
|
|
|
|
|
|
|
|
|
|
|
// If there's a search parameter, populate the search box and trigger search
|
|
|
|
|
|
if (searchParam) {
|
|
|
|
|
|
this.searchQuery = searchParam;
|
|
|
|
|
|
// Trigger search to populate results list
|
|
|
|
|
|
this.searchNotes();
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Highlight search term in editor and preview
|
|
|
|
|
|
highlightSearchTerm(query, focusEditor = false) {
|
|
|
|
|
|
if (!query || !query.trim()) {
|
|
|
|
|
|
this.clearSearchHighlights();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const searchTerm = query.trim();
|
|
|
|
|
|
|
|
|
|
|
|
// Highlight in editor (textarea)
|
|
|
|
|
|
this.highlightInEditor(searchTerm, focusEditor);
|
|
|
|
|
|
|
|
|
|
|
|
// Highlight in preview (rendered HTML)
|
|
|
|
|
|
this.highlightInPreview(searchTerm);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Highlight search term in the editor textarea
|
|
|
|
|
|
highlightInEditor(searchTerm, shouldFocus = false) {
|
|
|
|
|
|
const editor = this._domCache.editor || document.getElementById('editor');
|
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
|
|
|
|
|
|
// For textarea, we can't directly highlight text, but we can scroll to first match
|
|
|
|
|
|
const content = editor.value;
|
|
|
|
|
|
const lowerContent = content.toLowerCase();
|
|
|
|
|
|
const lowerTerm = searchTerm.toLowerCase();
|
|
|
|
|
|
const index = lowerContent.indexOf(lowerTerm);
|
|
|
|
|
|
|
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
|
// Calculate line number to scroll to
|
|
|
|
|
|
const textBefore = content.substring(0, index);
|
|
|
|
|
|
const lineNumber = textBefore.split('\n').length;
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll to approximate position
|
|
|
|
|
|
const lineHeight = 20; // Approximate line height in pixels
|
|
|
|
|
|
editor.scrollTop = (lineNumber - 5) * lineHeight; // Scroll a bit above to show context
|
|
|
|
|
|
|
|
|
|
|
|
// Only focus and select if explicitly requested (e.g., from search result click)
|
|
|
|
|
|
if (shouldFocus) {
|
|
|
|
|
|
editor.focus();
|
|
|
|
|
|
editor.setSelectionRange(index, index + searchTerm.length);
|
|
|
|
|
|
|
|
|
|
|
|
// Blur immediately so the selection stays visible but editor isn't focused
|
|
|
|
|
|
setTimeout(() => editor.blur(), 100);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Highlight search term in the preview pane
|
|
|
|
|
|
highlightInPreview(searchTerm) {
|
|
|
|
|
|
const preview = document.querySelector('.markdown-preview');
|
|
|
|
|
|
if (!preview) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Remove existing highlights
|
|
|
|
|
|
this.clearSearchHighlights();
|
|
|
|
|
|
|
|
|
|
|
|
// Create a tree walker to find all text nodes
|
|
|
|
|
|
const walker = document.createTreeWalker(
|
|
|
|
|
|
preview,
|
|
|
|
|
|
NodeFilter.SHOW_TEXT,
|
|
|
|
|
|
null,
|
|
|
|
|
|
false
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const textNodes = [];
|
|
|
|
|
|
let node;
|
|
|
|
|
|
while (node = walker.nextNode()) {
|
|
|
|
|
|
// Skip code blocks and pre tags
|
|
|
|
|
|
if (node.parentElement.tagName === 'CODE' ||
|
|
|
|
|
|
node.parentElement.tagName === 'PRE') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
textNodes.push(node);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const lowerTerm = searchTerm.toLowerCase();
|
|
|
|
|
|
let matchIndex = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Highlight matches in text nodes
|
|
|
|
|
|
textNodes.forEach(textNode => {
|
|
|
|
|
|
const text = textNode.textContent;
|
|
|
|
|
|
const lowerText = text.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
if (lowerText.includes(lowerTerm)) {
|
|
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
|
|
let lastIndex = 0;
|
|
|
|
|
|
let index;
|
|
|
|
|
|
|
|
|
|
|
|
while ((index = lowerText.indexOf(lowerTerm, lastIndex)) !== -1) {
|
|
|
|
|
|
// Add text before match
|
|
|
|
|
|
if (index > lastIndex) {
|
|
|
|
|
|
fragment.appendChild(
|
|
|
|
|
|
document.createTextNode(text.substring(lastIndex, index))
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add highlighted match
|
|
|
|
|
|
const mark = document.createElement('mark');
|
|
|
|
|
|
mark.className = 'search-highlight';
|
|
|
|
|
|
mark.setAttribute('data-match-index', matchIndex);
|
|
|
|
|
|
mark.textContent = text.substring(index, index + searchTerm.length);
|
|
|
|
|
|
|
2025-12-05 11:00:53 +00:00
|
|
|
|
// First match is active (styled via CSS)
|
2025-11-05 16:48:41 +00:00
|
|
|
|
if (matchIndex === 0) {
|
|
|
|
|
|
mark.classList.add('active-match');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fragment.appendChild(mark);
|
|
|
|
|
|
matchIndex++;
|
|
|
|
|
|
|
|
|
|
|
|
lastIndex = index + searchTerm.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add remaining text
|
|
|
|
|
|
if (lastIndex < text.length) {
|
|
|
|
|
|
fragment.appendChild(
|
|
|
|
|
|
document.createTextNode(text.substring(lastIndex))
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Replace text node with highlighted fragment
|
|
|
|
|
|
textNode.parentNode.replaceChild(fragment, textNode);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Update total matches and reset current index
|
|
|
|
|
|
this.totalMatches = matchIndex;
|
|
|
|
|
|
this.currentMatchIndex = matchIndex > 0 ? 0 : -1;
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll to first match
|
|
|
|
|
|
if (this.totalMatches > 0) {
|
|
|
|
|
|
this.scrollToMatch(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Navigate to next search match
|
|
|
|
|
|
nextMatch() {
|
|
|
|
|
|
if (this.totalMatches === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
this.currentMatchIndex = (this.currentMatchIndex + 1) % this.totalMatches;
|
|
|
|
|
|
this.scrollToMatch(this.currentMatchIndex);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Navigate to previous search match
|
|
|
|
|
|
previousMatch() {
|
|
|
|
|
|
if (this.totalMatches === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
this.currentMatchIndex = (this.currentMatchIndex - 1 + this.totalMatches) % this.totalMatches;
|
|
|
|
|
|
this.scrollToMatch(this.currentMatchIndex);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll to a specific match index
|
|
|
|
|
|
scrollToMatch(index) {
|
|
|
|
|
|
const preview = document.querySelector('.markdown-preview');
|
|
|
|
|
|
if (!preview) return;
|
|
|
|
|
|
|
|
|
|
|
|
const allMatches = preview.querySelectorAll('mark.search-highlight');
|
|
|
|
|
|
if (index < 0 || index >= allMatches.length) return;
|
|
|
|
|
|
|
2025-12-05 11:00:53 +00:00
|
|
|
|
// Update styling - make current match prominent (via CSS class)
|
2025-11-05 16:48:41 +00:00
|
|
|
|
allMatches.forEach((mark, i) => {
|
2025-12-05 11:00:53 +00:00
|
|
|
|
mark.classList.toggle('active-match', i === index);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll to the match
|
|
|
|
|
|
const targetMatch = allMatches[index];
|
|
|
|
|
|
const previewContainer = this._domCache.previewContainer;
|
|
|
|
|
|
if (previewContainer && targetMatch) {
|
|
|
|
|
|
const elementTop = targetMatch.offsetTop;
|
|
|
|
|
|
previewContainer.scrollTop = elementTop - 100; // Scroll with some offset
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Clear search highlights
|
|
|
|
|
|
clearSearchHighlights() {
|
|
|
|
|
|
const preview = document.querySelector('.markdown-preview');
|
|
|
|
|
|
if (!preview) return;
|
|
|
|
|
|
|
|
|
|
|
|
const highlights = preview.querySelectorAll('mark.search-highlight');
|
|
|
|
|
|
highlights.forEach(mark => {
|
|
|
|
|
|
const text = document.createTextNode(mark.textContent);
|
|
|
|
|
|
mark.parentNode.replaceChild(text, mark);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Normalize text nodes to merge adjacent text nodes
|
|
|
|
|
|
preview.normalize();
|
|
|
|
|
|
|
|
|
|
|
|
// Reset match counters
|
|
|
|
|
|
this.totalMatches = 0;
|
|
|
|
|
|
this.currentMatchIndex = -1;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-17 13:20:57 +00:00
|
|
|
|
// =====================================================
|
|
|
|
|
|
// DROPDOWN MENU SYSTEM
|
|
|
|
|
|
// =====================================================
|
|
|
|
|
|
|
2025-11-26 13:38:47 +00:00
|
|
|
|
toggleNewDropdown(event) {
|
|
|
|
|
|
this.showNewDropdown = true; // Always open (or keep open)
|
|
|
|
|
|
|
|
|
|
|
|
if (event && event.target) {
|
|
|
|
|
|
const rect = event.target.getBoundingClientRect();
|
|
|
|
|
|
// Position dropdown next to the clicked element
|
|
|
|
|
|
let top = rect.bottom + 4; // 4px spacing
|
|
|
|
|
|
let left = rect.left;
|
|
|
|
|
|
|
|
|
|
|
|
// Keep dropdown on screen
|
|
|
|
|
|
const dropdownWidth = 200;
|
|
|
|
|
|
const dropdownHeight = 150;
|
|
|
|
|
|
if (left + dropdownWidth > window.innerWidth) {
|
|
|
|
|
|
left = rect.right - dropdownWidth;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (top + dropdownHeight > window.innerHeight) {
|
|
|
|
|
|
top = rect.top - dropdownHeight - 4;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.dropdownPosition = { top, left };
|
2025-11-26 11:43:37 +00:00
|
|
|
|
}
|
2025-11-17 13:20:57 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
closeDropdown() {
|
|
|
|
|
|
this.showNewDropdown = false;
|
2025-11-27 13:13:39 +00:00
|
|
|
|
this.dropdownTargetFolder = null; // Reset folder context
|
2025-11-17 13:20:57 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-21 13:22:09 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Parent folder for new note/folder/drawing from the + menu when no explicit path is passed.
|
|
|
|
|
|
* Same rules as the create-name modal: '' = vault root; otherwise a folder path (e.g. folder1/sub).
|
|
|
|
|
|
*/
|
|
|
|
|
|
inferredNewItemTargetFolder() {
|
|
|
|
|
|
if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
|
|
|
|
|
|
return this.dropdownTargetFolder;
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.selectedHomepageFolder || '';
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-17 13:20:57 +00:00
|
|
|
|
// =====================================================
|
|
|
|
|
|
// UNIFIED CREATION FUNCTIONS (reusable from anywhere)
|
|
|
|
|
|
// =====================================================
|
|
|
|
|
|
|
2026-02-09 17:17:12 +00:00
|
|
|
|
// Switch to split view (if in preview-only mode) and focus editor for new notes
|
|
|
|
|
|
focusEditorForNewNote() {
|
|
|
|
|
|
// Only switch if in preview-only mode - don't disturb edit or split mode
|
|
|
|
|
|
if (this.viewMode === 'preview') {
|
|
|
|
|
|
this.viewMode = 'split';
|
|
|
|
|
|
this.saveViewMode();
|
|
|
|
|
|
}
|
|
|
|
|
|
// Focus the editor after a short delay to ensure DOM is updated
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
const editor = document.getElementById('note-editor');
|
|
|
|
|
|
if (editor) editor.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-19 14:52:29 +00:00
|
|
|
|
async createNote(folderPath = null, directPath = null) {
|
|
|
|
|
|
if (directPath) {
|
2026-04-17 07:27:36 +00:00
|
|
|
|
const notePath = directPath.endsWith('.md') ? directPath : `${directPath}.md`;
|
|
|
|
|
|
const existingNote = this.notes.find(note => note.path === notePath);
|
|
|
|
|
|
if (existingNote) {
|
|
|
|
|
|
this.toast(this.t('notes.already_exists', { name: notePath }), { type: 'warning' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await this._finalizeCreateNote(notePath);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const explicitTarget = folderPath !== null && folderPath !== undefined ? folderPath : undefined;
|
|
|
|
|
|
this.openCreateNameModal('note', explicitTarget);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param {'note'|'folder'} kind
|
|
|
|
|
|
* @param {string|undefined} explicitTargetFolder - if set, use as parent folder context ("" = root)
|
|
|
|
|
|
*/
|
|
|
|
|
|
openCreateNameModal(kind, explicitTargetFolder = undefined) {
|
2026-04-21 13:22:09 +00:00
|
|
|
|
const targetFolder =
|
|
|
|
|
|
explicitTargetFolder !== undefined ? explicitTargetFolder : this.inferredNewItemTargetFolder();
|
2026-04-17 07:27:36 +00:00
|
|
|
|
this.closeDropdown();
|
|
|
|
|
|
this.mobileSidebarOpen = false;
|
|
|
|
|
|
this.createNameModalKind = kind;
|
|
|
|
|
|
this.createNameModalTargetFolder = targetFolder;
|
|
|
|
|
|
this.createNameModalInput = '';
|
|
|
|
|
|
this.showCreateNameModal = true;
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
const el = document.getElementById('create-name-modal-input');
|
|
|
|
|
|
if (el) {
|
|
|
|
|
|
el.focus();
|
|
|
|
|
|
el.select();
|
2025-12-19 14:52:29 +00:00
|
|
|
|
}
|
2026-04-17 07:27:36 +00:00
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
closeCreateNameModal() {
|
|
|
|
|
|
this.showCreateNameModal = false;
|
|
|
|
|
|
this.createNameModalInput = '';
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
cancelCreateNameModal() {
|
|
|
|
|
|
this.closeCreateNameModal();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
createNameModalHelpText() {
|
|
|
|
|
|
const tf = this.createNameModalTargetFolder;
|
|
|
|
|
|
if (this.createNameModalKind === 'note') {
|
|
|
|
|
|
return tf
|
|
|
|
|
|
? this.t('notes.prompt_name_in_folder', { folder: tf })
|
2025-12-19 14:52:29 +00:00
|
|
|
|
: this.t('notes.prompt_name_with_path');
|
2026-04-17 07:27:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
return tf
|
|
|
|
|
|
? this.t('folders.prompt_name_in_folder', { folder: tf })
|
|
|
|
|
|
: this.t('folders.prompt_name_with_path');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
createNameModalTitle() {
|
|
|
|
|
|
return this.createNameModalKind === 'note'
|
|
|
|
|
|
? this.t('sidebar.new_note')
|
|
|
|
|
|
: this.t('sidebar.new_folder');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
createNameModalLabel() {
|
|
|
|
|
|
return this.createNameModalKind === 'note'
|
2026-04-20 10:45:41 +00:00
|
|
|
|
? this.t('notes.prompt_name')
|
2026-04-17 07:27:36 +00:00
|
|
|
|
: this.t('folders.prompt_name');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async submitCreateNameModal() {
|
|
|
|
|
|
const rawName = (this.createNameModalInput || '').trim();
|
|
|
|
|
|
if (!rawName) {
|
|
|
|
|
|
this.closeCreateNameModal();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const targetFolder = this.createNameModalTargetFolder;
|
|
|
|
|
|
const kind = this.createNameModalKind;
|
|
|
|
|
|
|
|
|
|
|
|
if (kind === 'note') {
|
|
|
|
|
|
const validation = targetFolder
|
|
|
|
|
|
? FilenameValidator.validateFilename(rawName)
|
|
|
|
|
|
: FilenameValidator.validatePath(rawName);
|
2025-12-19 14:52:29 +00:00
|
|
|
|
if (!validation.valid) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
|
2025-12-19 14:52:29 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const validatedName = validation.sanitized;
|
2026-04-17 07:27:36 +00:00
|
|
|
|
const notePath = targetFolder
|
|
|
|
|
|
? `${targetFolder}/${validatedName}.md`
|
|
|
|
|
|
: (validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`);
|
|
|
|
|
|
const existingNote = this.notes.find(note => note.path === notePath);
|
|
|
|
|
|
if (existingNote) {
|
|
|
|
|
|
this.toast(this.t('notes.already_exists', { name: notePath }), { type: 'warning' });
|
|
|
|
|
|
return;
|
2025-12-19 14:52:29 +00:00
|
|
|
|
}
|
2026-04-17 07:27:36 +00:00
|
|
|
|
const ok = await this._finalizeCreateNote(notePath);
|
|
|
|
|
|
if (ok) this.closeCreateNameModal();
|
|
|
|
|
|
return;
|
2025-11-17 13:20:57 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 07:27:36 +00:00
|
|
|
|
const validation = targetFolder
|
|
|
|
|
|
? FilenameValidator.validateFilename(rawName)
|
|
|
|
|
|
: FilenameValidator.validatePath(rawName);
|
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
|
this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' });
|
2025-11-17 13:20:57 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-17 07:27:36 +00:00
|
|
|
|
const validatedName = validation.sanitized;
|
|
|
|
|
|
const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
|
|
|
|
|
|
const existingFolder = this.allFolders.find(folder => folder === folderPath);
|
|
|
|
|
|
if (existingFolder) {
|
|
|
|
|
|
this.toast(this.t('folders.already_exists', { name: validatedName }), { type: 'warning' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const ok = await this._finalizeCreateFolder(folderPath, targetFolder);
|
|
|
|
|
|
if (ok) this.closeCreateNameModal();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async _finalizeCreateNote(notePath) {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/notes/${notePath}`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ content: '' })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
2025-12-19 14:52:29 +00:00
|
|
|
|
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
|
|
|
|
|
|
if (folderPart) this.expandedFolders.add(folderPart);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
await this.loadNote(notePath);
|
2026-02-09 17:17:12 +00:00
|
|
|
|
this.focusEditorForNewNote();
|
2026-04-17 07:27:36 +00:00
|
|
|
|
return true;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
2026-04-17 07:27:36 +00:00
|
|
|
|
ErrorHandler.handle('create note', new Error('Server returned error'));
|
|
|
|
|
|
return false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('create note', error);
|
2026-04-17 07:27:36 +00:00
|
|
|
|
return false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-17 07:27:36 +00:00
|
|
|
|
async _finalizeCreateFolder(folderPath, targetFolder) {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/folders', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ path: folderPath })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
2025-11-26 11:43:37 +00:00
|
|
|
|
if (targetFolder) {
|
|
|
|
|
|
this.expandedFolders.add(targetFolder);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
2025-11-17 13:20:57 +00:00
|
|
|
|
this.expandedFolders.add(folderPath);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
await this.loadNotes();
|
2025-11-27 10:30:49 +00:00
|
|
|
|
this.goToHomepageFolder(folderPath);
|
2026-04-17 07:27:36 +00:00
|
|
|
|
return true;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
2026-04-17 07:27:36 +00:00
|
|
|
|
ErrorHandler.handle('create folder', new Error('Server returned error'));
|
|
|
|
|
|
return false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('create folder', error);
|
2026-04-17 07:27:36 +00:00
|
|
|
|
return false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-17 07:27:36 +00:00
|
|
|
|
async createFolder(parentPath = null) {
|
|
|
|
|
|
const explicitTarget = parentPath !== null && parentPath !== undefined ? parentPath : undefined;
|
|
|
|
|
|
this.openCreateNameModal('folder', explicitTarget);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
renameFolder(folderPath, currentName) {
|
|
|
|
|
|
this.renameFolderPath = folderPath;
|
|
|
|
|
|
this.renameFolderOldName = currentName;
|
|
|
|
|
|
this.renameFolderInput = currentName;
|
|
|
|
|
|
this.showRenameFolderModal = true;
|
|
|
|
|
|
this.mobileSidebarOpen = false;
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
const el = document.getElementById('rename-folder-modal-input');
|
|
|
|
|
|
if (el) {
|
|
|
|
|
|
el.focus();
|
|
|
|
|
|
el.select();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
closeRenameFolderModal() {
|
|
|
|
|
|
this.showRenameFolderModal = false;
|
|
|
|
|
|
this.renameFolderPath = '';
|
|
|
|
|
|
this.renameFolderOldName = '';
|
|
|
|
|
|
this.renameFolderInput = '';
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
cancelRenameFolderModal() {
|
|
|
|
|
|
this.closeRenameFolderModal();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-17 07:39:52 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Theme-styled confirm dialog (replaces window.confirm).
|
|
|
|
|
|
* @param {{ message: string, title?: string, danger?: boolean, confirmLabel?: string, cancelLabel?: string }} options
|
|
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
confirmModalAsk(options = {}) {
|
|
|
|
|
|
const { message, title, danger = true, confirmLabel, cancelLabel } = options;
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
this.confirmModalTitle = title || this.t('common.confirm_title');
|
|
|
|
|
|
this.confirmModalMessage = message || '';
|
|
|
|
|
|
this.confirmModalDanger = danger;
|
|
|
|
|
|
this.confirmModalConfirmLabel = confirmLabel
|
|
|
|
|
|
|| (danger ? this.t('common.delete') : this.t('common.ok'));
|
|
|
|
|
|
this.confirmModalCancelLabel = cancelLabel || this.t('common.cancel');
|
|
|
|
|
|
this._confirmModalResolve = resolve;
|
|
|
|
|
|
this.showConfirmModal = true;
|
|
|
|
|
|
this.mobileSidebarOpen = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
confirmModalConfirm() {
|
|
|
|
|
|
this._confirmModalFinish(true);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
confirmModalCancel() {
|
|
|
|
|
|
this._confirmModalFinish(false);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_confirmModalFinish(ok) {
|
|
|
|
|
|
if (!this._confirmModalResolve) return;
|
|
|
|
|
|
this.showConfirmModal = false;
|
|
|
|
|
|
const fn = this._confirmModalResolve;
|
|
|
|
|
|
this._confirmModalResolve = null;
|
|
|
|
|
|
fn(!!ok);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-17 07:27:36 +00:00
|
|
|
|
async submitRenameFolderModal() {
|
|
|
|
|
|
const folderPath = this.renameFolderPath;
|
|
|
|
|
|
const currentName = this.renameFolderOldName;
|
|
|
|
|
|
const newName = (this.renameFolderInput || '').trim();
|
|
|
|
|
|
if (!newName || newName === currentName) {
|
|
|
|
|
|
this.closeRenameFolderModal();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2025-12-17 09:03:40 +00:00
|
|
|
|
const validation = FilenameValidator.validateFilename(newName);
|
|
|
|
|
|
if (!validation.valid) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' });
|
2025-11-05 16:48:41 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 09:03:40 +00:00
|
|
|
|
const validatedName = validation.sanitized;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const pathParts = folderPath.split('/');
|
2025-12-17 09:03:40 +00:00
|
|
|
|
pathParts[pathParts.length - 1] = validatedName;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const newPath = pathParts.join('/');
|
|
|
|
|
|
|
2026-04-17 07:27:36 +00:00
|
|
|
|
const ok = await this._finalizeRenameFolder(folderPath, newPath);
|
|
|
|
|
|
if (ok) this.closeRenameFolderModal();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async _finalizeRenameFolder(folderPath, newPath) {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/folders/rename', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-04-17 07:27:36 +00:00
|
|
|
|
body: JSON.stringify({
|
2025-11-05 16:48:41 +00:00
|
|
|
|
oldPath: folderPath,
|
|
|
|
|
|
newPath: newPath
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
if (this.expandedFolders.has(folderPath)) {
|
|
|
|
|
|
this.expandedFolders.delete(folderPath);
|
|
|
|
|
|
this.expandedFolders.add(newPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
|
const folderPrefix = folderPath + '/';
|
|
|
|
|
|
const newFolderPrefix = newPath + '/';
|
|
|
|
|
|
const newFavorites = this.favorites.map(f => {
|
|
|
|
|
|
if (f.startsWith(folderPrefix)) {
|
|
|
|
|
|
return f.replace(folderPrefix, newFolderPrefix);
|
|
|
|
|
|
}
|
|
|
|
|
|
return f;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) {
|
|
|
|
|
|
this.favorites = newFavorites;
|
|
|
|
|
|
this.favoritesSet = new Set(newFavorites);
|
|
|
|
|
|
this.saveFavorites();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
|
|
|
|
|
this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await this.loadNotes();
|
2026-04-17 07:27:36 +00:00
|
|
|
|
return true;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
2026-04-17 07:27:36 +00:00
|
|
|
|
ErrorHandler.handle('rename folder', new Error('Server returned error'));
|
|
|
|
|
|
return false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('rename folder', error);
|
2026-04-17 07:27:36 +00:00
|
|
|
|
return false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Delete folder
|
|
|
|
|
|
async deleteFolder(folderPath, folderName) {
|
2026-04-17 07:39:52 +00:00
|
|
|
|
const ok = await this.confirmModalAsk({
|
|
|
|
|
|
message: this.t('folders.confirm_delete', { name: folderName }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!ok) return;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
// Remove from expanded folders
|
|
|
|
|
|
this.expandedFolders.delete(folderPath);
|
|
|
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
|
// Remove any favorites that were in the deleted folder
|
|
|
|
|
|
const folderPrefix = folderPath + '/';
|
|
|
|
|
|
const newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix));
|
|
|
|
|
|
if (newFavorites.length !== this.favorites.length) {
|
|
|
|
|
|
this.favorites = newFavorites;
|
|
|
|
|
|
this.favoritesSet = new Set(newFavorites);
|
|
|
|
|
|
this.saveFavorites();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Clear current note if it was in the deleted folder
|
2025-12-15 11:18:14 +00:00
|
|
|
|
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.currentNote = '';
|
|
|
|
|
|
this.noteContent = '';
|
2026-01-05 10:05:08 +00:00
|
|
|
|
document.title = this.appName;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ErrorHandler.handle('delete folder', new Error('Server returned error'));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('delete folder', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Auto-save with debounce
|
|
|
|
|
|
autoSave() {
|
|
|
|
|
|
if (this.saveTimeout) {
|
|
|
|
|
|
clearTimeout(this.saveTimeout);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.lastSaved = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Push to undo history (but not during undo/redo operations)
|
|
|
|
|
|
if (!this.isUndoRedo) {
|
|
|
|
|
|
this.pushToHistory();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate stats in real-time if plugin enabled
|
|
|
|
|
|
if (this.statsPluginEnabled) {
|
|
|
|
|
|
this.calculateStats();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 09:07:07 +00:00
|
|
|
|
// Parse metadata in real-time
|
|
|
|
|
|
this.parseMetadata();
|
|
|
|
|
|
|
2025-12-16 15:55:05 +00:00
|
|
|
|
// Update outline (TOC) in real-time
|
|
|
|
|
|
this.extractOutline(this.noteContent);
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.saveTimeout = setTimeout(() => {
|
2025-12-18 08:50:08 +00:00
|
|
|
|
// Commit to undo history when autosave triggers (same debounce timing)
|
|
|
|
|
|
if (this.hasPendingHistoryChanges) {
|
|
|
|
|
|
this.commitToHistory();
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.saveNote();
|
|
|
|
|
|
}, CONFIG.AUTOSAVE_DELAY);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-18 08:50:08 +00:00
|
|
|
|
// Mark that we have pending changes (called on each keystroke)
|
2025-11-05 16:48:41 +00:00
|
|
|
|
pushToHistory() {
|
2025-12-18 08:50:08 +00:00
|
|
|
|
this.hasPendingHistoryChanges = true;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Immediately commit pending changes to history (call before undo/redo)
|
|
|
|
|
|
flushHistory() {
|
|
|
|
|
|
if (this.hasPendingHistoryChanges) {
|
|
|
|
|
|
this.commitToHistory();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Actually commit to undo history (internal)
|
|
|
|
|
|
commitToHistory() {
|
2025-12-14 14:16:06 +00:00
|
|
|
|
const editor = document.getElementById('note-editor');
|
|
|
|
|
|
const cursorPos = editor ? editor.selectionStart : 0;
|
|
|
|
|
|
|
2025-12-18 08:50:08 +00:00
|
|
|
|
// Only push if content actually changed from last history entry
|
2025-11-05 16:48:41 +00:00
|
|
|
|
if (this.undoHistory.length > 0 &&
|
2025-12-14 14:16:06 +00:00
|
|
|
|
this.undoHistory[this.undoHistory.length - 1].content === this.noteContent) {
|
2025-12-18 08:50:08 +00:00
|
|
|
|
this.hasPendingHistoryChanges = false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 14:16:06 +00:00
|
|
|
|
this.undoHistory.push({ content: this.noteContent, cursorPos });
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Limit history size
|
|
|
|
|
|
if (this.undoHistory.length > this.maxHistorySize) {
|
|
|
|
|
|
this.undoHistory.shift();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clear redo history when new change is made
|
|
|
|
|
|
this.redoHistory = [];
|
2025-12-18 08:50:08 +00:00
|
|
|
|
this.hasPendingHistoryChanges = false;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Undo last change
|
|
|
|
|
|
undo() {
|
2025-12-18 08:50:08 +00:00
|
|
|
|
if (!this.currentNote) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Flush any pending history changes first (so we don't lose unsaved edits)
|
|
|
|
|
|
this.flushHistory();
|
|
|
|
|
|
|
|
|
|
|
|
if (this.undoHistory.length <= 1) return;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2025-12-14 14:16:06 +00:00
|
|
|
|
const editor = document.getElementById('note-editor');
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Pop current state to redo history
|
2025-12-14 14:16:06 +00:00
|
|
|
|
const currentState = this.undoHistory.pop();
|
|
|
|
|
|
this.redoHistory.push(currentState);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Get previous state
|
2025-12-14 14:16:06 +00:00
|
|
|
|
const previousState = this.undoHistory[this.undoHistory.length - 1];
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Apply previous state
|
|
|
|
|
|
this.isUndoRedo = true;
|
2025-12-14 14:16:06 +00:00
|
|
|
|
this.noteContent = previousState.content;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Recalculate stats with new content
|
|
|
|
|
|
if (this.statsPluginEnabled) {
|
|
|
|
|
|
this.calculateStats();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 14:16:06 +00:00
|
|
|
|
// Restore cursor position from the state we're going back to
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
this.saveNote();
|
|
|
|
|
|
this.isUndoRedo = false;
|
2025-12-14 14:16:06 +00:00
|
|
|
|
if (editor) {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const newPos = Math.min(previousState.cursorPos, this.noteContent.length);
|
|
|
|
|
|
editor.setSelectionRange(newPos, newPos);
|
|
|
|
|
|
editor.focus();
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Redo last undone change
|
|
|
|
|
|
redo() {
|
2025-12-18 08:50:08 +00:00
|
|
|
|
if (!this.currentNote) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Flush any pending history changes first
|
|
|
|
|
|
this.flushHistory();
|
|
|
|
|
|
|
|
|
|
|
|
if (this.redoHistory.length === 0) return;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2025-12-14 14:16:06 +00:00
|
|
|
|
const editor = document.getElementById('note-editor');
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Pop from redo history
|
2025-12-14 14:16:06 +00:00
|
|
|
|
const nextState = this.redoHistory.pop();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Push to undo history
|
2025-12-14 14:16:06 +00:00
|
|
|
|
this.undoHistory.push(nextState);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Apply next state
|
|
|
|
|
|
this.isUndoRedo = true;
|
2025-12-14 14:16:06 +00:00
|
|
|
|
this.noteContent = nextState.content;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Recalculate stats with new content
|
|
|
|
|
|
if (this.statsPluginEnabled) {
|
|
|
|
|
|
this.calculateStats();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 14:16:06 +00:00
|
|
|
|
// Restore cursor position from the state we're going forward to
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
this.saveNote();
|
|
|
|
|
|
this.isUndoRedo = false;
|
2025-12-14 14:16:06 +00:00
|
|
|
|
if (editor) {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const newPos = Math.min(nextState.cursorPos, this.noteContent.length);
|
|
|
|
|
|
editor.setSelectionRange(newPos, newPos);
|
|
|
|
|
|
editor.focus();
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-24 09:17:21 +00:00
|
|
|
|
// Markdown formatting helpers
|
|
|
|
|
|
wrapSelection(before, after, placeholder) {
|
|
|
|
|
|
const editor = document.getElementById('note-editor');
|
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
|
|
|
|
|
|
const start = editor.selectionStart;
|
|
|
|
|
|
const end = editor.selectionEnd;
|
|
|
|
|
|
const selectedText = this.noteContent.substring(start, end);
|
|
|
|
|
|
const textToWrap = selectedText || placeholder;
|
|
|
|
|
|
|
|
|
|
|
|
// Build the new text
|
|
|
|
|
|
const newText = before + textToWrap + after;
|
|
|
|
|
|
|
|
|
|
|
|
// Update content
|
|
|
|
|
|
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
|
|
|
|
|
|
|
|
|
|
|
|
// Set cursor position (select the wrapped text or placeholder)
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
if (selectedText) {
|
|
|
|
|
|
// If text was selected, keep it selected (inside the wrapper)
|
|
|
|
|
|
editor.setSelectionRange(start + before.length, start + before.length + selectedText.length);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// If no text selected, select the placeholder
|
|
|
|
|
|
editor.setSelectionRange(start + before.length, start + before.length + placeholder.length);
|
|
|
|
|
|
}
|
|
|
|
|
|
editor.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Trigger autosave
|
|
|
|
|
|
this.autoSave();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
insertLink() {
|
|
|
|
|
|
const editor = document.getElementById('note-editor');
|
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
|
|
|
|
|
|
const start = editor.selectionStart;
|
|
|
|
|
|
const end = editor.selectionEnd;
|
|
|
|
|
|
const selectedText = this.noteContent.substring(start, end);
|
|
|
|
|
|
|
|
|
|
|
|
// If text is selected, use it as link text; otherwise use placeholder
|
|
|
|
|
|
const linkText = selectedText || 'link text';
|
|
|
|
|
|
const linkUrl = 'url';
|
|
|
|
|
|
|
|
|
|
|
|
// Build the markdown link
|
|
|
|
|
|
const newText = `[${linkText}](${linkUrl})`;
|
|
|
|
|
|
|
|
|
|
|
|
// Update content
|
|
|
|
|
|
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
|
|
|
|
|
|
|
|
|
|
|
|
// Set cursor position to select the URL part for easy editing
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
const urlStart = start + linkText.length + 3; // After "[linkText]("
|
|
|
|
|
|
const urlEnd = urlStart + linkUrl.length;
|
|
|
|
|
|
editor.setSelectionRange(urlStart, urlEnd);
|
|
|
|
|
|
editor.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Trigger autosave
|
|
|
|
|
|
this.autoSave();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-11 17:11:25 +00:00
|
|
|
|
// Insert a markdown table placeholder
|
|
|
|
|
|
insertTable() {
|
|
|
|
|
|
const editor = document.getElementById('note-editor');
|
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
|
|
|
|
|
|
const cursorPos = editor.selectionStart;
|
|
|
|
|
|
|
|
|
|
|
|
// Basic 3x3 table placeholder
|
|
|
|
|
|
const table = `| Header 1 | Header 2 | Header 3 |
|
|
|
|
|
|
|----------|----------|----------|
|
|
|
|
|
|
| Cell 1 | Cell 2 | Cell 3 |
|
|
|
|
|
|
| Cell 4 | Cell 5 | Cell 6 |
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
// Add newline before if not at start of line
|
|
|
|
|
|
const textBefore = this.noteContent.substring(0, cursorPos);
|
|
|
|
|
|
const needsNewlineBefore = textBefore.length > 0 && !textBefore.endsWith('\n');
|
|
|
|
|
|
const prefix = needsNewlineBefore ? '\n\n' : '';
|
|
|
|
|
|
|
|
|
|
|
|
// Insert the table
|
|
|
|
|
|
this.noteContent = textBefore + prefix + table + this.noteContent.substring(cursorPos);
|
|
|
|
|
|
|
|
|
|
|
|
// Position cursor at first header for easy editing
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
const newPos = cursorPos + prefix.length + 2; // After "| "
|
|
|
|
|
|
editor.setSelectionRange(newPos, newPos + 8); // Select "Header 1"
|
|
|
|
|
|
editor.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Trigger autosave
|
|
|
|
|
|
this.autoSave();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-18 13:16:04 +00:00
|
|
|
|
// Format selected text or insert formatting at cursor
|
|
|
|
|
|
formatText(type) {
|
2025-12-18 13:23:16 +00:00
|
|
|
|
// Simple wrap cases - reuse wrapSelection()
|
|
|
|
|
|
const wrapFormats = {
|
|
|
|
|
|
'bold': ['**', '**', 'bold'],
|
|
|
|
|
|
'italic': ['*', '*', 'italic'],
|
|
|
|
|
|
'strikethrough': ['~~', '~~', 'strikethrough'],
|
|
|
|
|
|
'code': ['`', '`', 'code']
|
|
|
|
|
|
};
|
2025-12-18 13:16:04 +00:00
|
|
|
|
|
2025-12-18 13:23:16 +00:00
|
|
|
|
if (wrapFormats[type]) {
|
|
|
|
|
|
const [before, after, placeholder] = wrapFormats[type];
|
|
|
|
|
|
this.wrapSelection(before, after, placeholder);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-12-18 13:16:04 +00:00
|
|
|
|
|
2025-12-18 13:23:16 +00:00
|
|
|
|
// Special cases that need custom handling
|
2025-12-18 13:16:04 +00:00
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 'heading':
|
2025-12-18 13:23:16 +00:00
|
|
|
|
this.insertLinePrefix('## ', 'Heading');
|
2025-12-18 13:16:04 +00:00
|
|
|
|
break;
|
|
|
|
|
|
case 'quote':
|
2025-12-18 13:23:16 +00:00
|
|
|
|
this.insertLinePrefix('> ', 'quote');
|
2025-12-18 13:16:04 +00:00
|
|
|
|
break;
|
|
|
|
|
|
case 'bullet':
|
2025-12-18 13:23:16 +00:00
|
|
|
|
this.insertLinePrefix('- ', 'item');
|
2025-12-18 13:16:04 +00:00
|
|
|
|
break;
|
|
|
|
|
|
case 'numbered':
|
2025-12-18 13:23:16 +00:00
|
|
|
|
this.insertLinePrefix('1. ', 'item');
|
2025-12-18 13:16:04 +00:00
|
|
|
|
break;
|
|
|
|
|
|
case 'checkbox':
|
2025-12-18 13:23:16 +00:00
|
|
|
|
this.insertLinePrefix('- [ ] ', 'task');
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'link':
|
|
|
|
|
|
this.insertLink();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'image':
|
|
|
|
|
|
this.wrapSelection('', 'alt text');
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'codeblock':
|
|
|
|
|
|
this.wrapSelection('```\n', '\n```', 'code');
|
2025-12-18 13:16:04 +00:00
|
|
|
|
break;
|
|
|
|
|
|
case 'table':
|
|
|
|
|
|
this.insertTable();
|
2025-12-18 13:23:16 +00:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Insert a line prefix (for headings, lists, quotes)
|
|
|
|
|
|
insertLinePrefix(prefix, placeholder) {
|
|
|
|
|
|
const editor = document.getElementById('note-editor');
|
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
|
|
|
|
|
|
const start = editor.selectionStart;
|
|
|
|
|
|
const end = editor.selectionEnd;
|
|
|
|
|
|
const selectedText = this.noteContent.substring(start, end);
|
|
|
|
|
|
const beforeText = this.noteContent.substring(0, start);
|
|
|
|
|
|
const afterText = this.noteContent.substring(end);
|
|
|
|
|
|
|
|
|
|
|
|
// Check if at start of line
|
|
|
|
|
|
const atLineStart = beforeText.endsWith('\n') || beforeText === '';
|
|
|
|
|
|
const newline = atLineStart ? '' : '\n';
|
|
|
|
|
|
|
|
|
|
|
|
let replacement;
|
|
|
|
|
|
if (selectedText) {
|
|
|
|
|
|
// Prefix each line of selection
|
|
|
|
|
|
replacement = newline + selectedText.split('\n').map((line, i) => {
|
|
|
|
|
|
// For numbered lists, increment the number
|
|
|
|
|
|
if (prefix === '1. ') return `${i + 1}. ${line}`;
|
|
|
|
|
|
return prefix + line;
|
|
|
|
|
|
}).join('\n');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
replacement = newline + prefix + placeholder;
|
2025-12-18 13:16:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.noteContent = beforeText + replacement + afterText;
|
|
|
|
|
|
|
|
|
|
|
|
this.$nextTick(() => {
|
2025-12-18 13:23:16 +00:00
|
|
|
|
if (selectedText) {
|
|
|
|
|
|
editor.setSelectionRange(start + newline.length, start + replacement.length);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const placeholderStart = start + newline.length + prefix.length;
|
|
|
|
|
|
editor.setSelectionRange(placeholderStart, placeholderStart + placeholder.length);
|
|
|
|
|
|
}
|
2025-12-18 13:16:04 +00:00
|
|
|
|
editor.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.autoSave();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Save current note
|
|
|
|
|
|
async saveNote() {
|
|
|
|
|
|
if (!this.currentNote) return;
|
|
|
|
|
|
|
|
|
|
|
|
this.isSaving = true;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/notes/${this.currentNote}`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ content: this.noteContent })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.lastSaved = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Update only the modified timestamp for the current note (no full reload needed)
|
|
|
|
|
|
const note = this.notes.find(n => n.path === this.currentNote);
|
|
|
|
|
|
if (note) {
|
|
|
|
|
|
note.modified = new Date().toISOString();
|
2025-11-20 14:31:36 +00:00
|
|
|
|
note.size = new Blob([this.noteContent]).size;
|
2025-11-24 16:11:22 +00:00
|
|
|
|
|
|
|
|
|
|
// Parse tags from content
|
|
|
|
|
|
note.tags = this.parseTagsFromContent(this.noteContent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reload tags to update sidebar counts (debounced to prevent spam)
|
|
|
|
|
|
this.loadTagsDebounced();
|
|
|
|
|
|
|
|
|
|
|
|
// Rebuild folder tree if tag filters are active
|
|
|
|
|
|
if (this.selectedTags.length > 0) {
|
|
|
|
|
|
this.buildFolderTree();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Hide "saved" indicator
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.lastSaved = false;
|
|
|
|
|
|
}, CONFIG.SAVE_INDICATOR_DURATION);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ErrorHandler.handle('save note', new Error('Server returned error'));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('save note', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.isSaving = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Rename current note
|
|
|
|
|
|
async renameNote() {
|
|
|
|
|
|
if (!this.currentNote) return;
|
|
|
|
|
|
|
|
|
|
|
|
const oldPath = this.currentNote;
|
|
|
|
|
|
const newName = this.currentNoteName.trim();
|
|
|
|
|
|
|
|
|
|
|
|
if (!newName) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('notes.empty_name'), { type: 'warning' });
|
2025-11-05 16:48:41 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 09:03:40 +00:00
|
|
|
|
// Validate the new name (single segment, no path separators)
|
|
|
|
|
|
const validation = FilenameValidator.validateFilename(newName);
|
|
|
|
|
|
if (!validation.valid) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
|
2025-12-17 09:03:40 +00:00
|
|
|
|
// Reset the name in the UI
|
|
|
|
|
|
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const validatedName = validation.sanitized;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const folder = oldPath.split('/').slice(0, -1).join('/');
|
2025-12-17 09:03:40 +00:00
|
|
|
|
const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
if (oldPath === newPath) return;
|
|
|
|
|
|
|
2025-12-11 10:00:21 +00:00
|
|
|
|
// Check if a note with the new name already exists
|
|
|
|
|
|
const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
|
|
|
|
|
|
if (existingNote) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('notes.already_exists', { name: validatedName }), { type: 'warning' });
|
2025-12-11 10:00:21 +00:00
|
|
|
|
// Reset the name in the UI
|
|
|
|
|
|
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Create new note with same content
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/notes/${newPath}`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ content: this.noteContent })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
// Delete old note
|
|
|
|
|
|
await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' });
|
|
|
|
|
|
|
2025-12-15 11:18:14 +00:00
|
|
|
|
// Update favorites if the renamed note was favorited
|
|
|
|
|
|
if (this.favoritesSet.has(oldPath)) {
|
|
|
|
|
|
const newFavorites = this.favorites.map(f => f === oldPath ? newPath : f);
|
|
|
|
|
|
this.favorites = newFavorites;
|
|
|
|
|
|
this.favoritesSet = new Set(newFavorites);
|
|
|
|
|
|
this.saveFavorites();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
this.currentNote = newPath;
|
|
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ErrorHandler.handle('rename note', new Error('Server returned error'));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('rename note', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Delete current note
|
|
|
|
|
|
async deleteCurrentNote() {
|
|
|
|
|
|
if (!this.currentNote) return;
|
|
|
|
|
|
|
2025-11-20 13:40:14 +00:00
|
|
|
|
// Just call deleteNote with current note details
|
|
|
|
|
|
await this.deleteNote(this.currentNote, this.currentNoteName);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Delete any note from sidebar
|
|
|
|
|
|
async deleteNote(notePath, noteName) {
|
2026-04-17 07:39:52 +00:00
|
|
|
|
const ok = await this.confirmModalAsk({
|
|
|
|
|
|
message: this.t('notes.confirm_delete', { name: noteName }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!ok) return;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/notes/${notePath}`, {
|
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
2025-12-15 11:18:14 +00:00
|
|
|
|
// Remove from favorites if it was favorited
|
|
|
|
|
|
if (this.favoritesSet.has(notePath)) {
|
|
|
|
|
|
const newFavorites = this.favorites.filter(f => f !== notePath);
|
|
|
|
|
|
this.favorites = newFavorites;
|
|
|
|
|
|
this.favoritesSet = new Set(newFavorites);
|
|
|
|
|
|
this.saveFavorites();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// If the deleted note is currently open, clear it
|
|
|
|
|
|
if (this.currentNote === notePath) {
|
|
|
|
|
|
this.currentNote = '';
|
|
|
|
|
|
this.noteContent = '';
|
|
|
|
|
|
this.currentNoteName = '';
|
2025-12-12 17:11:29 +00:00
|
|
|
|
this._lastRenderedContent = ''; // Clear render cache
|
2026-04-13 14:11:55 +00:00
|
|
|
|
this._lastRenderedNote = '';
|
2025-12-12 17:11:29 +00:00
|
|
|
|
this._cachedRenderedHTML = '';
|
2026-01-05 10:05:08 +00:00
|
|
|
|
document.title = this.appName;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Redirect to root
|
|
|
|
|
|
window.history.replaceState({}, '', '/');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await this.loadNotes();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ErrorHandler.handle('delete note', new Error('Server returned error'));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ErrorHandler.handle('delete note', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Search notes
|
2026-02-20 07:26:52 +00:00
|
|
|
|
debouncedSearchNotes() {
|
|
|
|
|
|
if (this.searchDebounceTimeout) {
|
|
|
|
|
|
clearTimeout(this.searchDebounceTimeout);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hasTextSearch = this.searchQuery.trim().length > 0;
|
|
|
|
|
|
if (!hasTextSearch) {
|
|
|
|
|
|
this.isSearching = false;
|
|
|
|
|
|
this.searchNotes();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.isSearching = true;
|
|
|
|
|
|
this.searchResults = [];
|
|
|
|
|
|
|
|
|
|
|
|
this.searchDebounceTimeout = setTimeout(() => {
|
|
|
|
|
|
this.searchNotes();
|
|
|
|
|
|
}, CONFIG.SEARCH_DEBOUNCE_DELAY);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
|
// Search notes by text (calls unified filter logic)
|
2025-11-05 16:48:41 +00:00
|
|
|
|
async searchNotes() {
|
2025-11-24 16:11:22 +00:00
|
|
|
|
await this.applyFilters();
|
2025-11-05 16:48:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-11 13:51:55 +00:00
|
|
|
|
// Trigger MathJax typesetting after DOM update
|
|
|
|
|
|
typesetMath() {
|
|
|
|
|
|
if (typeof MathJax !== 'undefined' && MathJax.typesetPromise) {
|
|
|
|
|
|
// Use a small delay to ensure DOM is updated
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const previewContent = document.querySelector('.markdown-preview');
|
|
|
|
|
|
if (previewContent) {
|
|
|
|
|
|
MathJax.typesetPromise([previewContent]).catch((err) => {
|
|
|
|
|
|
console.error('MathJax typesetting failed:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-17 16:29:00 +00:00
|
|
|
|
// Render Mermaid diagrams
|
|
|
|
|
|
async renderMermaid() {
|
|
|
|
|
|
if (typeof window.mermaid === 'undefined') {
|
|
|
|
|
|
console.warn('Mermaid not loaded yet');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Use requestAnimationFrame for better performance than setTimeout
|
|
|
|
|
|
requestAnimationFrame(async () => {
|
|
|
|
|
|
const previewContent = document.querySelector('.markdown-preview');
|
|
|
|
|
|
if (!previewContent) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Get the appropriate theme based on current app theme
|
|
|
|
|
|
const themeType = this.getThemeType();
|
|
|
|
|
|
const mermaidTheme = themeType === 'light' ? 'default' : 'dark';
|
|
|
|
|
|
|
|
|
|
|
|
// Only reinitialize if theme changed (performance optimization)
|
|
|
|
|
|
if (this.lastMermaidTheme !== mermaidTheme) {
|
|
|
|
|
|
window.mermaid.initialize({
|
|
|
|
|
|
startOnLoad: false,
|
|
|
|
|
|
theme: mermaidTheme,
|
|
|
|
|
|
securityLevel: 'strict', // Use strict for better security
|
2025-12-06 12:06:23 +00:00
|
|
|
|
fontFamily: 'inherit',
|
|
|
|
|
|
// v11 changed useMaxWidth defaults - restore responsive behavior
|
|
|
|
|
|
flowchart: { useMaxWidth: true },
|
|
|
|
|
|
sequence: { useMaxWidth: true },
|
|
|
|
|
|
gantt: { useMaxWidth: true },
|
|
|
|
|
|
journey: { useMaxWidth: true },
|
|
|
|
|
|
timeline: { useMaxWidth: true },
|
|
|
|
|
|
class: { useMaxWidth: true },
|
|
|
|
|
|
state: { useMaxWidth: true },
|
|
|
|
|
|
er: { useMaxWidth: true },
|
|
|
|
|
|
pie: { useMaxWidth: true },
|
|
|
|
|
|
quadrantChart: { useMaxWidth: true },
|
|
|
|
|
|
requirement: { useMaxWidth: true },
|
|
|
|
|
|
mindmap: { useMaxWidth: true },
|
|
|
|
|
|
gitGraph: { useMaxWidth: true }
|
2025-11-17 16:29:00 +00:00
|
|
|
|
});
|
|
|
|
|
|
this.lastMermaidTheme = mermaidTheme;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find all code blocks with language 'mermaid'
|
|
|
|
|
|
const mermaidBlocks = previewContent.querySelectorAll('pre code.language-mermaid');
|
|
|
|
|
|
|
|
|
|
|
|
// Early return if no diagrams to render
|
|
|
|
|
|
if (mermaidBlocks.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < mermaidBlocks.length; i++) {
|
|
|
|
|
|
const block = mermaidBlocks[i];
|
|
|
|
|
|
const pre = block.parentElement;
|
|
|
|
|
|
|
|
|
|
|
|
// Skip if already rendered (performance optimization)
|
|
|
|
|
|
if (pre.querySelector('.mermaid-rendered')) continue;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const code = block.textContent;
|
|
|
|
|
|
const id = `mermaid-diagram-${Date.now()}-${i}`;
|
|
|
|
|
|
|
|
|
|
|
|
// Render the diagram
|
|
|
|
|
|
const { svg } = await window.mermaid.render(id, code);
|
|
|
|
|
|
|
|
|
|
|
|
// Create a container for the rendered diagram
|
|
|
|
|
|
const container = document.createElement('div');
|
|
|
|
|
|
container.className = 'mermaid-rendered';
|
|
|
|
|
|
container.style.cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;';
|
|
|
|
|
|
container.innerHTML = svg;
|
|
|
|
|
|
// Store original code for theme re-rendering
|
|
|
|
|
|
container.dataset.originalCode = code;
|
|
|
|
|
|
|
|
|
|
|
|
// Replace the code block with the rendered diagram
|
|
|
|
|
|
pre.parentElement.replaceChild(container, pre);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Mermaid rendering error:', error);
|
|
|
|
|
|
// Add error indicator to the code block
|
|
|
|
|
|
const errorMsg = document.createElement('div');
|
|
|
|
|
|
errorMsg.style.cssText = 'color: var(--error); padding: 10px; border-left: 3px solid var(--error); margin-top: 10px;';
|
|
|
|
|
|
errorMsg.textContent = `⚠️ Mermaid diagram error: ${error.message}`;
|
|
|
|
|
|
pre.parentElement.insertBefore(errorMsg, pre.nextSibling);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get current theme type (light or dark)
|
|
|
|
|
|
// Returns: 'light' or 'dark'
|
|
|
|
|
|
// Used by features that need to adapt to theme brightness (e.g., Mermaid diagrams, Chart.js)
|
|
|
|
|
|
getThemeType() {
|
|
|
|
|
|
// Handle system theme
|
|
|
|
|
|
if (this.currentTheme === 'system') {
|
|
|
|
|
|
const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
|
|
|
|
return isDark ? 'dark' : 'light';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Try to get theme type from loaded theme metadata
|
|
|
|
|
|
const currentThemeData = this.availableThemes.find(t => t.id === this.currentTheme);
|
|
|
|
|
|
if (currentThemeData && currentThemeData.type) {
|
|
|
|
|
|
// Use metadata from theme file (light or dark)
|
|
|
|
|
|
return currentThemeData.type; // Already 'light' or 'dark'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Backward compatibility: fallback to hardcoded map if metadata not available
|
|
|
|
|
|
const fallbackMap = {
|
|
|
|
|
|
'light': 'light',
|
|
|
|
|
|
'vs-blue': 'light'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return fallbackMap[this.currentTheme] || 'dark';
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Computed property for rendered markdown
|
|
|
|
|
|
get renderedMarkdown() {
|
|
|
|
|
|
if (!this.noteContent) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>';
|
|
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// Performance: Return cached HTML if content hasn't changed
|
2026-04-13 14:11:55 +00:00
|
|
|
|
if (this.noteContent === this._lastRenderedContent &&
|
|
|
|
|
|
this.currentNote === this._lastRenderedNote &&
|
|
|
|
|
|
this._cachedRenderedHTML) {
|
2025-12-12 17:11:29 +00:00
|
|
|
|
return this._cachedRenderedHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-24 16:11:22 +00:00
|
|
|
|
// Strip YAML frontmatter from content before rendering
|
|
|
|
|
|
let contentToRender = this.noteContent;
|
|
|
|
|
|
if (contentToRender.trim().startsWith('---')) {
|
|
|
|
|
|
const lines = contentToRender.split('\n');
|
|
|
|
|
|
if (lines[0].trim() === '---') {
|
|
|
|
|
|
// Find closing ---
|
|
|
|
|
|
let endIdx = -1;
|
|
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
|
|
|
|
if (lines[i].trim() === '---') {
|
|
|
|
|
|
endIdx = i;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (endIdx !== -1) {
|
|
|
|
|
|
// Remove frontmatter (including the closing ---) and any empty lines after it
|
|
|
|
|
|
contentToRender = lines.slice(endIdx + 1).join('\n').trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 15:00:25 +00:00
|
|
|
|
// Convert Obsidian-style wikilinks: [[note]] or [[note|display text]]
|
|
|
|
|
|
// Must be done before marked.parse() to avoid conflicts with markdown syntax
|
2026-01-03 16:24:52 +00:00
|
|
|
|
// BUT we need to protect code blocks first to avoid converting [[text]] inside code
|
2025-12-12 17:11:29 +00:00
|
|
|
|
const self = this; // Reference for closure
|
2026-01-03 16:24:52 +00:00
|
|
|
|
|
|
|
|
|
|
// Step 1: Temporarily replace code blocks and inline code with placeholders
|
|
|
|
|
|
const codeBlocks = [];
|
|
|
|
|
|
// Protect fenced code blocks (```...```)
|
|
|
|
|
|
contentToRender = contentToRender.replace(/```[\s\S]*?```/g, (match) => {
|
|
|
|
|
|
codeBlocks.push(match);
|
|
|
|
|
|
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
|
|
|
|
|
|
});
|
|
|
|
|
|
// Protect inline code (`...`)
|
|
|
|
|
|
contentToRender = contentToRender.replace(/`[^`]+`/g, (match) => {
|
|
|
|
|
|
codeBlocks.push(match);
|
|
|
|
|
|
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Step 2: Convert media wikilinks FIRST: ![[file.png]] or ![[file.png|alt text]]
|
|
|
|
|
|
// Must be before note wikilinks to prevent [[file.png]] from being matched first
|
2026-01-05 11:51:24 +00:00
|
|
|
|
contentToRender = contentToRender.replace(
|
|
|
|
|
|
/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
|
2026-01-18 15:00:01 +00:00
|
|
|
|
(match, mediaName, altText) => {
|
|
|
|
|
|
const filename = mediaName.trim();
|
2026-01-05 11:51:24 +00:00
|
|
|
|
const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, '');
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Resolve media path using O(1) lookup
|
|
|
|
|
|
const mediaPath = self.resolveMediaWikilink(filename);
|
2026-01-05 11:51:24 +00:00
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
if (mediaPath) {
|
2026-01-05 11:51:24 +00:00
|
|
|
|
// URL-encode path segments for the API
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const encodedPath = mediaPath.split('/').map(segment => {
|
2026-01-05 11:51:24 +00:00
|
|
|
|
try {
|
|
|
|
|
|
return encodeURIComponent(decodeURIComponent(segment));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return encodeURIComponent(segment);
|
|
|
|
|
|
}
|
|
|
|
|
|
}).join('/');
|
|
|
|
|
|
|
|
|
|
|
|
const safeAlt = alt.replace(/"/g, '"');
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const mediaSrc = `/api/media/${encodedPath}`;
|
|
|
|
|
|
const mediaType = self.getMediaType(filename);
|
|
|
|
|
|
|
|
|
|
|
|
// Return appropriate HTML based on media type
|
|
|
|
|
|
switch (mediaType) {
|
|
|
|
|
|
case 'audio':
|
|
|
|
|
|
return `<div class="media-embed media-audio"><audio controls preload="none" src="${mediaSrc}" title="${safeAlt}"></audio><span class="media-caption">${safeAlt}</span></div>`;
|
|
|
|
|
|
case 'video':
|
|
|
|
|
|
return `<div class="media-embed media-video"><video controls preload="none" poster="" src="${mediaSrc}" title="${safeAlt}"></video></div>`;
|
|
|
|
|
|
case 'document':
|
2026-01-18 18:04:05 +00:00
|
|
|
|
// Local PDFs: show iframe preview
|
|
|
|
|
|
return `<div class="media-embed media-pdf"><iframe src="${mediaSrc}" title="${safeAlt}"></iframe></div>`;
|
2026-01-18 15:00:01 +00:00
|
|
|
|
default: // image
|
|
|
|
|
|
return `<img src="${mediaSrc}" alt="${safeAlt}" title="${safeAlt}">`;
|
|
|
|
|
|
}
|
2026-01-05 11:51:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Media not found - return broken indicator
|
2026-01-05 11:51:24 +00:00
|
|
|
|
const safeFilename = filename.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
2026-01-18 15:00:01 +00:00
|
|
|
|
const mediaType = self.getMediaType(filename);
|
|
|
|
|
|
const icon = mediaType === 'audio' ? '🎵' : mediaType === 'video' ? '🎬' : mediaType === 'document' ? '📄' : '🖼️';
|
|
|
|
|
|
return `<span class="wikilink-broken" title="Media not found">${icon} ${safeFilename}</span>`;
|
2026-01-05 11:51:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 2b: Convert note wikilinks: [[note]] or [[note|display text]]
|
2025-12-03 15:00:25 +00:00
|
|
|
|
contentToRender = contentToRender.replace(
|
|
|
|
|
|
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
|
|
|
|
|
|
(match, target, displayText) => {
|
|
|
|
|
|
const linkTarget = target.trim();
|
|
|
|
|
|
const linkText = displayText ? displayText.trim() : linkTarget;
|
|
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// Fast O(1) check using pre-built lookup maps
|
|
|
|
|
|
// Handle section anchors: extract base note path
|
|
|
|
|
|
const hashIndex = linkTarget.indexOf('#');
|
|
|
|
|
|
const basePath = hashIndex !== -1 ? linkTarget.substring(0, hashIndex) : linkTarget;
|
|
|
|
|
|
const noteExists = basePath === '' || self.wikiLinkExists(basePath);
|
2025-12-03 15:00:25 +00:00
|
|
|
|
|
|
|
|
|
|
// Escape special chars: href needs quote escaping, text needs HTML escaping
|
|
|
|
|
|
const safeHref = linkTarget.replace(/"/g, '%22');
|
|
|
|
|
|
const safeText = linkText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
|
|
|
|
|
|
|
|
// Return link with data attribute for styling broken links
|
|
|
|
|
|
const brokenClass = noteExists ? '' : ' class="wikilink-broken"';
|
|
|
|
|
|
return `<a href="${safeHref}"${brokenClass} data-wikilink="true">${safeText}</a>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-03 16:24:52 +00:00
|
|
|
|
// Step 3: Restore code blocks
|
|
|
|
|
|
contentToRender = contentToRender.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => {
|
|
|
|
|
|
return codeBlocks[parseInt(index)];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-06 10:01:52 +00:00
|
|
|
|
// Protect LaTeX \(...\) and \[...\] delimiters from marked.js escaping
|
|
|
|
|
|
marked.use({
|
|
|
|
|
|
extensions: [{
|
|
|
|
|
|
name: 'protectLatexMath',
|
|
|
|
|
|
level: 'inline',
|
|
|
|
|
|
start(src) { return src.match(/\\[\(\[]/)?.index; },
|
|
|
|
|
|
tokenizer(src) {
|
|
|
|
|
|
// Match \(...\) or \[...\]
|
|
|
|
|
|
const match = src.match(/^(\\[\(\[])([\s\S]*?)(\\[\)\]])/);
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
type: 'html',
|
|
|
|
|
|
raw: match[0],
|
|
|
|
|
|
text: match[0]
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}]
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Configure marked with syntax highlighting
|
|
|
|
|
|
marked.setOptions({
|
|
|
|
|
|
breaks: true,
|
|
|
|
|
|
gfm: true,
|
|
|
|
|
|
highlight: function(code, lang) {
|
|
|
|
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return hljs.highlight(code, { language: lang }).value;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Highlight error:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return hljs.highlightAuto(code).value;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Parse markdown
|
2025-11-24 16:11:22 +00:00
|
|
|
|
let html = marked.parse(contentToRender);
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
2026-03-03 08:27:01 +00:00
|
|
|
|
// Sanitize HTML to prevent XSS attacks
|
|
|
|
|
|
// DOMPurify defaults allow most HTML/SVG tags but strip scripts, iframes, and event handlers
|
|
|
|
|
|
// MathJax and Mermaid run AFTER this, so their elements don't need whitelisting
|
|
|
|
|
|
html = DOMPurify.sanitize(html);
|
|
|
|
|
|
|
2025-11-23 15:59:57 +00:00
|
|
|
|
// Post-process: Add target="_blank" to external links and title attributes to images
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Parse as DOM to safely manipulate
|
|
|
|
|
|
const tempDiv = document.createElement('div');
|
|
|
|
|
|
tempDiv.innerHTML = html;
|
|
|
|
|
|
|
|
|
|
|
|
// Find all links
|
|
|
|
|
|
const links = tempDiv.querySelectorAll('a');
|
|
|
|
|
|
links.forEach(link => {
|
|
|
|
|
|
const href = link.getAttribute('href');
|
|
|
|
|
|
if (href && typeof href === 'string') {
|
|
|
|
|
|
// Check if it's an external link
|
|
|
|
|
|
const isExternal = href.indexOf('http://') === 0 ||
|
|
|
|
|
|
href.indexOf('https://') === 0 ||
|
|
|
|
|
|
href.indexOf('//') === 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (isExternal) {
|
|
|
|
|
|
link.setAttribute('target', '_blank');
|
|
|
|
|
|
link.setAttribute('rel', 'noopener noreferrer');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-11 10:39:12 +00:00
|
|
|
|
// Find all images and transform paths for display
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Also convert non-image media (audio, video, PDF) to appropriate elements
|
2025-11-23 15:59:57 +00:00
|
|
|
|
const images = tempDiv.querySelectorAll('img');
|
|
|
|
|
|
images.forEach(img => {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
let src = img.getAttribute('src');
|
2025-12-11 10:39:12 +00:00
|
|
|
|
if (src) {
|
2026-01-18 18:04:05 +00:00
|
|
|
|
const isExternal = src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//');
|
|
|
|
|
|
const isLocal = !isExternal && !src.startsWith('data:');
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// Transform relative paths to /api/media/ for serving
|
2026-01-18 18:04:05 +00:00
|
|
|
|
if (isLocal && !src.startsWith('/api/media/')) {
|
2026-04-13 14:11:55 +00:00
|
|
|
|
const vaultRelative = self.currentNote
|
|
|
|
|
|
? self.resolveMarkdownMediaPathForNote(self.currentNote, src)
|
|
|
|
|
|
: src.split('#')[0].split('?')[0];
|
|
|
|
|
|
const encodedPath = self.encodeVaultRelativePathForMediaApi(vaultRelative);
|
2026-01-18 15:00:01 +00:00
|
|
|
|
src = `/api/media/${encodedPath}`;
|
|
|
|
|
|
img.setAttribute('src', src);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if this is non-image media and convert to appropriate element
|
|
|
|
|
|
const mediaType = self.getMediaType(src);
|
|
|
|
|
|
const altText = img.getAttribute('alt') || src.split('/').pop().replace(/\.[^/.]+$/, '');
|
|
|
|
|
|
const safeAlt = altText.replace(/"/g, '"');
|
|
|
|
|
|
|
2026-01-18 18:04:05 +00:00
|
|
|
|
// Only convert LOCAL media to embedded elements (security)
|
|
|
|
|
|
// External non-image media gets styled links instead
|
|
|
|
|
|
if (isLocal || src.startsWith('/api/media/')) {
|
|
|
|
|
|
if (mediaType === 'audio') {
|
|
|
|
|
|
const wrapper = document.createElement('div');
|
|
|
|
|
|
wrapper.className = 'media-embed media-audio';
|
|
|
|
|
|
wrapper.innerHTML = `<audio controls preload="none" src="${src}" title="${safeAlt}"></audio><span class="media-caption">${safeAlt}</span>`;
|
|
|
|
|
|
img.replaceWith(wrapper);
|
|
|
|
|
|
return;
|
|
|
|
|
|
} else if (mediaType === 'video') {
|
|
|
|
|
|
const wrapper = document.createElement('div');
|
|
|
|
|
|
wrapper.className = 'media-embed media-video';
|
|
|
|
|
|
wrapper.innerHTML = `<video controls preload="none" poster="" src="${src}" title="${safeAlt}"></video>`;
|
|
|
|
|
|
img.replaceWith(wrapper);
|
|
|
|
|
|
return;
|
|
|
|
|
|
} else if (mediaType === 'document') {
|
|
|
|
|
|
// Local PDFs: show iframe preview
|
|
|
|
|
|
const wrapper = document.createElement('div');
|
|
|
|
|
|
wrapper.className = 'media-embed media-pdf';
|
|
|
|
|
|
wrapper.innerHTML = `<iframe src="${src}" title="${safeAlt}"></iframe>`;
|
|
|
|
|
|
img.replaceWith(wrapper);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (isExternal && mediaType === 'document') {
|
|
|
|
|
|
// External PDFs: styled link (opens in new tab)
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
link.href = src;
|
|
|
|
|
|
link.target = '_blank';
|
|
|
|
|
|
link.rel = 'noopener noreferrer';
|
|
|
|
|
|
link.className = 'pdf-link';
|
|
|
|
|
|
link.title = `Open ${safeAlt}`;
|
|
|
|
|
|
link.innerHTML = `<span class="pdf-link-content">📄 ${safeAlt}</span><span class="pdf-link-note">Opens in new tab</span>`;
|
|
|
|
|
|
img.replaceWith(link);
|
2026-01-18 15:00:01 +00:00
|
|
|
|
return;
|
2025-12-11 10:39:12 +00:00
|
|
|
|
}
|
2026-01-18 18:04:05 +00:00
|
|
|
|
// External audio/video: leave as broken image for security
|
2025-12-11 10:39:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 15:00:01 +00:00
|
|
|
|
// For regular images, set title attribute
|
2025-11-23 15:59:57 +00:00
|
|
|
|
const altText = img.getAttribute('alt');
|
|
|
|
|
|
if (altText) {
|
|
|
|
|
|
img.setAttribute('title', altText);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
html = tempDiv.innerHTML;
|
|
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// Debounced MathJax rendering (avoid re-running on every keystroke)
|
|
|
|
|
|
if (this._mathDebounceTimeout) clearTimeout(this._mathDebounceTimeout);
|
|
|
|
|
|
this._mathDebounceTimeout = setTimeout(() => this.typesetMath(), 300);
|
2025-11-11 13:51:55 +00:00
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// Debounced Mermaid rendering
|
|
|
|
|
|
if (this._mermaidDebounceTimeout) clearTimeout(this._mermaidDebounceTimeout);
|
|
|
|
|
|
this._mermaidDebounceTimeout = setTimeout(() => this.renderMermaid(), 300);
|
2025-11-17 16:29:00 +00:00
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Apply syntax highlighting and add copy buttons to code blocks
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
// Use cached reference if available, otherwise query
|
|
|
|
|
|
const previewEl = this._domCache.previewContent || document.querySelector('.markdown-preview');
|
|
|
|
|
|
if (previewEl) {
|
2025-11-17 16:29:00 +00:00
|
|
|
|
// Exclude code blocks that are rendered by other tools (e.g., Mermaid diagrams)
|
|
|
|
|
|
// Note: MathJax uses $$...$$ delimiters (not code blocks) so no exclusion needed
|
|
|
|
|
|
previewEl.querySelectorAll('pre code:not(.language-mermaid)').forEach((block) => {
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Apply syntax highlighting
|
|
|
|
|
|
if (!block.classList.contains('hljs')) {
|
|
|
|
|
|
hljs.highlightElement(block);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add copy button if not already present
|
|
|
|
|
|
const pre = block.parentElement;
|
|
|
|
|
|
if (pre && !pre.querySelector('.copy-code-button')) {
|
|
|
|
|
|
this.addCopyButtonToCodeBlock(pre);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-01-18 16:32:22 +00:00
|
|
|
|
|
|
|
|
|
|
// Enable video metadata loading (for first frame preview)
|
|
|
|
|
|
// Track by source URL to prevent duplicate requests on re-renders
|
|
|
|
|
|
if (!this._initializedVideoSources) this._initializedVideoSources = new Set();
|
|
|
|
|
|
previewEl.querySelectorAll('video[preload="none"]').forEach((video) => {
|
|
|
|
|
|
const src = video.getAttribute('src');
|
|
|
|
|
|
if (src && !this._initializedVideoSources.has(src)) {
|
|
|
|
|
|
this._initializedVideoSources.add(src);
|
|
|
|
|
|
video.preload = 'metadata';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
|
2025-12-12 17:11:29 +00:00
|
|
|
|
// Cache the result for performance
|
|
|
|
|
|
this._lastRenderedContent = this.noteContent;
|
2026-04-13 14:11:55 +00:00
|
|
|
|
this._lastRenderedNote = this.currentNote;
|
2025-12-12 17:11:29 +00:00
|
|
|
|
this._cachedRenderedHTML = html;
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
return html;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Refresh DOM element cache
|
|
|
|
|
|
refreshDOMCache() {
|
|
|
|
|
|
this._domCache.editor = document.querySelector('.editor-textarea');
|
|
|
|
|
|
this._domCache.previewContent = document.querySelector('.markdown-preview');
|
|
|
|
|
|
this._domCache.previewContainer = this._domCache.previewContent ? this._domCache.previewContent.parentElement : null;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Add copy button to code block
|
|
|
|
|
|
addCopyButtonToCodeBlock(preElement) {
|
2026-01-03 16:35:49 +00:00
|
|
|
|
// Extract language from code element class (e.g., "language-toml" -> "TOML")
|
|
|
|
|
|
const codeElement = preElement.querySelector('code');
|
|
|
|
|
|
let language = '';
|
|
|
|
|
|
if (codeElement && codeElement.className) {
|
|
|
|
|
|
const match = codeElement.className.match(/language-(\w+)/);
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
const langMap = {
|
|
|
|
|
|
'js': 'JavaScript', 'ts': 'TypeScript', 'py': 'Python',
|
|
|
|
|
|
'rb': 'Ruby', 'cs': 'C#', 'cpp': 'C++', 'sh': 'Shell',
|
|
|
|
|
|
'bash': 'Bash', 'zsh': 'Zsh', 'yml': 'YAML', 'md': 'Markdown'
|
|
|
|
|
|
};
|
|
|
|
|
|
const rawLang = match[1].toLowerCase();
|
|
|
|
|
|
language = langMap[rawLang] || match[1].toUpperCase();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create copy button with language label
|
2025-11-05 16:48:41 +00:00
|
|
|
|
const button = document.createElement('button');
|
|
|
|
|
|
button.className = 'copy-code-button';
|
2026-01-03 16:35:49 +00:00
|
|
|
|
const displayText = language || this.t('common.copy_to_clipboard').split(' ')[0]; // Use first word as fallback
|
|
|
|
|
|
button.innerHTML = `<span>${displayText}</span>`;
|
|
|
|
|
|
button.dataset.originalText = displayText; // Store for restore after copy
|
|
|
|
|
|
button.title = this.t('common.copy_to_clipboard');
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Style the button
|
|
|
|
|
|
button.style.position = 'absolute';
|
|
|
|
|
|
button.style.top = '8px';
|
|
|
|
|
|
button.style.right = '8px';
|
2026-01-03 16:35:49 +00:00
|
|
|
|
button.style.padding = '4px 10px';
|
2025-11-05 16:48:41 +00:00
|
|
|
|
button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
|
|
|
|
|
|
button.style.border = 'none';
|
|
|
|
|
|
button.style.borderRadius = '4px';
|
|
|
|
|
|
button.style.cursor = 'pointer';
|
|
|
|
|
|
button.style.opacity = '0';
|
|
|
|
|
|
button.style.transition = 'opacity 0.2s, background-color 0.2s';
|
|
|
|
|
|
button.style.color = 'white';
|
|
|
|
|
|
button.style.display = 'flex';
|
|
|
|
|
|
button.style.alignItems = 'center';
|
|
|
|
|
|
button.style.justifyContent = 'center';
|
|
|
|
|
|
button.style.zIndex = '10';
|
2026-01-03 16:35:49 +00:00
|
|
|
|
button.style.fontSize = '11px';
|
|
|
|
|
|
button.style.fontWeight = '600';
|
|
|
|
|
|
button.style.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
|
|
|
|
|
|
button.style.textTransform = 'uppercase';
|
|
|
|
|
|
button.style.letterSpacing = '0.5px';
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Style the pre element to be relative
|
|
|
|
|
|
preElement.style.position = 'relative';
|
|
|
|
|
|
|
|
|
|
|
|
// Show button on hover
|
|
|
|
|
|
preElement.addEventListener('mouseenter', () => {
|
|
|
|
|
|
button.style.opacity = '1';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
preElement.addEventListener('mouseleave', () => {
|
|
|
|
|
|
button.style.opacity = '0';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Copy to clipboard on click
|
|
|
|
|
|
button.addEventListener('click', async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
|
|
|
|
|
|
const codeElement = preElement.querySelector('code');
|
|
|
|
|
|
if (!codeElement) return;
|
|
|
|
|
|
|
|
|
|
|
|
const code = codeElement.textContent;
|
|
|
|
|
|
|
2026-01-03 16:35:49 +00:00
|
|
|
|
const originalText = button.dataset.originalText;
|
|
|
|
|
|
const copiedText = this.t('common.copied');
|
|
|
|
|
|
const copyTitle = this.t('common.copy_to_clipboard');
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(code);
|
|
|
|
|
|
|
2026-01-03 16:35:49 +00:00
|
|
|
|
// Visual feedback - show localized "Copied!"
|
|
|
|
|
|
button.innerHTML = `<span>${copiedText}</span>`;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
|
2026-01-03 16:35:49 +00:00
|
|
|
|
button.title = copiedText;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Reset after 2 seconds
|
|
|
|
|
|
setTimeout(() => {
|
2026-01-03 16:35:49 +00:00
|
|
|
|
button.innerHTML = `<span>${originalText}</span>`;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
|
2026-01-03 16:35:49 +00:00
|
|
|
|
button.title = copyTitle;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}, 2000);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to copy code:', err);
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback for older browsers
|
|
|
|
|
|
const textArea = document.createElement('textarea');
|
|
|
|
|
|
textArea.value = code;
|
|
|
|
|
|
textArea.style.position = 'fixed';
|
|
|
|
|
|
textArea.style.left = '-999999px';
|
|
|
|
|
|
document.body.appendChild(textArea);
|
|
|
|
|
|
textArea.select();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
document.execCommand('copy');
|
2026-01-03 16:35:49 +00:00
|
|
|
|
button.innerHTML = `<span>${copiedText}</span>`;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
|
|
|
|
|
|
setTimeout(() => {
|
2026-01-03 16:35:49 +00:00
|
|
|
|
button.innerHTML = `<span>${originalText}</span>`;
|
2025-11-05 16:48:41 +00:00
|
|
|
|
button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
} catch (fallbackErr) {
|
|
|
|
|
|
console.error('Fallback copy failed:', fallbackErr);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.body.removeChild(textArea);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Add button to pre element
|
|
|
|
|
|
preElement.appendChild(button);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Setup scroll synchronization
|
|
|
|
|
|
setupScrollSync() {
|
|
|
|
|
|
// Use cached references (refresh if not available)
|
|
|
|
|
|
if (!this._domCache.editor || !this._domCache.previewContainer) {
|
|
|
|
|
|
this.refreshDOMCache();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const editor = this._domCache.editor;
|
|
|
|
|
|
const preview = this._domCache.previewContainer;
|
|
|
|
|
|
|
|
|
|
|
|
if (!editor || !preview) {
|
|
|
|
|
|
// If elements don't exist yet, retry with limit
|
|
|
|
|
|
if (!this._setupScrollSyncRetries) this._setupScrollSyncRetries = 0;
|
|
|
|
|
|
if (this._setupScrollSyncRetries < CONFIG.SCROLL_SYNC_MAX_RETRIES) {
|
|
|
|
|
|
this._setupScrollSyncRetries++;
|
|
|
|
|
|
setTimeout(() => this.setupScrollSync(), CONFIG.SCROLL_SYNC_RETRY_INTERVAL);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.warn(`setupScrollSync: Failed to find editor/preview elements after ${CONFIG.SCROLL_SYNC_MAX_RETRIES} retries`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reset retry counter on success
|
|
|
|
|
|
this._setupScrollSyncRetries = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Remove old listeners if they exist
|
|
|
|
|
|
if (this._editorScrollHandler) {
|
|
|
|
|
|
editor.removeEventListener('scroll', this._editorScrollHandler);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this._previewScrollHandler) {
|
|
|
|
|
|
preview.removeEventListener('scroll', this._previewScrollHandler);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create new scroll handlers
|
|
|
|
|
|
this._editorScrollHandler = () => {
|
|
|
|
|
|
if (this.isScrolling) {
|
|
|
|
|
|
this.isScrolling = false;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const scrollableHeight = editor.scrollHeight - editor.clientHeight;
|
|
|
|
|
|
if (scrollableHeight <= 0) return; // No scrolling needed
|
|
|
|
|
|
|
|
|
|
|
|
const scrollPercentage = editor.scrollTop / scrollableHeight;
|
|
|
|
|
|
const previewScrollableHeight = preview.scrollHeight - preview.clientHeight;
|
|
|
|
|
|
|
|
|
|
|
|
if (previewScrollableHeight > 0) {
|
|
|
|
|
|
this.isScrolling = true;
|
|
|
|
|
|
preview.scrollTop = scrollPercentage * previewScrollableHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
this._previewScrollHandler = () => {
|
|
|
|
|
|
if (this.isScrolling) {
|
|
|
|
|
|
this.isScrolling = false;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const scrollableHeight = preview.scrollHeight - preview.clientHeight;
|
|
|
|
|
|
if (scrollableHeight <= 0) return; // No scrolling needed
|
|
|
|
|
|
|
|
|
|
|
|
const scrollPercentage = preview.scrollTop / scrollableHeight;
|
|
|
|
|
|
const editorScrollableHeight = editor.scrollHeight - editor.clientHeight;
|
|
|
|
|
|
|
|
|
|
|
|
if (editorScrollableHeight > 0) {
|
|
|
|
|
|
this.isScrolling = true;
|
|
|
|
|
|
editor.scrollTop = scrollPercentage * editorScrollableHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Attach new listeners
|
|
|
|
|
|
editor.addEventListener('scroll', this._editorScrollHandler);
|
|
|
|
|
|
preview.addEventListener('scroll', this._previewScrollHandler);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Check if stats plugin is enabled
|
|
|
|
|
|
async checkStatsPlugin() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/plugins');
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
const statsPlugin = data.plugins.find(p => p.id === 'note_stats');
|
|
|
|
|
|
this.statsPluginEnabled = statsPlugin && statsPlugin.enabled;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate stats for current note if enabled
|
|
|
|
|
|
if (this.statsPluginEnabled && this.noteContent) {
|
|
|
|
|
|
this.calculateStats();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to check stats plugin:', error);
|
|
|
|
|
|
this.statsPluginEnabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate note statistics (client-side)
|
|
|
|
|
|
calculateStats() {
|
|
|
|
|
|
if (!this.statsPluginEnabled || !this.noteContent) {
|
|
|
|
|
|
this.noteStats = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const content = this.noteContent;
|
|
|
|
|
|
|
|
|
|
|
|
// Word count
|
|
|
|
|
|
const words = (content.match(/\S+/g) || []).length;
|
|
|
|
|
|
|
|
|
|
|
|
// Character count
|
|
|
|
|
|
const chars = content.replace(/\s/g, '').length;
|
|
|
|
|
|
const totalChars = content.length;
|
|
|
|
|
|
|
|
|
|
|
|
// Reading time (200 words per minute)
|
|
|
|
|
|
const readingTime = Math.max(1, Math.round(words / 200));
|
|
|
|
|
|
|
|
|
|
|
|
// Line count
|
|
|
|
|
|
const lines = content.split('\n').length;
|
|
|
|
|
|
|
|
|
|
|
|
// Paragraph count
|
|
|
|
|
|
const paragraphs = content.split('\n\n').filter(p => p.trim()).length;
|
|
|
|
|
|
|
2025-12-01 12:51:38 +00:00
|
|
|
|
// Sentences: punctuation [.!?]+ followed by space or end-of-string
|
|
|
|
|
|
const sentences = (content.match(/[.!?]+(?:\s|$)/g) || []).length;
|
|
|
|
|
|
|
|
|
|
|
|
// List items: lines starting with -, *, + or a number (e.g. 1., 10.), excluding tasks [-]
|
|
|
|
|
|
const listItems = (content.match(/^\s*(?:[-*+]|\d+\.)\s+(?!\[)/gm) || []).length;
|
|
|
|
|
|
|
2025-12-02 11:00:39 +00:00
|
|
|
|
// Tables: markdown table separator rows (| --- | --- |)
|
|
|
|
|
|
const tables = (content.match(/^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm) || []).length;
|
2025-12-01 12:51:38 +00:00
|
|
|
|
|
2025-12-03 15:00:25 +00:00
|
|
|
|
// Link count (standard markdown links)
|
|
|
|
|
|
const markdownLinkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || [];
|
|
|
|
|
|
const markdownLinks = markdownLinkMatches.length;
|
|
|
|
|
|
const markdownInternalLinks = markdownLinkMatches.filter(l => l.includes('.md')).length;
|
|
|
|
|
|
|
|
|
|
|
|
// Wikilink count ([[note]] or [[note|display text]] format)
|
|
|
|
|
|
const wikilinks = (content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g) || []).length;
|
|
|
|
|
|
|
|
|
|
|
|
// Total links (markdown + wikilinks)
|
|
|
|
|
|
const links = markdownLinks + wikilinks;
|
|
|
|
|
|
const internalLinks = markdownInternalLinks + wikilinks; // All wikilinks are internal
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Code blocks
|
|
|
|
|
|
const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length;
|
|
|
|
|
|
const inlineCode = (content.match(/`[^`]+`/g) || []).length;
|
|
|
|
|
|
|
|
|
|
|
|
// Headings
|
|
|
|
|
|
const h1 = (content.match(/^# /gm) || []).length;
|
|
|
|
|
|
const h2 = (content.match(/^## /gm) || []).length;
|
|
|
|
|
|
const h3 = (content.match(/^### /gm) || []).length;
|
|
|
|
|
|
|
|
|
|
|
|
// Tasks
|
|
|
|
|
|
const totalTasks = (content.match(/- \[[ x]\]/gi) || []).length;
|
|
|
|
|
|
const completedTasks = (content.match(/- \[x\]/gi) || []).length;
|
|
|
|
|
|
const pendingTasks = totalTasks - completedTasks;
|
|
|
|
|
|
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Images
|
|
|
|
|
|
const images = (content.match(/!\[([^\]]*)\]\(([^\)]+)\)/g) || []).length;
|
|
|
|
|
|
|
|
|
|
|
|
// Blockquotes
|
|
|
|
|
|
const blockquotes = (content.match(/^> /gm) || []).length;
|
|
|
|
|
|
|
|
|
|
|
|
this.noteStats = {
|
|
|
|
|
|
words,
|
2025-12-01 12:51:38 +00:00
|
|
|
|
sentences,
|
2025-11-05 16:48:41 +00:00
|
|
|
|
characters: chars,
|
|
|
|
|
|
total_characters: totalChars,
|
|
|
|
|
|
reading_time_minutes: readingTime,
|
|
|
|
|
|
lines,
|
|
|
|
|
|
paragraphs,
|
2025-12-01 12:51:38 +00:00
|
|
|
|
list_items: listItems,
|
|
|
|
|
|
tables,
|
2025-11-05 16:48:41 +00:00
|
|
|
|
links,
|
|
|
|
|
|
internal_links: internalLinks,
|
|
|
|
|
|
external_links: links - internalLinks,
|
2025-12-03 15:00:25 +00:00
|
|
|
|
wikilinks,
|
2025-11-05 16:48:41 +00:00
|
|
|
|
code_blocks: codeBlocks,
|
|
|
|
|
|
inline_code: inlineCode,
|
|
|
|
|
|
headings: {
|
|
|
|
|
|
h1,
|
|
|
|
|
|
h2,
|
|
|
|
|
|
h3,
|
|
|
|
|
|
total: h1 + h2 + h3
|
|
|
|
|
|
},
|
|
|
|
|
|
tasks: {
|
|
|
|
|
|
total: totalTasks,
|
|
|
|
|
|
completed: completedTasks,
|
|
|
|
|
|
pending: pendingTasks,
|
|
|
|
|
|
completion_rate: completionRate
|
|
|
|
|
|
},
|
|
|
|
|
|
images,
|
|
|
|
|
|
blockquotes
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-04 09:07:07 +00:00
|
|
|
|
// Parse YAML frontmatter metadata from note content
|
|
|
|
|
|
parseMetadata() {
|
|
|
|
|
|
if (!this.noteContent) {
|
|
|
|
|
|
this.noteMetadata = null;
|
|
|
|
|
|
this._lastFrontmatter = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const content = this.noteContent;
|
|
|
|
|
|
|
|
|
|
|
|
// Check if content starts with frontmatter
|
|
|
|
|
|
if (!content.trim().startsWith('---')) {
|
|
|
|
|
|
this.noteMetadata = null;
|
|
|
|
|
|
this._lastFrontmatter = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const lines = content.split('\n');
|
|
|
|
|
|
if (lines[0].trim() !== '---') {
|
|
|
|
|
|
this.noteMetadata = null;
|
|
|
|
|
|
this._lastFrontmatter = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find closing ---
|
|
|
|
|
|
let endIdx = -1;
|
|
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
|
|
|
|
if (lines[i].trim() === '---') {
|
|
|
|
|
|
endIdx = i;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (endIdx === -1) {
|
|
|
|
|
|
this.noteMetadata = null;
|
|
|
|
|
|
this._lastFrontmatter = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Performance optimization: skip parsing if frontmatter unchanged
|
|
|
|
|
|
const frontmatterRaw = lines.slice(0, endIdx + 1).join('\n');
|
|
|
|
|
|
if (frontmatterRaw === this._lastFrontmatter) {
|
|
|
|
|
|
return; // No change, keep existing metadata
|
|
|
|
|
|
}
|
|
|
|
|
|
this._lastFrontmatter = frontmatterRaw;
|
|
|
|
|
|
|
|
|
|
|
|
const frontmatterLines = lines.slice(1, endIdx);
|
|
|
|
|
|
const metadata = {};
|
|
|
|
|
|
let currentKey = null;
|
|
|
|
|
|
let currentValue = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const line of frontmatterLines) {
|
|
|
|
|
|
// Check for new key: value pair (supports keys with hyphens/underscores)
|
|
|
|
|
|
const keyMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
|
|
|
|
|
|
|
|
|
|
|
|
if (keyMatch) {
|
|
|
|
|
|
// Save previous key if exists
|
|
|
|
|
|
if (currentKey) {
|
|
|
|
|
|
metadata[currentKey] = this.parseYamlValue(currentValue.join('\n'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentKey = keyMatch[1];
|
|
|
|
|
|
const value = keyMatch[2].trim();
|
|
|
|
|
|
currentValue = [value];
|
|
|
|
|
|
} else if (line.match(/^\s+-\s+/) && currentKey) {
|
|
|
|
|
|
// List item continuation (e.g., " - item")
|
|
|
|
|
|
currentValue.push(line);
|
|
|
|
|
|
} else if (line.startsWith(' ') && currentKey) {
|
|
|
|
|
|
// Indented content (multiline value)
|
|
|
|
|
|
currentValue.push(line);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Save last key
|
|
|
|
|
|
if (currentKey) {
|
|
|
|
|
|
metadata[currentKey] = this.parseYamlValue(currentValue.join('\n'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.noteMetadata = Object.keys(metadata).length > 0 ? metadata : null;
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to parse frontmatter:', error);
|
|
|
|
|
|
this.noteMetadata = null;
|
|
|
|
|
|
this._lastFrontmatter = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Parse a YAML value (handles arrays, strings, numbers, booleans)
|
|
|
|
|
|
parseYamlValue(value) {
|
|
|
|
|
|
if (!value || value.trim() === '') return null;
|
|
|
|
|
|
|
|
|
|
|
|
value = value.trim();
|
|
|
|
|
|
|
|
|
|
|
|
// Check for inline array: [item1, item2]
|
|
|
|
|
|
if (value.startsWith('[') && value.endsWith(']')) {
|
|
|
|
|
|
const inner = value.slice(1, -1);
|
|
|
|
|
|
return inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(s => s);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for YAML list format (multiple lines starting with -)
|
|
|
|
|
|
if (value.includes('\n -') || value.startsWith(' -')) {
|
|
|
|
|
|
const items = [];
|
|
|
|
|
|
const lines = value.split('\n');
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
const match = line.match(/^\s*-\s*(.+)$/);
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
items.push(match[1].trim().replace(/^["']|["']$/g, ''));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return items.length > 0 ? items : value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for boolean
|
|
|
|
|
|
if (value.toLowerCase() === 'true') return true;
|
|
|
|
|
|
if (value.toLowerCase() === 'false') return false;
|
|
|
|
|
|
|
|
|
|
|
|
// Check for number
|
|
|
|
|
|
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
|
|
|
|
return parseFloat(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Return as string (remove quotes if present)
|
|
|
|
|
|
return value.replace(/^["']|["']$/g, '');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Check if a string is a URL
|
|
|
|
|
|
isUrl(str) {
|
|
|
|
|
|
if (typeof str !== 'string') return false;
|
|
|
|
|
|
return /^https?:\/\/\S+$/i.test(str.trim());
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Escape HTML to prevent XSS
|
|
|
|
|
|
escapeHtml(str) {
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.textContent = str;
|
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Format metadata value for display
|
|
|
|
|
|
formatMetadataValue(key, value) {
|
|
|
|
|
|
if (value === null || value === undefined) return '';
|
|
|
|
|
|
|
|
|
|
|
|
// Arrays are handled separately in the template
|
|
|
|
|
|
if (Array.isArray(value)) return value;
|
|
|
|
|
|
|
|
|
|
|
|
// Format dates nicely
|
|
|
|
|
|
if (key === 'date' || key === 'created' || key === 'modified' || key === 'updated') {
|
2025-12-11 07:28:28 +00:00
|
|
|
|
let date;
|
|
|
|
|
|
// Parse date-only strings (YYYY-MM-DD) as local dates to avoid timezone issues
|
|
|
|
|
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
|
|
|
|
const [year, month, day] = value.split('-').map(Number);
|
|
|
|
|
|
date = new Date(year, month - 1, day); // month is 0-indexed
|
|
|
|
|
|
} else {
|
|
|
|
|
|
date = new Date(value);
|
|
|
|
|
|
}
|
2025-12-04 09:07:07 +00:00
|
|
|
|
if (!isNaN(date.getTime())) {
|
2025-12-14 17:02:57 +00:00
|
|
|
|
return date.toLocaleDateString(this.currentLocale, {
|
2025-12-04 09:07:07 +00:00
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: 'short',
|
|
|
|
|
|
day: 'numeric'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Booleans
|
|
|
|
|
|
if (typeof value === 'boolean') {
|
2025-12-14 17:02:57 +00:00
|
|
|
|
return value ? this.t('common.yes') : this.t('common.no');
|
2025-12-04 09:07:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return String(value);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Format metadata value as HTML (for URL support)
|
|
|
|
|
|
formatMetadataValueHtml(key, value) {
|
|
|
|
|
|
const formatted = this.formatMetadataValue(key, value);
|
|
|
|
|
|
|
|
|
|
|
|
// Check if it's a URL
|
|
|
|
|
|
if (this.isUrl(formatted)) {
|
|
|
|
|
|
const escaped = this.escapeHtml(formatted);
|
|
|
|
|
|
// Truncate long URLs for display
|
|
|
|
|
|
const displayUrl = formatted.length > 40
|
|
|
|
|
|
? formatted.substring(0, 37) + '...'
|
|
|
|
|
|
: formatted;
|
|
|
|
|
|
return `<a href="${escaped}" target="_blank" rel="noopener noreferrer" class="metadata-link">${this.escapeHtml(displayUrl)}</a>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return this.escapeHtml(formatted);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get priority metadata fields (shown in collapsed view)
|
|
|
|
|
|
getPriorityMetadataFields() {
|
|
|
|
|
|
if (!this.noteMetadata) return [];
|
|
|
|
|
|
|
|
|
|
|
|
// Fields to show in collapsed view, in order of priority
|
|
|
|
|
|
const priority = ['date', 'created', 'author', 'status', 'priority', 'type', 'category'];
|
|
|
|
|
|
const fields = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const key of priority) {
|
|
|
|
|
|
if (this.noteMetadata[key] !== undefined && !Array.isArray(this.noteMetadata[key])) {
|
|
|
|
|
|
const formatted = this.formatMetadataValue(key, this.noteMetadata[key]);
|
|
|
|
|
|
const isUrl = this.isUrl(formatted);
|
|
|
|
|
|
fields.push({
|
|
|
|
|
|
key,
|
|
|
|
|
|
value: formatted,
|
|
|
|
|
|
valueHtml: isUrl ? this.formatMetadataValueHtml(key, this.noteMetadata[key]) : this.escapeHtml(formatted),
|
|
|
|
|
|
isUrl
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return fields.slice(0, 3); // Show max 3 fields in collapsed view
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get all metadata fields except tags (for expanded view)
|
|
|
|
|
|
getAllMetadataFields() {
|
|
|
|
|
|
if (!this.noteMetadata) return [];
|
|
|
|
|
|
|
|
|
|
|
|
return Object.entries(this.noteMetadata)
|
|
|
|
|
|
.filter(([key]) => key !== 'tags') // Tags shown separately
|
|
|
|
|
|
.map(([key, value]) => {
|
|
|
|
|
|
const isArray = Array.isArray(value);
|
|
|
|
|
|
const formatted = this.formatMetadataValue(key, value);
|
|
|
|
|
|
const isUrl = !isArray && this.isUrl(formatted);
|
|
|
|
|
|
return {
|
|
|
|
|
|
key,
|
|
|
|
|
|
value: formatted,
|
|
|
|
|
|
valueHtml: isUrl ? this.formatMetadataValueHtml(key, value) : this.escapeHtml(formatted),
|
|
|
|
|
|
isArray,
|
|
|
|
|
|
isUrl
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Check if note has any displayable metadata
|
|
|
|
|
|
getHasMetadata() {
|
|
|
|
|
|
const has = this.noteMetadata && Object.keys(this.noteMetadata).length > 0;
|
|
|
|
|
|
return has;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Get tags from metadata
|
|
|
|
|
|
getMetadataTags() {
|
|
|
|
|
|
if (!this.noteMetadata || !this.noteMetadata.tags) return [];
|
|
|
|
|
|
return Array.isArray(this.noteMetadata.tags) ? this.noteMetadata.tags : [this.noteMetadata.tags];
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Save sidebar width to localStorage
|
|
|
|
|
|
saveSidebarWidth() {
|
|
|
|
|
|
localStorage.setItem('sidebarWidth', this.sidebarWidth.toString());
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Save view mode to localStorage
|
|
|
|
|
|
saveViewMode() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem('viewMode', this.viewMode);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error saving view mode:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2025-11-24 16:11:22 +00:00
|
|
|
|
|
|
|
|
|
|
saveTagsExpanded() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem('tagsExpanded', this.tagsExpanded.toString());
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error saving tags expanded state:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2025-11-05 16:48:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Start resizing sidebar
|
|
|
|
|
|
startResize(event) {
|
|
|
|
|
|
this.isResizing = true;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const resize = (e) => {
|
|
|
|
|
|
if (!this.isResizing) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate new width based on mouse position
|
|
|
|
|
|
const newWidth = e.clientX;
|
|
|
|
|
|
|
|
|
|
|
|
// Clamp between min and max
|
|
|
|
|
|
if (newWidth >= 200 && newWidth <= 600) {
|
|
|
|
|
|
this.sidebarWidth = newWidth;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const stopResize = () => {
|
|
|
|
|
|
if (this.isResizing) {
|
|
|
|
|
|
this.isResizing = false;
|
|
|
|
|
|
this.saveSidebarWidth();
|
|
|
|
|
|
document.removeEventListener('mousemove', resize);
|
|
|
|
|
|
document.removeEventListener('mouseup', stopResize);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', resize);
|
|
|
|
|
|
document.addEventListener('mouseup', stopResize);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-09 09:17:09 +00:00
|
|
|
|
// Start resizing split panes (editor/preview)
|
|
|
|
|
|
startSplitResize(event) {
|
|
|
|
|
|
this.isResizingSplit = true;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const container = event.target.parentElement;
|
|
|
|
|
|
|
|
|
|
|
|
const resize = (e) => {
|
|
|
|
|
|
if (!this.isResizingSplit) return;
|
|
|
|
|
|
|
|
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
|
|
|
|
|
const mouseX = e.clientX - containerRect.left;
|
|
|
|
|
|
const percentage = (mouseX / containerRect.width) * 100;
|
|
|
|
|
|
|
|
|
|
|
|
// Clamp between 20% and 80%
|
|
|
|
|
|
if (percentage >= 20 && percentage <= 80) {
|
|
|
|
|
|
this.editorWidth = percentage;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const stopResize = () => {
|
|
|
|
|
|
if (this.isResizingSplit) {
|
|
|
|
|
|
this.isResizingSplit = false;
|
|
|
|
|
|
this.saveEditorWidth();
|
|
|
|
|
|
document.removeEventListener('mousemove', resize);
|
|
|
|
|
|
document.removeEventListener('mouseup', stopResize);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', resize);
|
|
|
|
|
|
document.addEventListener('mouseup', stopResize);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-13 14:09:26 +00:00
|
|
|
|
// Setup mobile view mode handler (auto-switch from split to edit on mobile)
|
|
|
|
|
|
setupMobileViewMode() {
|
|
|
|
|
|
const MOBILE_BREAKPOINT = 768; // Match CSS breakpoint
|
|
|
|
|
|
let previousWidth = window.innerWidth;
|
|
|
|
|
|
|
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
|
const currentWidth = window.innerWidth;
|
|
|
|
|
|
const wasMobile = previousWidth <= MOBILE_BREAKPOINT;
|
|
|
|
|
|
const isMobile = currentWidth <= MOBILE_BREAKPOINT;
|
|
|
|
|
|
|
|
|
|
|
|
// If switching from desktop to mobile and in split mode
|
|
|
|
|
|
if (!wasMobile && isMobile && this.viewMode === 'split') {
|
|
|
|
|
|
this.viewMode = 'edit';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
previousWidth = currentWidth;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Listen for window resize
|
|
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
|
|
|
|
|
|
|
|
// Check initial state
|
|
|
|
|
|
if (window.innerWidth <= MOBILE_BREAKPOINT && this.viewMode === 'split') {
|
|
|
|
|
|
this.viewMode = 'edit';
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-09 09:17:09 +00:00
|
|
|
|
// Save editor width to localStorage
|
|
|
|
|
|
saveEditorWidth() {
|
|
|
|
|
|
localStorage.setItem('editorWidth', this.editorWidth.toString());
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-05 16:48:41 +00:00
|
|
|
|
// Scroll to top of editor and preview
|
|
|
|
|
|
scrollToTop() {
|
|
|
|
|
|
// Disable scroll sync temporarily to prevent interference
|
|
|
|
|
|
this.isScrolling = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Use cached references (refresh if not available)
|
|
|
|
|
|
if (!this._domCache.editor || !this._domCache.previewContainer) {
|
|
|
|
|
|
this.refreshDOMCache();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Only scroll the visible panes based on viewMode
|
|
|
|
|
|
if (this.viewMode === 'edit' || this.viewMode === 'split') {
|
|
|
|
|
|
if (this._domCache.editor) {
|
|
|
|
|
|
this._domCache.editor.scrollTop = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.viewMode === 'preview' || this.viewMode === 'split') {
|
|
|
|
|
|
// Scroll the preview container (parent of .markdown-preview)
|
|
|
|
|
|
if (this._domCache.previewContainer) {
|
|
|
|
|
|
this._domCache.previewContainer.scrollTop = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Re-enable scroll sync after a short delay
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.isScrolling = false;
|
|
|
|
|
|
}, CONFIG.SCROLL_SYNC_DELAY);
|
2025-11-12 10:16:12 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-06 10:01:52 +00:00
|
|
|
|
// Export current note as HTML via backend API
|
2025-11-12 10:16:12 +00:00
|
|
|
|
async exportToHTML() {
|
|
|
|
|
|
if (!this.currentNote || !this.noteContent) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('notes.no_content'), { type: 'info' });
|
2025-11-12 10:16:12 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-06 10:01:52 +00:00
|
|
|
|
// Build API URL with current theme
|
2025-11-12 10:16:12 +00:00
|
|
|
|
const currentTheme = this.currentTheme || 'light';
|
2026-04-06 10:01:52 +00:00
|
|
|
|
const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
|
|
|
|
|
|
const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}`;
|
2025-11-12 10:16:12 +00:00
|
|
|
|
|
2026-04-06 10:01:52 +00:00
|
|
|
|
// Fetch the exported HTML from backend
|
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json().catch(() => ({ detail: 'Export failed' }));
|
|
|
|
|
|
throw new Error(error.detail || 'Export failed');
|
2025-11-12 10:16:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-06 10:01:52 +00:00
|
|
|
|
// Get filename from Content-Disposition header or use note name
|
|
|
|
|
|
let filename = (this.currentNoteName || 'note') + '.html';
|
|
|
|
|
|
const contentDisposition = response.headers.get('Content-Disposition');
|
|
|
|
|
|
if (contentDisposition) {
|
|
|
|
|
|
const match = contentDisposition.match(/filename="([^"]+)"/);
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
filename = match[1];
|
2025-11-12 10:16:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-06 10:01:52 +00:00
|
|
|
|
// Download as blob
|
|
|
|
|
|
const blob = await response.blob();
|
|
|
|
|
|
const blobUrl = URL.createObjectURL(blob);
|
2025-11-12 10:16:12 +00:00
|
|
|
|
const a = document.createElement('a');
|
2026-04-06 10:01:52 +00:00
|
|
|
|
a.href = blobUrl;
|
|
|
|
|
|
a.download = filename;
|
2025-11-12 10:16:12 +00:00
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
|
|
|
|
|
|
// Cleanup
|
2026-04-06 10:01:52 +00:00
|
|
|
|
URL.revokeObjectURL(blobUrl);
|
2025-11-12 10:16:12 +00:00
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('HTML export failed:', error);
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('export.failed', { error: error.message }), { type: 'error' });
|
2025-11-12 10:16:12 +00:00
|
|
|
|
}
|
2025-11-20 03:01:27 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-06 10:58:38 +00:00
|
|
|
|
// Open print preview in new window
|
|
|
|
|
|
printPreview() {
|
|
|
|
|
|
if (!this.currentNote || !this.noteContent) {
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('notes.no_content'), { type: 'info' });
|
2026-04-06 10:58:38 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build API URL with current theme and download=false for inline display
|
|
|
|
|
|
const currentTheme = this.currentTheme || 'light';
|
|
|
|
|
|
const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
|
|
|
|
|
|
const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}&download=false`;
|
|
|
|
|
|
|
|
|
|
|
|
// Open in new window/tab
|
|
|
|
|
|
window.open(url, '_blank');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-12 15:32:29 +00:00
|
|
|
|
// Copy current note link to clipboard
|
|
|
|
|
|
async copyNoteLink() {
|
2025-12-12 15:42:14 +00:00
|
|
|
|
if (!this.currentNote) return;
|
2025-12-12 15:32:29 +00:00
|
|
|
|
|
|
|
|
|
|
// Build the full URL
|
|
|
|
|
|
const pathWithoutExtension = this.currentNote.replace('.md', '');
|
|
|
|
|
|
const encodedPath = pathWithoutExtension.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
|
|
|
|
const url = `${window.location.origin}/${encodedPath}`;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(url);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// Fallback for older browsers
|
|
|
|
|
|
const textArea = document.createElement('textarea');
|
|
|
|
|
|
textArea.value = url;
|
|
|
|
|
|
document.body.appendChild(textArea);
|
|
|
|
|
|
textArea.select();
|
|
|
|
|
|
document.execCommand('copy');
|
|
|
|
|
|
document.body.removeChild(textArea);
|
|
|
|
|
|
}
|
2025-12-12 15:42:14 +00:00
|
|
|
|
|
|
|
|
|
|
// Show brief "Copied!" feedback
|
|
|
|
|
|
this.linkCopied = true;
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.linkCopied = false;
|
|
|
|
|
|
}, 1500);
|
2025-12-12 15:32:29 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-15 09:07:51 +00:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// Share Functions
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// Load list of shared note paths (for visual indicators)
|
|
|
|
|
|
async loadSharedNotePaths() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/shared-notes');
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
this._sharedNotePaths = new Set(data.paths || []);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load shared note paths:', error);
|
|
|
|
|
|
this._sharedNotePaths = new Set();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Check if a note is currently shared (O(1) lookup)
|
|
|
|
|
|
isNoteShared(notePath) {
|
|
|
|
|
|
return this._sharedNotePaths.has(notePath);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-15 15:32:35 +00:00
|
|
|
|
// ============================================
|
|
|
|
|
|
// Quick Switcher (Ctrl+Alt+P)
|
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
|
|
openQuickSwitcher() {
|
|
|
|
|
|
this.showQuickSwitcher = true;
|
|
|
|
|
|
this.quickSwitcherQuery = '';
|
|
|
|
|
|
this.quickSwitcherIndex = 0;
|
|
|
|
|
|
// Populate initial results
|
|
|
|
|
|
this.quickSwitcherResults = (this.allNotes || []).slice(0, 10);
|
|
|
|
|
|
// Focus the input after the modal renders
|
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
const input = document.getElementById('quickSwitcherInput');
|
|
|
|
|
|
if (input) input.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
closeQuickSwitcher() {
|
|
|
|
|
|
this.showQuickSwitcher = false;
|
|
|
|
|
|
this.quickSwitcherQuery = '';
|
|
|
|
|
|
this.quickSwitcherIndex = 0;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Filter notes for quick switcher based on query
|
|
|
|
|
|
filterQuickSwitcher(query) {
|
|
|
|
|
|
// Only include actual notes, not images
|
|
|
|
|
|
const notes = (this.notes || []).filter(n => n.type === 'note');
|
|
|
|
|
|
if (!query || !query.trim()) {
|
|
|
|
|
|
// Show recent notes when no query
|
|
|
|
|
|
return notes.slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
const q = query.toLowerCase();
|
|
|
|
|
|
return notes
|
|
|
|
|
|
.filter(n =>
|
|
|
|
|
|
n.name.toLowerCase().includes(q) ||
|
|
|
|
|
|
n.path.toLowerCase().includes(q)
|
|
|
|
|
|
)
|
|
|
|
|
|
.slice(0, 10);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Handle keyboard navigation in quick switcher
|
|
|
|
|
|
handleQuickSwitcherKeydown(e) {
|
|
|
|
|
|
const results = this.quickSwitcherResults;
|
|
|
|
|
|
|
|
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.quickSwitcherIndex = Math.min(this.quickSwitcherIndex + 1, results.length - 1);
|
|
|
|
|
|
this.scrollQuickSwitcherIntoView();
|
|
|
|
|
|
} else if (e.key === 'ArrowUp') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.quickSwitcherIndex = Math.max(this.quickSwitcherIndex - 1, 0);
|
|
|
|
|
|
this.scrollQuickSwitcherIntoView();
|
|
|
|
|
|
} else if (e.key === 'Enter') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const note = results[this.quickSwitcherIndex];
|
|
|
|
|
|
if (note) {
|
|
|
|
|
|
this.loadNote(note.path);
|
|
|
|
|
|
this.closeQuickSwitcher();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.closeQuickSwitcher();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll selected item into view in quick switcher
|
|
|
|
|
|
scrollQuickSwitcherIntoView() {
|
2026-01-15 16:15:44 +00:00
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
|
const items = document.querySelectorAll('[data-quick-switcher-item]');
|
|
|
|
|
|
if (items[this.quickSwitcherIndex]) {
|
|
|
|
|
|
items[this.quickSwitcherIndex].scrollIntoView({ block: 'nearest' });
|
2026-01-15 15:32:35 +00:00
|
|
|
|
}
|
2026-01-15 16:15:44 +00:00
|
|
|
|
});
|
2026-01-15 15:32:35 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Select note from quick switcher by click
|
|
|
|
|
|
selectQuickSwitcherNote(note) {
|
|
|
|
|
|
this.loadNote(note.path);
|
|
|
|
|
|
this.closeQuickSwitcher();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-09 17:55:55 +00:00
|
|
|
|
// Close share modal and reset state after animation
|
|
|
|
|
|
closeShareModal() {
|
|
|
|
|
|
this.showShareModal = false;
|
|
|
|
|
|
// Delay state reset until modal is fully hidden
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.showShareQR = false;
|
|
|
|
|
|
this.shareInfo = null;
|
|
|
|
|
|
this.shareLoading = false;
|
|
|
|
|
|
}, 200);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Generate QR code for share URL
|
|
|
|
|
|
generateQRCode(url) {
|
|
|
|
|
|
if (!url || typeof qrcode === 'undefined') return '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const qr = qrcode(0, 'M'); // 0 = auto version, M = medium error correction
|
|
|
|
|
|
qr.addData(url);
|
|
|
|
|
|
qr.make();
|
|
|
|
|
|
return qr.createDataURL(4); // 4 = module size in pixels
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('QR code generation failed:', e);
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-15 09:07:51 +00:00
|
|
|
|
// Open share modal and fetch current share status
|
|
|
|
|
|
async openShareModal() {
|
|
|
|
|
|
if (!this.currentNote) return;
|
|
|
|
|
|
|
2026-02-09 17:55:55 +00:00
|
|
|
|
// Reset state BEFORE showing modal to prevent flicker
|
|
|
|
|
|
this.showShareQR = false;
|
2026-01-15 09:07:51 +00:00
|
|
|
|
this.shareInfo = null;
|
2026-02-09 17:55:55 +00:00
|
|
|
|
this.shareLoading = true;
|
|
|
|
|
|
this.showShareModal = true;
|
2026-01-15 09:07:51 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const notePath = this.currentNote.replace('.md', '');
|
|
|
|
|
|
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
|
|
|
|
const response = await fetch(`/api/share/${encodedPath}`);
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.shareInfo = await response.json();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.shareInfo = { shared: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to get share status:', error);
|
|
|
|
|
|
this.shareInfo = { shared: false };
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.shareLoading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Create a share link for the current note (with current theme)
|
|
|
|
|
|
async createShareLink() {
|
|
|
|
|
|
if (!this.currentNote) return;
|
|
|
|
|
|
|
|
|
|
|
|
this.shareLoading = true;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const notePath = this.currentNote.replace('.md', '');
|
|
|
|
|
|
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
|
|
|
|
const response = await fetch(`/api/share/${encodedPath}`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ theme: this.currentTheme || 'light' })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.shareInfo = await response.json();
|
|
|
|
|
|
this.shareInfo.shared = true;
|
|
|
|
|
|
// Update the shared paths set
|
|
|
|
|
|
this._sharedNotePaths.add(this.currentNote);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const error = await response.json();
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('share.error_creating', { error: error.detail || 'Unknown error' }), { type: 'error' });
|
2026-01-15 09:07:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to create share link:', error);
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('share.error_creating', { error: error.message }), { type: 'error' });
|
2026-01-15 09:07:51 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
this.shareLoading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Copy share link to clipboard
|
|
|
|
|
|
async copyShareLink() {
|
|
|
|
|
|
if (!this.shareInfo?.url) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(this.shareInfo.url);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// Fallback for older browsers
|
|
|
|
|
|
const textArea = document.createElement('textarea');
|
|
|
|
|
|
textArea.value = this.shareInfo.url;
|
|
|
|
|
|
document.body.appendChild(textArea);
|
|
|
|
|
|
textArea.select();
|
|
|
|
|
|
document.execCommand('copy');
|
|
|
|
|
|
document.body.removeChild(textArea);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.shareLinkCopied = true;
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.shareLinkCopied = false;
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Revoke share link
|
|
|
|
|
|
async revokeShareLink() {
|
|
|
|
|
|
if (!this.currentNote) return;
|
|
|
|
|
|
|
2026-04-17 07:39:52 +00:00
|
|
|
|
const ok = await this.confirmModalAsk({
|
|
|
|
|
|
message: this.t('share.confirm_revoke'),
|
|
|
|
|
|
danger: false,
|
|
|
|
|
|
confirmLabel: this.t('common.yes'),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!ok) return;
|
2026-01-15 09:07:51 +00:00
|
|
|
|
|
|
|
|
|
|
this.shareLoading = true;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const notePath = this.currentNote.replace('.md', '');
|
|
|
|
|
|
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
|
|
|
|
const response = await fetch(`/api/share/${encodedPath}`, {
|
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.shareInfo = { shared: false };
|
|
|
|
|
|
// Update the shared paths set
|
|
|
|
|
|
this._sharedNotePaths.delete(this.currentNote);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const error = await response.json();
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('share.error_revoking', { error: error.detail || 'Unknown error' }), { type: 'error' });
|
2026-01-15 09:07:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to revoke share link:', error);
|
2026-04-16 16:17:34 +00:00
|
|
|
|
this.toast(this.t('share.error_revoking', { error: error.message }), { type: 'error' });
|
2026-01-15 09:07:51 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
this.shareLoading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-12 16:18:14 +00:00
|
|
|
|
// Toggle Zen Mode (full immersive writing experience)
|
|
|
|
|
|
async toggleZenMode() {
|
|
|
|
|
|
if (!this.zenMode) {
|
|
|
|
|
|
// Entering Zen Mode
|
|
|
|
|
|
this.previousViewMode = this.viewMode;
|
|
|
|
|
|
this.viewMode = 'edit';
|
|
|
|
|
|
this.mobileSidebarOpen = false;
|
|
|
|
|
|
this.zenMode = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Request fullscreen
|
|
|
|
|
|
try {
|
|
|
|
|
|
const elem = document.documentElement;
|
|
|
|
|
|
if (elem.requestFullscreen) {
|
|
|
|
|
|
await elem.requestFullscreen();
|
|
|
|
|
|
} else if (elem.webkitRequestFullscreen) {
|
|
|
|
|
|
await elem.webkitRequestFullscreen();
|
|
|
|
|
|
} else if (elem.msRequestFullscreen) {
|
|
|
|
|
|
await elem.msRequestFullscreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// Fullscreen not supported or denied, continue anyway
|
|
|
|
|
|
console.log('Fullscreen not available:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Focus editor after transition
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const editor = document.getElementById('note-editor');
|
|
|
|
|
|
if (editor) editor.focus();
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Exiting Zen Mode
|
|
|
|
|
|
this.zenMode = false;
|
|
|
|
|
|
this.viewMode = this.previousViewMode;
|
|
|
|
|
|
|
|
|
|
|
|
// Exit fullscreen
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (document.exitFullscreen) {
|
|
|
|
|
|
await document.exitFullscreen();
|
|
|
|
|
|
} else if (document.webkitExitFullscreen) {
|
|
|
|
|
|
await document.webkitExitFullscreen();
|
|
|
|
|
|
} else if (document.msExitFullscreen) {
|
|
|
|
|
|
await document.msExitFullscreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.log('Exit fullscreen error:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-11-20 03:01:27 +00:00
|
|
|
|
// Homepage folder navigation methods
|
|
|
|
|
|
goToHomepageFolder(folderPath) {
|
2025-12-04 17:05:41 +00:00
|
|
|
|
this.showGraph = false; // Close graph when navigating
|
2025-11-20 03:01:27 +00:00
|
|
|
|
this.selectedHomepageFolder = folderPath || '';
|
|
|
|
|
|
|
2025-11-27 10:30:49 +00:00
|
|
|
|
// Clear editor state to show landing page
|
|
|
|
|
|
this.currentNote = '';
|
|
|
|
|
|
this.currentNoteName = '';
|
|
|
|
|
|
this.noteContent = '';
|
2026-01-18 15:00:01 +00:00
|
|
|
|
this.currentMedia = '';
|
2025-12-16 15:55:05 +00:00
|
|
|
|
this.outline = [];
|
2026-03-24 11:02:34 +00:00
|
|
|
|
this.backlinks = [];
|
2026-01-05 10:05:08 +00:00
|
|
|
|
document.title = this.appName;
|
2025-11-27 10:30:49 +00:00
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Invalidate cache to force recalculation
|
|
|
|
|
|
this._homepageCache = {
|
|
|
|
|
|
folderPath: null,
|
|
|
|
|
|
notes: null,
|
|
|
|
|
|
folders: null,
|
|
|
|
|
|
breadcrumb: null
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.history.pushState({ homepageFolder: folderPath || '' }, '', '/');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Navigate to homepage root and clear all editor state
|
|
|
|
|
|
goHome() {
|
2025-12-04 17:05:41 +00:00
|
|
|
|
this.showGraph = false; // Close graph when going home
|
2025-11-24 09:04:20 +00:00
|
|
|
|
this.selectedHomepageFolder = '';
|
|
|
|
|
|
this.currentNote = '';
|
|
|
|
|
|
this.currentNoteName = '';
|
|
|
|
|
|
this.noteContent = '';
|
2026-01-18 15:00:01 +00:00
|
|
|
|
this.currentMedia = '';
|
2025-12-16 15:55:05 +00:00
|
|
|
|
this.outline = [];
|
2026-03-24 11:02:34 +00:00
|
|
|
|
this.backlinks = [];
|
2025-11-24 09:04:20 +00:00
|
|
|
|
this.mobileSidebarOpen = false;
|
2026-01-05 10:05:08 +00:00
|
|
|
|
document.title = this.appName;
|
2025-11-24 09:04:20 +00:00
|
|
|
|
|
2025-12-18 08:50:08 +00:00
|
|
|
|
// Clear undo/redo history
|
|
|
|
|
|
this.undoHistory = [];
|
|
|
|
|
|
this.redoHistory = [];
|
|
|
|
|
|
this.hasPendingHistoryChanges = false;
|
|
|
|
|
|
|
2025-11-24 09:04:20 +00:00
|
|
|
|
// Invalidate cache to force recalculation
|
|
|
|
|
|
this._homepageCache = {
|
|
|
|
|
|
folderPath: null,
|
|
|
|
|
|
notes: null,
|
|
|
|
|
|
folders: null,
|
|
|
|
|
|
breadcrumb: null
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.history.pushState({ homepageFolder: '' }, '', '/');
|
2025-12-04 17:05:41 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-13 11:21:19 +00:00
|
|
|
|
// Mobile files/home tab - context-aware behavior
|
|
|
|
|
|
mobileFilesTabClick() {
|
2026-01-18 15:00:01 +00:00
|
|
|
|
if (this.currentNote || this.currentMedia || this.showGraph) {
|
2026-01-13 11:21:19 +00:00
|
|
|
|
// Viewing content → go home
|
|
|
|
|
|
this.goHome();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// On homepage → toggle files sidebar
|
|
|
|
|
|
this.activePanel = 'files';
|
|
|
|
|
|
this.mobileSidebarOpen = !this.mobileSidebarOpen;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2025-12-04 17:05:41 +00:00
|
|
|
|
// ==================== GRAPH VIEW ====================
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize the graph visualization
|
|
|
|
|
|
async initGraph() {
|
|
|
|
|
|
// Check if vis is loaded
|
|
|
|
|
|
if (typeof vis === 'undefined') {
|
|
|
|
|
|
console.error('vis-network library not loaded');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.graphLoaded = false;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Fetch graph data from API
|
|
|
|
|
|
const response = await fetch('/api/graph');
|
|
|
|
|
|
if (!response.ok) throw new Error('Failed to fetch graph data');
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
this.graphData = data;
|
|
|
|
|
|
|
|
|
|
|
|
// Get container
|
|
|
|
|
|
const container = document.getElementById('graph-overlay');
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Get theme colors (force reflow to ensure CSS is applied)
|
|
|
|
|
|
document.body.offsetHeight; // Force reflow
|
|
|
|
|
|
const style = getComputedStyle(document.documentElement);
|
|
|
|
|
|
|
|
|
|
|
|
// Helper to get CSS variable with fallback
|
|
|
|
|
|
const getCssVar = (name, fallback) => {
|
|
|
|
|
|
const value = style.getPropertyValue(name).trim();
|
|
|
|
|
|
return value || fallback;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const accentPrimary = getCssVar('--accent-primary', '#7c3aed');
|
|
|
|
|
|
const accentSecondary = getCssVar('--accent-secondary', '#a78bfa');
|
|
|
|
|
|
const textPrimary = getCssVar('--text-primary', '#111827');
|
|
|
|
|
|
const textSecondary = getCssVar('--text-secondary', '#6b7280');
|
|
|
|
|
|
const bgPrimary = getCssVar('--bg-primary', '#ffffff');
|
|
|
|
|
|
const bgSecondary = getCssVar('--bg-secondary', '#f3f4f6');
|
|
|
|
|
|
const borderColor = getCssVar('--border-primary', '#e5e7eb');
|
|
|
|
|
|
|
|
|
|
|
|
// Prepare nodes with styling - all nodes same base color
|
|
|
|
|
|
const nodes = new vis.DataSet(data.nodes.map(n => ({
|
|
|
|
|
|
id: n.id,
|
|
|
|
|
|
label: n.label,
|
|
|
|
|
|
title: n.id, // Tooltip shows full path
|
|
|
|
|
|
color: {
|
|
|
|
|
|
background: accentPrimary,
|
|
|
|
|
|
border: accentPrimary,
|
|
|
|
|
|
highlight: {
|
|
|
|
|
|
background: accentPrimary,
|
|
|
|
|
|
border: textPrimary // Darker border when selected
|
|
|
|
|
|
},
|
|
|
|
|
|
hover: {
|
|
|
|
|
|
background: accentSecondary,
|
|
|
|
|
|
border: accentPrimary
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
font: {
|
|
|
|
|
|
color: textPrimary,
|
|
|
|
|
|
size: 12,
|
|
|
|
|
|
face: 'system-ui, -apple-system, sans-serif'
|
|
|
|
|
|
},
|
|
|
|
|
|
borderWidth: this.currentNote === n.id ? 4 : 2,
|
|
|
|
|
|
chosen: {
|
|
|
|
|
|
node: (values) => {
|
|
|
|
|
|
values.size = 22;
|
|
|
|
|
|
values.borderWidth = 4;
|
|
|
|
|
|
values.borderColor = textPrimary;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})));
|
|
|
|
|
|
|
|
|
|
|
|
// Prepare edges with styling based on type
|
|
|
|
|
|
const edges = new vis.DataSet(data.edges.map((e, i) => ({
|
|
|
|
|
|
id: i,
|
|
|
|
|
|
from: e.source,
|
|
|
|
|
|
to: e.target,
|
|
|
|
|
|
color: {
|
|
|
|
|
|
color: e.type === 'wikilink' ? accentPrimary : borderColor,
|
|
|
|
|
|
highlight: accentPrimary,
|
|
|
|
|
|
hover: accentSecondary,
|
|
|
|
|
|
opacity: 0.8
|
|
|
|
|
|
},
|
|
|
|
|
|
width: e.type === 'wikilink' ? 2 : 1,
|
|
|
|
|
|
smooth: {
|
|
|
|
|
|
type: 'continuous',
|
|
|
|
|
|
roundness: 0.5
|
|
|
|
|
|
},
|
|
|
|
|
|
chosen: {
|
|
|
|
|
|
edge: (values) => {
|
|
|
|
|
|
values.width = 3;
|
|
|
|
|
|
values.color = accentPrimary;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})));
|
|
|
|
|
|
|
|
|
|
|
|
// Network options
|
|
|
|
|
|
const options = {
|
|
|
|
|
|
nodes: {
|
|
|
|
|
|
shape: 'dot',
|
|
|
|
|
|
size: 16,
|
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
|
shadow: {
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
color: 'rgba(0,0,0,0.1)',
|
|
|
|
|
|
size: 5,
|
|
|
|
|
|
x: 2,
|
|
|
|
|
|
y: 2
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
edges: {
|
|
|
|
|
|
arrows: {
|
|
|
|
|
|
to: {
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
scaleFactor: 0.5,
|
|
|
|
|
|
type: 'arrow'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
physics: {
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
solver: 'forceAtlas2Based',
|
|
|
|
|
|
forceAtlas2Based: {
|
|
|
|
|
|
gravitationalConstant: -50,
|
|
|
|
|
|
centralGravity: 0.01,
|
|
|
|
|
|
springLength: 100,
|
|
|
|
|
|
springConstant: 0.08,
|
|
|
|
|
|
damping: 0.4,
|
|
|
|
|
|
avoidOverlap: 0.5
|
|
|
|
|
|
},
|
|
|
|
|
|
stabilization: {
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
iterations: 200,
|
|
|
|
|
|
updateInterval: 25
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
interaction: {
|
|
|
|
|
|
hover: true,
|
|
|
|
|
|
tooltipDelay: 200,
|
|
|
|
|
|
navigationButtons: false, // Using custom buttons instead
|
|
|
|
|
|
keyboard: {
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
bindToWindow: false
|
|
|
|
|
|
},
|
|
|
|
|
|
zoomView: true,
|
|
|
|
|
|
dragView: true
|
|
|
|
|
|
},
|
|
|
|
|
|
layout: {
|
|
|
|
|
|
improvedLayout: true,
|
|
|
|
|
|
randomSeed: 42
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Destroy existing instance if any
|
|
|
|
|
|
if (this.graphInstance) {
|
|
|
|
|
|
this.graphInstance.destroy();
|
|
|
|
|
|
this.graphInstance = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clear container to ensure clean state
|
|
|
|
|
|
const graphCanvas = container.querySelector('canvas');
|
|
|
|
|
|
if (graphCanvas) graphCanvas.remove();
|
|
|
|
|
|
const visElements = container.querySelectorAll('.vis-network, .vis-navigation');
|
|
|
|
|
|
visElements.forEach(el => el.remove());
|
|
|
|
|
|
|
|
|
|
|
|
// Create the network
|
|
|
|
|
|
this.graphInstance = new vis.Network(container, { nodes, edges }, options);
|
|
|
|
|
|
|
|
|
|
|
|
// Store reference for callbacks
|
|
|
|
|
|
const graphRef = this.graphInstance;
|
|
|
|
|
|
const currentNoteRef = this.currentNote;
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for stabilization
|
|
|
|
|
|
this.graphInstance.once('stabilizationIterationsDone', () => {
|
|
|
|
|
|
graphRef.setOptions({ physics: { enabled: false } });
|
|
|
|
|
|
this.graphLoaded = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Focus and select current note if one is loaded
|
|
|
|
|
|
if (currentNoteRef) {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (graphRef && this.showGraph) {
|
|
|
|
|
|
const nodeIds = graphRef.body.data.nodes.getIds();
|
|
|
|
|
|
if (nodeIds.includes(currentNoteRef)) {
|
|
|
|
|
|
// Focus on the node
|
|
|
|
|
|
graphRef.focus(currentNoteRef, {
|
|
|
|
|
|
scale: 1.2,
|
|
|
|
|
|
animation: {
|
|
|
|
|
|
duration: 500,
|
|
|
|
|
|
easingFunction: 'easeInOutQuad'
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// Select the node to highlight it
|
|
|
|
|
|
graphRef.selectNodes([currentNoteRef]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// Ignore - graph may have been destroyed
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 150);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Click event - open note
|
|
|
|
|
|
this.graphInstance.on('click', (params) => {
|
|
|
|
|
|
if (params.nodes.length > 0) {
|
|
|
|
|
|
const noteId = params.nodes[0];
|
|
|
|
|
|
this.loadNote(noteId);
|
|
|
|
|
|
// Node is already selected by vis-network on click, no need to call selectNodes
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Double-click event - open note and close graph
|
|
|
|
|
|
this.graphInstance.on('doubleClick', (params) => {
|
|
|
|
|
|
if (params.nodes.length > 0) {
|
|
|
|
|
|
const noteId = params.nodes[0];
|
|
|
|
|
|
// Close graph and load note
|
|
|
|
|
|
this.showGraph = false;
|
|
|
|
|
|
this.loadNote(noteId);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Hover event - highlight connections
|
|
|
|
|
|
this.graphInstance.on('hoverNode', (params) => {
|
|
|
|
|
|
const nodeId = params.node;
|
|
|
|
|
|
const connectedNodes = this.graphInstance.getConnectedNodes(nodeId);
|
|
|
|
|
|
const connectedEdges = this.graphInstance.getConnectedEdges(nodeId);
|
|
|
|
|
|
|
|
|
|
|
|
// Dim all nodes except hovered and connected
|
|
|
|
|
|
const allNodes = nodes.getIds();
|
|
|
|
|
|
const updates = allNodes.map(id => ({
|
|
|
|
|
|
id,
|
|
|
|
|
|
opacity: (id === nodeId || connectedNodes.includes(id)) ? 1 : 0.2
|
|
|
|
|
|
}));
|
|
|
|
|
|
nodes.update(updates);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.graphInstance.on('blurNode', () => {
|
|
|
|
|
|
// Reset all nodes to full opacity
|
|
|
|
|
|
const allNodes = nodes.getIds();
|
|
|
|
|
|
const updates = allNodes.map(id => ({ id, opacity: 1 }));
|
|
|
|
|
|
nodes.update(updates);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Add legend to container
|
|
|
|
|
|
this.addGraphLegend(container, accentPrimary, borderColor, textSecondary);
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to initialize graph:', error);
|
|
|
|
|
|
this.graphLoaded = true; // Stop loading indicator
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Add legend to graph container
|
|
|
|
|
|
addGraphLegend(container, wikiColor, mdColor, textColor) {
|
|
|
|
|
|
// Remove existing legend if any
|
|
|
|
|
|
const existingLegend = container.querySelector('.graph-legend');
|
|
|
|
|
|
if (existingLegend) existingLegend.remove();
|
|
|
|
|
|
|
|
|
|
|
|
const legend = document.createElement('div');
|
|
|
|
|
|
legend.className = 'graph-legend';
|
|
|
|
|
|
legend.innerHTML = `
|
|
|
|
|
|
<div class="graph-legend-item">
|
|
|
|
|
|
<span class="graph-legend-dot" style="background: ${wikiColor};"></span>
|
|
|
|
|
|
<span style="color: ${textColor};">Wikilinks</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="graph-legend-item">
|
|
|
|
|
|
<span class="graph-legend-dot" style="background: ${mdColor};"></span>
|
2025-12-14 17:02:57 +00:00
|
|
|
|
<span style="color: ${textColor};">${this.t('graph.markdown_links')}</span>
|
2025-12-04 17:05:41 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style="margin-top: 8px; font-size: 10px; color: ${textColor}; opacity: 0.7;">
|
2025-12-14 17:02:57 +00:00
|
|
|
|
${this.t('graph.click_hint')}
|
2025-12-04 17:05:41 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
container.appendChild(legend);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Refresh graph when theme changes
|
|
|
|
|
|
refreshGraph() {
|
|
|
|
|
|
if (this.viewMode === 'graph' && this.graphInstance) {
|
|
|
|
|
|
this.initGraph();
|
|
|
|
|
|
}
|
2025-11-05 16:48:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|