`;
},
// Render root-level items (notes and media not in any folder)
renderRootItems() {
const root = this.folderTree['__root__'];
if (!root || !root.notes || root.notes.length === 0) {
return '';
}
return root.notes.map(note => this.renderNoteItem(note)).join('');
},
// Toggle folder expansion
toggleFolder(folderPath) {
if (this.expandedFolders.has(folderPath)) {
this.expandedFolders.delete(folderPath);
} else {
this.expandedFolders.add(folderPath);
}
// Force Alpine reactivity by creating new Set reference
this.expandedFolders = new Set(this.expandedFolders);
},
// Check if folder is expanded
isFolderExpanded(folderPath) {
return this.expandedFolders.has(folderPath);
},
// Expand all folders
expandAllFolders() {
this.allFolders.forEach(folder => {
this.expandedFolders.add(folder);
});
// Force Alpine reactivity
this.expandedFolders = new Set(this.expandedFolders);
},
// Collapse all folders
collapseAllFolders() {
this.expandedFolders.clear();
// Force Alpine reactivity
this.expandedFolders = new Set(this.expandedFolders);
},
// Expand folder tree to show a specific note
expandFolderForNote(notePath) {
const parts = notePath.split('/');
// If note is in root, no folders to expand
if (parts.length <= 1) return;
// Remove the note name (last part)
parts.pop();
// Build and expand all parent folders
let currentPath = '';
parts.forEach((part, index) => {
currentPath = index === 0 ? part : `${currentPath}/${part}`;
this.expandedFolders.add(currentPath);
});
// Force Alpine reactivity
this.expandedFolders = new Set(this.expandedFolders);
},
// Scroll note into view in the sidebar navigation
scrollNoteIntoView(notePath) {
// Find the note element in the sidebar
// Use a slight delay to ensure DOM is fully rendered with Alpine bindings applied
setTimeout(() => {
const sidebar = document.querySelector('.flex-1.overflow-y-auto.custom-scrollbar');
if (!sidebar) return;
const noteElements = sidebar.querySelectorAll('.note-item');
let targetElement = null;
const noteName = notePath.split('/').pop().replace('.md', '');
// Find the element that corresponds to this note
noteElements.forEach(el => {
// Check if this is a note element (not folder) by checking if it has the note name
if (el.textContent.trim().startsWith(noteName) || el.textContent.includes(noteName)) {
// Check computed style to see if it's highlighted
const computedStyle = window.getComputedStyle(el);
const bgColor = computedStyle.backgroundColor;
// Check if background has the accent color (not transparent or default)
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' && !bgColor.includes('255, 255, 255')) {
targetElement = el;
}
}
});
// If found, scroll it into view
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}, 200); // Increased delay to ensure Alpine has finished rendering
},
// Unified drag and drop handlers for notes, folders, and media
onItemDragStart(itemPath, itemType, event) {
// Set unified drag state
this.draggedItem = { path: itemPath, type: itemType };
// Make drag image semi-transparent
if (event.target) {
event.target.style.opacity = '0.5';
}
event.dataTransfer.effectAllowed = 'all';
},
onItemDragEnd() {
this.draggedItem = null;
this.dropTarget = null;
this.dragOverFolder = null;
// Reset opacity of all draggable items
document.querySelectorAll('.note-item, .folder-header').forEach(el => el.style.opacity = '1');
// Reset drag-over class
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
},
// Handle dragover on editor to show cursor position
onEditorDragOver(event) {
if (!this.draggedItem) return;
event.preventDefault();
this.dropTarget = 'editor';
// Focus the textarea
const textarea = event.target;
if (textarea.tagName !== 'TEXTAREA') return;
textarea.focus();
// Calculate cursor position from mouse coordinates
const pos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
if (pos >= 0) {
textarea.setSelectionRange(pos, pos);
}
},
// Calculate textarea cursor position from mouse coordinates
getTextareaCursorFromPoint(textarea, x, y) {
const rect = textarea.getBoundingClientRect();
const style = window.getComputedStyle(textarea);
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
const paddingTop = parseFloat(style.paddingTop) || 0;
const paddingLeft = parseFloat(style.paddingLeft) || 0;
// Calculate which line we're on
const relativeY = y - rect.top - paddingTop + textarea.scrollTop;
const lineIndex = Math.max(0, Math.floor(relativeY / lineHeight));
// Split content into lines
const lines = textarea.value.split('\n');
// Find the character position at the start of this line
let charPos = 0;
for (let i = 0; i < Math.min(lineIndex, lines.length); i++) {
charPos += lines[i].length + 1; // +1 for newline
}
// If we're beyond the last line, position at end
if (lineIndex >= lines.length) {
return textarea.value.length;
}
// Approximate character position within the line based on X coordinate
const relativeX = x - rect.left - paddingLeft;
const charWidth = parseFloat(style.fontSize) * 0.6; // Approximate for monospace
const charInLine = Math.max(0, Math.floor(relativeX / charWidth));
const lineLength = lines[lineIndex]?.length || 0;
return charPos + Math.min(charInLine, lineLength);
},
// Handle dragenter on editor
onEditorDragEnter(event) {
if (!this.draggedItem) return;
event.preventDefault();
this.dropTarget = 'editor';
},
// Handle dragleave on editor
onEditorDragLeave(event) {
// Only clear dropTarget if we're actually leaving the editor
// (not just moving between child elements)
if (event.target.tagName === 'TEXTAREA') {
this.dropTarget = null;
}
},
// Handle drop into editor to create internal link or upload media
async onEditorDrop(event) {
event.preventDefault();
this.dropTarget = null;
// Check if files are being dropped (media from file system)
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
await this.handleMediaDrop(event);
return;
}
// Otherwise, handle note/media link drop from sidebar
if (!this.draggedItem) return;
const notePath = this.draggedItem.path;
const isMediaFile = this.draggedItem.type !== 'note';
let link;
if (isMediaFile) {
// For media files (images, audio, video, PDF), use wiki-style embed link
const filename = notePath.split('/').pop();
link = `![[${filename}]]`;
} else {
// For notes, insert note link
const noteName = notePath.split('/').pop().replace('.md', '');
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
link = `[${noteName}](${encodedPath})`;
}
// Insert at drop position
const textarea = event.target;
// Recalculate position from drop coordinates for accuracy
let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
const textBefore = this.noteContent.substring(0, cursorPos);
const textAfter = this.noteContent.substring(cursorPos);
this.noteContent = textBefore + link + textAfter;
// Move cursor after the link
this.$nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = cursorPos + link.length;
textarea.focus();
});
// Trigger autosave
this.autoSave();
this.draggedItem = null;
},
/**
* Backend `get_attachment_dir` uses the parent folder of `note_path`.
* With no note open, infer a synthetic path from `currentMedia` so uploads go to the same
* `_attachments` folder as the file being viewed (or vault root when appropriate).
*/
resolveUploadNotePath() {
if (this.currentNote) return this.currentNote;
if (!this.currentMedia) return '';
const parts = this.currentMedia.split('/').filter(Boolean);
const ai = parts.indexOf('_attachments');
if (ai === -1 || ai === 0) return '';
return `${parts.slice(0, ai).join('/')}/_.md`;
},
// Handle media files dropped into editor
async handleMediaDrop(event) {
const files = Array.from(event.dataTransfer.files);
// Filter for allowed media types
const allowedTypes = [
// Images
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
// Audio
'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a',
// Video
'video/mp4', 'video/webm', 'video/quicktime',
// Documents
'application/pdf'
];
const mediaFiles = files.filter(file => allowedTypes.includes(file.type.toLowerCase()));
if (mediaFiles.length === 0) {
this.toast(this.t('media.no_valid_files'), { type: 'warning' });
return;
}
const textarea = event.target;
const notePath = this.resolveUploadNotePath();
// Calculate cursor position from drop coordinates (only meaningful when a note is open)
let cursorPos = 0;
if (this.currentNote && textarea && textarea.tagName === 'TEXTAREA') {
cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
}
for (const file of mediaFiles) {
try {
const mediaPath = await this.uploadMedia(file, notePath);
if (mediaPath && this.currentNote) {
await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
}
} catch (error) {
ErrorHandler.handle(`upload file ${file.name}`, error);
}
}
// uploadMedia already injects the file into this.notes optimistically.
},
// Upload a media file (image, audio, video, PDF)
// Use options.nextToNotes + options.contentFolder to save a new drawing PNG next to .md files (not in _attachments).
async uploadMedia(file, notePath, options = {}) {
const { nextToNotes, contentFolder } = options;
const formData = new FormData();
formData.append('file', file);
if (nextToNotes) {
formData.append('next_to_notes', '1');
formData.append('content_folder', contentFolder != null ? contentFolder : '');
formData.append('note_path', '');
} else {
formData.append('note_path', notePath || '');
}
const response = await fetch('/api/upload-media', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Upload failed');
}
const data = await response.json();
// Drop the new file into the local note list so the wikilink
// resolver finds it without a full /api/notes refresh.
if (data.path) {
this._optimisticAddNote(data.path, { size: file.size });
this._rebuildTreeAfterMutation();
}
return data.path;
},
// Insert media markdown at cursor position using wiki-style syntax
// (media links don't break when notes are moved). The uploaded file is
// already in this.notes thanks to uploadMedia()'s optimistic add.
async insertMediaMarkdown(mediaPath, altText, cursorPos) {
const filename = mediaPath.split('/').pop();
const filenameWithoutExt = filename.replace(/\.[^/.]+$/, '');
const altWithoutExt = altText.replace(/\.[^/.]+$/, '');
const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image'))
? `![[${filename}|${altWithoutExt}]]`
: `![[${filename}]]`;
const textBefore = this.noteContent.substring(0, cursorPos);
const textAfter = this.noteContent.substring(cursorPos);
this.noteContent = textBefore + markdown + '\n' + textAfter;
this.autoSave();
},
// Handle paste event for clipboard media (images)
async handlePaste(event) {
if (!this.currentNote) return;
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault();
const blob = item.getAsFile();
if (blob) {
try {
const textarea = event.target;
const cursorPos = textarea.selectionStart || 0;
// Create a simple filename - backend will add timestamp to prevent collisions
const ext = item.type.split('/')[1] || 'png';
const filename = `pasted-image.${ext}`;
// Create a File from the blob
const file = new File([blob], filename, { type: item.type });
const mediaPath = await this.uploadMedia(file, this.currentNote);
if (mediaPath) {
await this.insertMediaMarkdown(mediaPath, filename, cursorPos);
}
} catch (error) {
ErrorHandler.handle('paste media', error);
}
}
break; // Only handle first media item
}
}
},
// Media type detection based on file extension (and drawing-*.png convention)
getMediaType(filename) {
if (!filename) return null;
const base = filename.split('/').pop().toLowerCase();
if (base.startsWith('drawing-') && base.endsWith('.png')) {
return 'drawing';
}
const ext = filename.split('.').pop().toLowerCase();
const mediaTypes = {
image: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
audio: ['mp3', 'wav', 'ogg', 'm4a'],
video: ['mp4', 'webm', 'mov', 'avi'],
document: ['pdf'],
};
for (const [type, extensions] of Object.entries(mediaTypes)) {
if (extensions.includes(ext)) return type;
}
return null;
},
// Get icon for media type
getMediaIcon(type) {
const icons = {
image: '🖼️',
drawing: '✏️',
audio: '🎵',
video: '🎬',
document: '📄',
};
return icons[type] || '';
},
// Open a note or media file (unified handler for sidebar/homepage clicks)
openItem(path, type = 'note', searchHighlight = '') {
this.showGraph = false;
// Check if it's a media file by type or extension
const mediaType = type !== 'note' ? type : this.getMediaType(path);
if (mediaType && mediaType !== 'note') {
this.viewMedia(path, mediaType);
} else {
this.loadNote(path, true, searchHighlight);
}
},
// View a media file (image, audio, video, PDF) in the main pane
viewMedia(mediaPath, mediaType = null, updateHistory = true) {
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
this._drawingCancelAutosave();
}
this.showGraph = false; // Ensure graph is closed
this.currentNote = '';
this.currentNoteName = '';
this.noteContent = '';
this.currentMedia = mediaPath; // Reuse currentMedia for all media
this.currentMediaType = mediaType || this.getMediaType(mediaPath) || 'image';
this.shareInfo = null; // Reset share info
this.viewMode = 'preview'; // Use preview mode to show media
// Update browser tab title
const fileName = mediaPath.split('/').pop();
document.title = `${fileName} - ${this.appName}`;
// Expand folder tree to show the media file
this.expandFolderForNote(mediaPath);
// Update browser URL
if (updateHistory) {
// Encode each path segment to handle special characters
const encodedPath = mediaPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
window.history.pushState(
{ mediaPath: mediaPath },
'',
`/${encodedPath}`
);
}
// Drawing: Alpine x-init on the canvas runs only on first mount; switching from one drawing
// to another keeps currentMediaType === 'drawing', so we must reload the PNG here.
if (this.currentMediaType === 'drawing') {
this.$nextTick(() => {
this.initDrawingViewer();
});
}
},
// Backward compatibility alias
viewImage(mediaPath, updateHistory = true) {
this.viewMedia(mediaPath, 'image', updateHistory);
},
// Delete a media file (image, audio, video, PDF)
async deleteMedia(mediaPath) {
const filename = mediaPath.split('/').pop();
const ok = await this.confirmModalAsk({
message: this.t('media.confirm_delete', { name: filename }),
});
if (!ok) return;
this._optimisticRemoveNote(mediaPath);
this._rebuildTreeAfterMutation();
if (this.currentMedia === mediaPath) this.currentMedia = '';
try {
const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete media file');
} catch (error) {
ErrorHandler.handle('delete media', error);
await this.loadNotes({ silent: true });
}
},
/**
* Create a blank drawing PNG and open it for editing.
* Attachment folder matches "New note" / "New folder" from the same + menu (root vs folder row vs homepage folder).
*/
/**
* Create a brand-new drawing PNG and open it in the editor.
* @param {{docW?: number, docH?: number}} [opts]
* Optional intrinsic dimensions in document pixels. Phase-2: a "New drawing"
* dialog (presets, A4, custom) only needs to pass these values here — no other
* plumbing is required because every downstream function reads doc size from
* either the loaded PNG or the in-memory drawingDocW/drawingDocH fields.
*/
async createNewDrawing(opts = {}) {
const targetFolder = this.inferredNewItemTargetFolder();
this.closeDropdown();
const w = this._drawingClampDim(opts.docW, CONFIG.DRAWING_DEFAULT_DOC_W);
const h = this._drawingClampDim(opts.docH, CONFIG.DRAWING_DEFAULT_DOC_H);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = CONFIG.DRAWING_BACKGROUND;
ctx.fillRect(0, 0, w, h);
let blob;
try {
blob = await new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
});
} catch (e) {
ErrorHandler.handle('create drawing', e);
return;
}
const file = new File([blob], 'drawing.png', { type: 'image/png' });
try {
const path = await this.uploadMedia(file, '', {
nextToNotes: true,
contentFolder: targetFolder,
});
// Server returns the final upload path (may differ from
// 'drawing.png' if it added a timestamp suffix).
this._optimisticAddNote(path, { type: 'image', size: blob.size });
this._rebuildTreeAfterMutation();
this.viewMedia(path, 'drawing');
} catch (error) {
ErrorHandler.handle('create drawing', error);
await this.loadNotes({ silent: true });
}
},
_drawingEncodeMediaPath() {
return this.currentMedia.split('/').map((s) => encodeURIComponent(s)).join('/');
},
/**
* Clamp a dimension to the [MIN, MAX] document-px range. Falls back to `fallback`
* when value is missing, non-finite or non-positive. Always returns an integer.
*/
_drawingClampDim(value, fallback) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return Math.round(fallback);
const clamped = Math.min(
CONFIG.DRAWING_MAX_DOC_DIM,
Math.max(CONFIG.DRAWING_MIN_DOC_DIM, n)
);
return Math.round(clamped);
},
/**
* Convert a pointer event to DOCUMENT-space coordinates (0..drawingDocW, 0..drawingDocH).
* The mapping is independent of CSS size, DPR and zoom — strokes stored from this
* function render identically on any device and round-trip cleanly through PNG export.
*/
_drawingCanvasCoords(event) {
const canvas = this._drawingCanvasEl;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return { x: 0, y: 0 };
return {
x: ((event.clientX - rect.left) / rect.width) * this.drawingDocW,
y: ((event.clientY - rect.top) / rect.height) * this.drawingDocH,
};
},
_drawingDrawOp(ctx, op) {
if (!op) return;
if (op.type === 'fill') {
if (op.bitmap) {
ctx.drawImage(op.bitmap, op.x, op.y);
}
return;
}
if (op.type === 'text') {
const text = op.text;
if (typeof text !== 'string' || text === '') return;
ctx.save();
ctx.fillStyle = op.color;
ctx.font = `${op.fontSize}px sans-serif`;
ctx.textBaseline = 'top';
ctx.fillText(text, op.x, op.y);
ctx.restore();
return;
}
if (op.type === 'stroke') {
const pts = op.points;
if (!pts || pts.length < 2) return;
ctx.save();
ctx.strokeStyle = op.color;
ctx.lineWidth = op.lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i][0], pts[i][1]);
}
ctx.stroke();
ctx.restore();
return;
}
ctx.save();
ctx.strokeStyle = op.color;
ctx.lineWidth = op.lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (op.type === 'line') {
ctx.beginPath();
ctx.moveTo(op.x1, op.y1);
ctx.lineTo(op.x2, op.y2);
ctx.stroke();
} else if (op.type === 'rect') {
const nx = op.w < 0 ? op.x + op.w : op.x;
const ny = op.h < 0 ? op.y + op.h : op.y;
ctx.strokeRect(nx, ny, Math.abs(op.w), Math.abs(op.h));
} else if (op.type === 'ellipse') {
const nx = op.w < 0 ? op.x + op.w : op.x;
const ny = op.h < 0 ? op.y + op.h : op.y;
const rw = Math.abs(op.w) / 2;
const rh = Math.abs(op.h) / 2;
const cx = nx + rw;
const cy = ny + rh;
ctx.beginPath();
ctx.ellipse(cx, cy, rw, rh, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
},
/**
* Repaint the visible canvas. All drawing math runs in DOCUMENT space; a single
* setTransform call maps (docW, docH) → device pixels by combining the doc→display
* scale with the device pixel ratio. _drawingDrawOp itself is space-agnostic.
*/
drawingRedraw() {
const canvas = this._drawingCanvasEl;
const ctx = this._drawingCtx;
if (!canvas || !ctx) return;
const docW = this.drawingDocW;
const docH = this.drawingDocH;
const cssW = this._drawingCssW;
const cssH = this._drawingCssH;
const dpr = this._drawingDpr || 1;
if (!docW || !docH || !cssW || !cssH) return;
const sx = (cssW / docW) * dpr;
const sy = (cssH / docH) * dpr;
ctx.setTransform(sx, 0, 0, sy, 0, 0);
ctx.clearRect(0, 0, docW, docH);
ctx.fillStyle = CONFIG.DRAWING_BACKGROUND;
ctx.fillRect(0, 0, docW, docH);
if (this._drawingBaseImage && this._drawingBaseImage.complete) {
ctx.drawImage(this._drawingBaseImage, 0, 0, docW, docH);
}
for (const op of this.drawingOps) {
this._drawingDrawOp(ctx, op);
}
if (this.drawingDraft) {
this._drawingDrawOp(ctx, this.drawingDraft);
}
},
_drawingScheduleRedraw() {
if (this._drawingRaf) return;
this._drawingRaf = requestAnimationFrame(() => {
this._drawingRaf = null;
this.drawingRedraw();
});
},
/**
* Render the current drawing state (background + base image + all committed ops) to a
* fresh off-screen canvas at the given document size. Used by drawingSave() to produce
* a device-independent PNG, and by drawingFill() to sample the visible state at full
* doc resolution before flood-filling. Returns the canvas, or null if a 2D context
* can't be obtained.
*/
_drawingRenderToOffscreen(docW, docH) {
const off = document.createElement('canvas');
off.width = docW;
off.height = docH;
const ctx = off.getContext('2d');
if (!ctx) return null;
ctx.fillStyle = CONFIG.DRAWING_BACKGROUND;
ctx.fillRect(0, 0, docW, docH);
if (this._drawingBaseImage && this._drawingBaseImage.complete) {
ctx.drawImage(this._drawingBaseImage, 0, 0, docW, docH);
}
for (const op of this.drawingOps) {
this._drawingDrawOp(ctx, op);
}
return off;
},
_drawingDisconnectResizeObserver() {
if (this._drawingResizeObserver) {
try {
this._drawingResizeObserver.disconnect();
} catch (_) {
/* ignore */
}
this._drawingResizeObserver = null;
}
},
_drawingCancelAutosave() {
if (this._drawingAutosaveTimeout) {
clearTimeout(this._drawingAutosaveTimeout);
this._drawingAutosaveTimeout = null;
}
},
/**
* Debounced PNG save (same delay as notes). Never runs while the primary button is held
* (active stroke/shape); pending timers are cleared when a new stroke starts.
*/
_drawingScheduleAutosave() {
if (this.currentMediaType !== 'drawing' || !this.currentMedia) return;
if (this._drawingAutosaveTimeout) {
clearTimeout(this._drawingAutosaveTimeout);
this._drawingAutosaveTimeout = null;
}
const path = this.currentMedia;
const pollMs = 150;
const attemptSave = () => {
if (this.currentMedia !== path || this.currentMediaType !== 'drawing') {
this._drawingAutosaveTimeout = null;
return;
}
if (this.drawingIsPointerDown) {
this._drawingAutosaveTimeout = setTimeout(attemptSave, pollMs);
return;
}
this._drawingAutosaveTimeout = null;
this.drawingSave();
};
this._drawingAutosaveTimeout = setTimeout(attemptSave, this.autosaveDelayMs);
},
/**
* Size the visible canvas as a letterbox of (drawingDocW × drawingDocH) inside the
* available wrap, preserving aspect ratio. The display canvas may be smaller than
* the wrap on narrow viewports — that is intentional. The document space stays
* fixed; only the on-screen viewport scales.
*/
_drawingLayoutCanvas() {
const wrap = this.$refs.drawingCanvasWrap;
const canvas = this.$refs.drawingCanvas;
if (!wrap || !canvas || this.currentMediaType !== 'drawing') return;
// Available area (with sane fallbacks for the brief moment before layout settles).
let availW = wrap.clientWidth;
let availH = wrap.clientHeight;
if (availW < 32) {
availW = Math.max(320, wrap.parentElement ? wrap.parentElement.clientWidth : 320);
}
if (availH < 32) {
const col = wrap.closest('.flex-1.flex.flex-col') || wrap.closest('.flex-1');
const h = col ? col.clientHeight : 0;
availH = h > 64 ? h : Math.max(240, Math.floor((availW || 400) * 0.5));
}
// Letterbox the document into the available area.
const docW = this.drawingDocW;
const docH = this.drawingDocH;
const docAspect = docW / docH;
const wrapAspect = availW / availH;
let cssW;
let cssH;
if (wrapAspect > docAspect) {
cssH = availH;
cssW = cssH * docAspect;
} else {
cssW = availW;
cssH = cssW / docAspect;
}
const dpr = window.devicePixelRatio || 1;
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
// Backing store in device pixels — capped above by availW/availH * dpr, so this
// never allocates more than the viewport could ever show. Cheap on every device.
canvas.width = Math.max(1, Math.round(cssW * dpr));
canvas.height = Math.max(1, Math.round(cssH * dpr));
this._drawingCanvasEl = canvas;
this._drawingCssW = cssW;
this._drawingCssH = cssH;
this._drawingDpr = dpr;
if (!this._drawingCtx) {
this._drawingCtx = canvas.getContext('2d');
}
this.drawingRedraw();
},
async initDrawingViewer() {
if (this.currentMediaType !== 'drawing' || !this.currentMedia) return;
this._drawingDisconnectResizeObserver();
await this.$nextTick();
if (this._drawingObjectURL) {
URL.revokeObjectURL(this._drawingObjectURL);
this._drawingObjectURL = null;
}
const canvas = this.$refs.drawingCanvas;
const wrap = this.$refs.drawingCanvasWrap;
if (!canvas || !wrap) return;
this._drawingCtx = canvas.getContext('2d');
this._drawingDisposeOps(this.drawingOps);
this._drawingDisposeOps(this.drawingRedoStack);
this.drawingOps = [];
this.drawingRedoStack = [];
this.drawingDraft = null;
this.drawingIsPointerDown = false;
this.drawingTextActive = false;
this.drawingTextValue = '';
this._drawingBaseImage = null;
this.drawingHasRasterFromFile = false;
// Reset to defaults; replaced by the loaded PNG's natural size below.
// For drawings created via createNewDrawing() the file already matches the defaults
// (or the size requested by a future "new drawing" dialog).
this.drawingDocW = CONFIG.DRAWING_DEFAULT_DOC_W;
this.drawingDocH = CONFIG.DRAWING_DEFAULT_DOC_H;
this._drawingLoadToken = Symbol();
const token = this._drawingLoadToken;
this._drawingResizeObserver = new ResizeObserver(() => {
if (this.currentMediaType !== 'drawing' || !this.currentMedia) return;
this._drawingLayoutCanvas();
});
this._drawingResizeObserver.observe(wrap);
requestAnimationFrame(() => {
if (token !== this._drawingLoadToken) return;
this._drawingLayoutCanvas();
requestAnimationFrame(() => {
if (token !== this._drawingLoadToken) return;
this._drawingLayoutCanvas();
});
});
try {
const enc = this._drawingEncodeMediaPath();
const res = await fetch(`/api/media/${enc}`, { credentials: 'same-origin' });
if (!res.ok) throw new Error('Failed to load drawing');
const blob = await res.blob();
if (token !== this._drawingLoadToken) return;
const url = URL.createObjectURL(blob);
this._drawingObjectURL = url;
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = url;
});
if (token !== this._drawingLoadToken) return;
this._drawingBaseImage = img;
this.drawingHasRasterFromFile = true;
// Adopt the PNG's intrinsic size as the document size for this drawing.
// The PNG itself is the source of truth — no sidecar metadata required.
this.drawingDocW = this._drawingClampDim(img.naturalWidth, CONFIG.DRAWING_DEFAULT_DOC_W);
this.drawingDocH = this._drawingClampDim(img.naturalHeight, CONFIG.DRAWING_DEFAULT_DOC_H);
this._drawingLayoutCanvas();
} catch (e) {
if (token !== this._drawingLoadToken) return;
ErrorHandler.handle('load drawing', e);
this.drawingHasRasterFromFile = false;
this._drawingLayoutCanvas();
}
},
_drawingRgbToHex(r, g, b) {
const h = (n) => {
const s = n.toString(16);
return s.length === 1 ? `0${s}` : s;
};
return `#${h(r)}${h(g)}${h(b)}`;
},
_drawingHexToRgb(hex) {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || '');
if (!m) return { r: 0, g: 0, b: 0 };
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
},
/**
* Release the native bitmap memory backing any 'fill' ops in `ops`. Safe to call on a
* mixed list (vector ops are skipped). Caller is responsible for clearing the array
* itself afterwards if needed.
*/
_drawingDisposeOps(ops) {
if (!ops) return;
for (const op of ops) {
if (op && op.type === 'fill' && op.bitmap && typeof op.bitmap.close === 'function') {
try { op.bitmap.close(); } catch (_) { /* ignore */ }
}
}
},
/**
* Iterative scanline flood fill on an ImageData buffer. Marks every pixel that is
* connected to (sx, sy) and matches the seed color exactly (4-way connectivity).
* Returns { mask, minX, minY, maxX, maxY } describing the filled region's bounding
* box, or null if the seed pixel is already the same color as the fill (so the caller
* can skip pushing a no-op onto the undo stack). Stack-based — never recurses, so a
* full-canvas fill at 1600×1000 stays well under any browser stack limit.
*
* Known optimization opportunities (revisit only if fills feel slow in practice):
* 1) Add a `tolerance` parameter to soften antialiasing halos around strokes.
* 2) Push only the start of each new connected run above/below the current span
* instead of every cell — the textbook scanline optimization, ~3–5× faster.
* 3) Replace the Array<[x,y]> stack with a flat Int32Array + top pointer to remove
* per-push allocations and cut GC pressure on large fills.
*/
_drawingFlood(imgData, sx, sy, fillRGB) {
const { data, width: W, height: H } = imgData;
if (sx < 0 || sy < 0 || sx >= W || sy >= H) return null;
const seedIdx = (sy * W + sx) * 4;
const tR = data[seedIdx];
const tG = data[seedIdx + 1];
const tB = data[seedIdx + 2];
const tA = data[seedIdx + 3];
if (tR === fillRGB.r && tG === fillRGB.g && tB === fillRGB.b && tA === 255) {
return null;
}
const mask = new Uint8Array(W * H);
let minX = sx, minY = sy, maxX = sx, maxY = sy;
const matches = (px, py) => {
if (mask[py * W + px]) return false;
const i = (py * W + px) * 4;
return (
data[i] === tR &&
data[i + 1] === tG &&
data[i + 2] === tB &&
data[i + 3] === tA
);
};
const stack = [[sx, sy]];
while (stack.length) {
const [x, y] = stack.pop();
if (!matches(x, y)) continue;
let lx = x;
while (lx > 0 && matches(lx - 1, y)) lx--;
let rx = x;
while (rx < W - 1 && matches(rx + 1, y)) rx++;
for (let i = lx; i <= rx; i++) {
mask[y * W + i] = 1;
}
if (lx < minX) minX = lx;
if (rx > maxX) maxX = rx;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
if (y > 0) {
for (let i = lx; i <= rx; i++) {
if (matches(i, y - 1)) stack.push([i, y - 1]);
}
}
if (y < H - 1) {
for (let i = lx; i <= rx; i++) {
if (matches(i, y + 1)) stack.push([i, y + 1]);
}
}
}
return { mask, minX, minY, maxX, maxY };
},
/**
* Sample visible canvas color under the pointer; sets drawingColor.
* Pointer coords come in document space; we map to device pixels via the canvas's
* actual backing store (which already includes both the doc→display scale and DPR).
*/
drawingSampleColor(e) {
const canvas = this._drawingCanvasEl;
const ctx = this._drawingCtx;
if (!canvas || !ctx) return;
const docW = this.drawingDocW;
const docH = this.drawingDocH;
if (!docW || !docH) return;
const { x, y } = this._drawingCanvasCoords(e);
this.drawingRedraw();
let ix = Math.floor((x / docW) * canvas.width);
let iy = Math.floor((y / docH) * canvas.height);
ix = Math.max(0, Math.min(ix, canvas.width - 1));
iy = Math.max(0, Math.min(iy, canvas.height - 1));
const pix = ctx.getImageData(ix, iy, 1, 1).data;
this.drawingColor = this._drawingRgbToHex(pix[0], pix[1], pix[2]);
},
/**
* Paint-bucket flood fill at the click point. Renders the current state to a fresh
* off-screen canvas at document resolution, runs an iterative scanline flood fill from
* the clicked pixel, then stores only the cropped delta as a 'fill' op so the existing
* undo/redo machinery (which just shuffles ops between drawingOps and drawingRedoStack)
* works unchanged. The cropped ImageBitmap keeps memory proportional to the filled
* area, not the canvas area.
*/
async drawingFill(e) {
if (this.currentMediaType !== 'drawing' || !this.currentMedia) return;
const docW = this._drawingClampDim(this.drawingDocW, CONFIG.DRAWING_DEFAULT_DOC_W);
const docH = this._drawingClampDim(this.drawingDocH, CONFIG.DRAWING_DEFAULT_DOC_H);
if (!docW || !docH) return;
const { x, y } = this._drawingCanvasCoords(e);
const sx = Math.floor(x);
const sy = Math.floor(y);
if (sx < 0 || sy < 0 || sx >= docW || sy >= docH) return;
const off = this._drawingRenderToOffscreen(docW, docH);
if (!off) return;
const offCtx = off.getContext('2d');
if (!offCtx) return;
const imgData = offCtx.getImageData(0, 0, docW, docH);
const fillRGB = this._drawingHexToRgb(this.drawingColor);
const result = this._drawingFlood(imgData, sx, sy, fillRGB);
// Clicked on a pixel that's already the fill color (or out of bounds): no-op.
if (!result) return;
const w = result.maxX - result.minX + 1;
const h = result.maxY - result.minY + 1;
const overlay = new ImageData(w, h);
const ovData = overlay.data;
const { mask } = result;
for (let py = 0; py < h; py++) {
const srcRow = (result.minY + py) * docW + result.minX;
const dstRow = py * w * 4;
for (let px = 0; px < w; px++) {
if (mask[srcRow + px]) {
const di = dstRow + px * 4;
ovData[di] = fillRGB.r;
ovData[di + 1] = fillRGB.g;
ovData[di + 2] = fillRGB.b;
ovData[di + 3] = 255;
}
}
}
let bitmap;
try {
bitmap = await createImageBitmap(overlay);
} catch (err) {
ErrorHandler.handle('drawing fill', err);
return;
}
// Drawing may have closed (navigation, clear) while createImageBitmap awaited.
if (this.currentMediaType !== 'drawing' || !this.currentMedia) {
try { bitmap.close(); } catch (_) { /* ignore */ }
return;
}
this._drawingCancelAutosave();
this._drawingDisposeOps(this.drawingRedoStack);
this.drawingRedoStack = [];
this.drawingOps.push({
type: 'fill',
x: result.minX,
y: result.minY,
bitmap,
});
this.drawingRedraw();
this._drawingScheduleAutosave();
},
/** 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._drawingDisposeOps(this.drawingOps);
this._drawingDisposeOps(this.drawingRedoStack);
this.drawingOps = [];
this.drawingRedoStack = [];
this.drawingTextActive = false;
this.drawingTextValue = '';
this.drawingRedraw();
this._drawingScheduleAutosave();
},
drawingPointerDown(e) {
if (this.currentMediaType !== 'drawing' || e.button !== 0) return;
const canvas = this._drawingCanvasEl;
if (!canvas) return;
const tool = this.drawingTool;
if (tool === 'eyedropper') {
e.preventDefault();
this.drawingSampleColor(e);
return;
}
if (tool === 'fill') {
e.preventDefault();
this.drawingFill(e);
return;
}
if (tool === 'text') {
e.preventDefault();
this.drawingTextStart(e);
return;
}
this._drawingCancelAutosave();
canvas.setPointerCapture(e.pointerId);
this._drawingPointerId = e.pointerId;
const { x, y } = this._drawingCanvasCoords(e);
this.drawingIsPointerDown = true;
this._drawingDisposeOps(this.drawingRedoStack);
this.drawingRedoStack = [];
const lw = this.drawingLineWidth;
const color = tool === 'eraser' ? CONFIG.DRAWING_BACKGROUND : this.drawingColor;
if (tool === 'freehand' || tool === 'eraser') {
this.drawingDraft = { type: 'stroke', color, lineWidth: lw, points: [[x, y]] };
} else if (tool === 'line') {
this.drawingDraft = { type: 'line', color, lineWidth: lw, x1: x, y1: y, x2: x, y2: y };
} else if (tool === 'rect') {
this.drawingDraft = { type: 'rect', color, lineWidth: lw, x, y, w: 0, h: 0 };
} else if (tool === 'ellipse') {
this.drawingDraft = { type: 'ellipse', color, lineWidth: lw, x, y, w: 0, h: 0 };
}
this.drawingRedraw();
},
drawingPointerMove(e) {
if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return;
const { x, y } = this._drawingCanvasCoords(e);
const d = this.drawingDraft;
if (!d) return;
if (d.type === 'stroke') {
const pts = d.points;
const last = pts[pts.length - 1];
const dx = x - last[0];
const dy = y - last[1];
if (dx * dx + dy * dy < 1) return;
pts.push([x, y]);
this._drawingScheduleRedraw();
return;
}
if (d.type === 'line') {
d.x2 = x;
d.y2 = y;
} else if (d.type === 'rect' || d.type === 'ellipse') {
d.w = x - d.x;
d.h = y - d.y;
}
this._drawingScheduleRedraw();
},
drawingPointerUp(e) {
if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return;
const canvas = this._drawingCanvasEl;
if (canvas && this._drawingPointerId === e.pointerId) {
try {
canvas.releasePointerCapture(e.pointerId);
} catch (_) {
/* ignore */
}
}
this._drawingPointerId = null;
this.drawingIsPointerDown = false;
const d = this.drawingDraft;
this.drawingDraft = null;
if (!d) {
this.drawingRedraw();
return;
}
if (d.type === 'stroke') {
if (!d.points || d.points.length < 2) {
this.drawingRedraw();
return;
}
this.drawingOps.push({
type: 'stroke',
color: d.color,
lineWidth: d.lineWidth,
points: d.points.slice(),
});
} else if (d.type === 'line') {
const dx = d.x2 - d.x1;
const dy = d.y2 - d.y1;
if (dx * dx + dy * dy < 4) {
this.drawingRedraw();
return;
}
this.drawingOps.push({
type: 'line',
color: d.color,
lineWidth: d.lineWidth,
x1: d.x1,
y1: d.y1,
x2: d.x2,
y2: d.y2,
});
} else if (d.type === 'rect' || d.type === 'ellipse') {
if (Math.abs(d.w) < 2 && Math.abs(d.h) < 2) {
this.drawingRedraw();
return;
}
this.drawingOps.push({
type: d.type,
color: d.color,
lineWidth: d.lineWidth,
x: d.x,
y: d.y,
w: d.w,
h: d.h,
});
}
this.drawingRedraw();
this._drawingScheduleAutosave();
},
drawingUndo() {
if (this.drawingOps.length === 0) return;
this.drawingRedoStack.push(this.drawingOps.pop());
this.drawingRedraw();
this._drawingScheduleAutosave();
},
drawingRedo() {
if (this.drawingRedoStack.length === 0) return;
this.drawingOps.push(this.drawingRedoStack.pop());
this.drawingRedraw();
this._drawingScheduleAutosave();
},
/**
* Text tool — single-line, commit-once. Clicking the canvas spawns a floating
* at the click position; pressing Enter or losing focus rasterizes the text into a
* 'text' op (rendered in DOC space via ctx.fillText so it auto-scales with the canvas).
* Esc cancels without writing. Each commit pushes one op so undo/redo handles it just
* like any other shape — no separate "edit text" mode, no DOM cleanup gymnastics.
*/
drawingTextStart(e) {
const canvas = this._drawingCanvasEl;
if (!canvas) return;
// If a previous input was still open (e.g. mobile pointerdown before blur fires),
// commit it first so we never lose typed text.
if (this.drawingTextActive) {
this.drawingTextCommit();
}
// CSS coords are stored relative to the WRAP (the input's offset parent), not the
// canvas: the wrap is a flex container that centers the canvas, so when the canvas
// is narrower than the wrap there are gutters on either side that would otherwise
// shift the floating input away from the click point.
const wrap = this.$refs.drawingCanvasWrap || canvas.parentElement;
const wrapRect = wrap.getBoundingClientRect();
const { x: docX, y: docY } = this._drawingCanvasCoords(e);
this.drawingTextDocX = docX;
this.drawingTextDocY = docY;
this.drawingTextCssX = e.clientX - wrapRect.left;
this.drawingTextCssY = e.clientY - wrapRect.top;
this.drawingTextDocFontSize = Math.max(14, this.drawingLineWidth * 6);
this.drawingTextValue = '';
this.drawingTextActive = true;
this.drawingDraft = null;
this.$nextTick(() => {
const el = document.getElementById('drawing-text-input');
if (!el) return;
// Wipe any leftover text from a previous session before focusing — contenteditable
// doesn't auto-clear from setting drawingTextValue alone.
el.textContent = '';
el.focus();
});
},
/**
* Normalize text-tool input: ctx.fillText doesn't honor line breaks (renders them
* as literal LF glyphs / tofu), and Firefox treats contenteditable="plaintext-only"
* as plain "true" so multi-line paste sneaks newlines into textContent. Also clamp
* length defensively so a giant paste can't bloat the op / autosave payload.
*/
_drawingTextSanitize(s) {
if (typeof s !== 'string' || s.length === 0) return '';
const collapsed = s.replace(/[\r\n\t\v\f]+/g, ' ');
return collapsed.length > 1024 ? collapsed.slice(0, 1024) : collapsed;
},
/**
* Live-preview handler for the text tool. The contenteditable div is invisible
* (color: transparent), so the only thing the user actually SEES of their typing
* is whatever drawingRedraw paints from drawingDraft. Pushing the in-progress
* text into drawingDraft means the live preview goes through the very same
* ctx.fillText call path as the final commit — guaranteeing zero pixel drift.
*/
drawingTextOnInput(e) {
if (!this.drawingTextActive) return;
const text = this._drawingTextSanitize(e.target.textContent || '');
this.drawingTextValue = text;
if (text.length === 0) {
this.drawingDraft = null;
} else {
this.drawingDraft = {
type: 'text',
color: this.drawingColor,
x: this.drawingTextDocX,
y: this.drawingTextDocY,
text,
fontSize: this.drawingTextDocFontSize,
};
}
this._drawingScheduleRedraw();
},
drawingTextCommit() {
if (!this.drawingTextActive) return;
// Read straight from the DOM so we capture the very last keystroke even if the
// @input event hasn't fired yet (it usually has, but Enter can race it).
const el = document.getElementById('drawing-text-input');
const raw = el ? (el.textContent || '') : (this.drawingTextValue || '');
const text = this._drawingTextSanitize(raw).trim();
const docX = this.drawingTextDocX;
const docY = this.drawingTextDocY;
const fontSize = this.drawingTextDocFontSize;
const color = this.drawingColor;
this.drawingTextActive = false;
this.drawingTextValue = '';
this.drawingDraft = null;
if (el) el.textContent = '';
if (!text) {
this.drawingRedraw();
return;
}
this._drawingDisposeOps(this.drawingRedoStack);
this.drawingRedoStack = [];
this.drawingOps.push({
type: 'text',
color,
x: docX,
y: docY,
text,
fontSize,
});
this.drawingRedraw();
this._drawingScheduleAutosave();
},
drawingTextCancel() {
if (!this.drawingTextActive) return;
this.drawingTextActive = false;
this.drawingTextValue = '';
this.drawingDraft = null;
const el = document.getElementById('drawing-text-input');
if (el) el.textContent = '';
this.drawingRedraw();
},
/**
* Persist the flattened canvas to disk (Ctrl+S, autosave). Same feedback as saveNote:
* header "Saved" only — never clears stroke undo/redo or reloads the image; stacks reset when
* opening another drawing via initDrawingViewer().
*/
async drawingSave() {
if (!this.currentMedia || this.currentMediaType !== 'drawing') return;
// Commit any in-progress text BEFORE the in-flight guard so the typed value
// makes it into drawingOps before the next line wipes drawingDraft. Otherwise
// Ctrl+S (or autosave) mid-typing would silently drop whatever the user just
// typed from the saved PNG. drawingTextCommit will trigger another autosave,
// but that's harmless — the next save just re-uploads the same bytes.
if (this.drawingTextActive) {
this.drawingTextCommit();
}
if (this._drawingSaveInFlight) {
this._drawingSaveQueued = true;
return;
}
this._drawingSaveInFlight = true;
try {
this._drawingCancelAutosave();
this.drawingDraft = null;
this.drawingIsPointerDown = false;
// Render onto an off-screen canvas at the exact document size. This makes the
// exported PNG byte-deterministic across devices: same ops, same dimensions →
// identical bytes regardless of DPR, window size, or zoom level. The off-screen
// canvas is allocated per save (microseconds for 1600×1000) so dimension changes
// from a future "resize drawing" feature can never leak between saves.
const docW = this._drawingClampDim(this.drawingDocW, CONFIG.DRAWING_DEFAULT_DOC_W);
const docH = this._drawingClampDim(this.drawingDocH, CONFIG.DRAWING_DEFAULT_DOC_H);
const off = this._drawingRenderToOffscreen(docW, docH);
if (!off) return;
// Keep the visible canvas in sync (cosmetic; UI doesn't have to wait for the upload).
this.drawingRedraw();
const blob = await new Promise((resolve, reject) => {
off.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
});
this.isSaving = true;
try {
const enc = this._drawingEncodeMediaPath();
const res = await fetch(`/api/media/${enc}`, {
method: 'PUT',
body: blob,
headers: { 'Content-Type': 'image/png' },
credentials: 'same-origin',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
let detail = err.detail;
if (Array.isArray(detail)) {
detail = detail.map((d) => d.msg || d).join(', ');
}
throw new Error(detail || res.statusText);
}
// Drawing file already in this.notes — only metadata changed
// (size/mtime). Bump locally instead of a full /api/notes scan.
const rec = this.notes.find(n => n.path === this.currentMedia);
if (rec) {
rec.size = blob.size;
rec.modified = this._isoNow();
}
this.lastSaved = true;
setTimeout(() => {
this.lastSaved = false;
}, CONFIG.SAVE_INDICATOR_DURATION);
} catch (error) {
ErrorHandler.handle('save drawing', error);
} finally {
this.isSaving = false;
}
} finally {
this._drawingSaveInFlight = false;
if (this._drawingSaveQueued) {
this._drawingSaveQueued = false;
queueMicrotask(() => {
if (this.currentMedia && this.currentMediaType === 'drawing') {
this.drawingSave();
}
});
}
}
},
// Handle clicks on internal links in preview
handleInternalLink(event) {
// Check if clicked element is a link
const link = event.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href) return;
// Check if it's an external link or API path (media files, etc.)
// Safe external protocols: http, https, mailto, tel, ssh, ftp, sftp, and app deep links
const externalProtocols = ['http://', 'https://', '//', 'mailto:', 'tel:', 'ssh:', 'ftp:', 'sftp:', 'slack:', 'discord:', 'teams:', 'vscode:', 'zoom:', 'whatsapp:', 'telegram:', 'signal:', 'spotify:', 'steam:', 'magnet:', '/api/'];
if (externalProtocols.some(p => href.startsWith(p))) {
return; // Let external links and API paths work normally
}
// Prevent default navigation for internal links
event.preventDefault();
// Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section")
const decodedHref = decodeURIComponent(href);
const hashIndex = decodedHref.indexOf('#');
const notePath = hashIndex !== -1 ? decodedHref.substring(0, hashIndex) : decodedHref;
const anchor = hashIndex !== -1 ? decodedHref.substring(hashIndex + 1) : null;
// If it's just an anchor link (#heading), scroll within current note
if (!notePath && anchor) {
this.scrollToAnchor(anchor);
return;
}
// Skip if no path
if (!notePath) return;
// Find the note by path (try exact match first, then with .md extension)
let targetNote = this.notes.find(n =>
n.path === notePath ||
n.path === notePath + '.md'
);
if (!targetNote) {
// Try to find by name (in case link uses just the note name without path)
targetNote = this.notes.find(n =>
n.name === notePath ||
n.name === notePath + '.md' ||
n.name.toLowerCase() === notePath.toLowerCase() ||
n.name.toLowerCase() === (notePath + '.md').toLowerCase()
);
}
if (!targetNote) {
// Last resort: case-insensitive path matching
targetNote = this.notes.find(n =>
n.path.toLowerCase() === notePath.toLowerCase() ||
n.path.toLowerCase() === (notePath + '.md').toLowerCase()
);
}
if (targetNote) {
// Load the note, then scroll to anchor if present
this.loadNote(targetNote.path).then(() => {
if (anchor) {
// Small delay to ensure content is rendered
setTimeout(() => this.scrollToAnchor(anchor), 100);
}
});
} else {
this.confirmModalAsk({
message: this.t('notes.create_from_link', { path: notePath }),
danger: false,
title: this.t('sidebar.new_note'),
confirmLabel: this.t('common.create'),
}).then((ok) => {
if (ok) this.createNote(null, notePath);
});
}
},
// Scroll to an anchor (heading) by slug - reuses outline data
scrollToAnchor(anchor) {
// Normalize the anchor (GitHub-style slug)
const targetSlug = anchor
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
// Find matching heading in outline
const heading = this.outline.find(h => h.slug === targetSlug);
if (heading) {
this.scrollToHeading(heading);
} else {
// Fallback: try to find heading by exact text match
const headingByText = this.outline.find(h =>
h.text.toLowerCase().replace(/\s+/g, '-') === anchor.toLowerCase()
);
if (headingByText) {
this.scrollToHeading(headingByText);
}
}
},
cancelDrag() {
// Cancel any active drag operation (triggered by ESC key)
this.draggedItem = null;
this.dropTarget = null;
this.dragOverFolder = null;
// Reset styles - only query elements with drag-over class (more efficient)
document.querySelectorAll('.folder-item').forEach(el => el.style.opacity = '1');
document.querySelectorAll('.note-item').forEach(el => el.style.opacity = '1');
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
},
async onFolderDrop(targetFolderPath) {
// Ignore if we're dropping into the editor
if (this.dropTarget === 'editor') {
return;
}
// Capture dragged item info immediately (ondragend may clear it)
if (!this.draggedItem) return;
const { path: draggedPath, type: draggedType } = this.draggedItem;
// Determine item category for endpoint selection
const isFolder = draggedType === 'folder';
const isNote = draggedType === 'note';
const isMedia = !isFolder && !isNote; // image, audio, video, document
// Handle folder drop
if (isFolder) {
// Prevent dropping folder into itself or its subfolders
if (targetFolderPath === draggedPath ||
targetFolderPath.startsWith(draggedPath + '/')) {
this.toast(this.t('folders.cannot_move_into_self'), { type: 'warning' });
return;
}
const folderName = draggedPath.split('/').pop();
const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName;
if (newPath === draggedPath) return;
const oldPrefix = draggedPath + '/';
const newPrefix = newPath + '/';
const wasExpanded = this.expandedFolders.has(draggedPath);
this._optimisticRenameFolderTree(draggedPath, newPath);
this._rebuildTreeAfterMutation();
const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix));
if (favoritesInFolder.length > 0) {
const newFavorites = this.favorites.map(f =>
f.startsWith(oldPrefix) ? newPrefix + f.substring(oldPrefix.length) : f
);
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
if (wasExpanded) {
this.expandedFolders.delete(draggedPath);
this.expandedFolders.add(newPath);
this.saveExpandedFolders();
}
if (this.currentNote && this.currentNote.startsWith(oldPrefix)) {
this.currentNote = newPrefix + this.currentNote.substring(oldPrefix.length);
}
try {
const response = await fetch('/api/folders/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPath: draggedPath, newPath })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || this.t('move.failed_folder'));
}
await this.loadSharedNotePaths();
} catch (error) {
console.error('Failed to move folder:', error);
this.toast(error.message || this.t('move.failed_folder'), { type: 'error' });
await this.loadNotes({ silent: true });
}
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;
const wasFavorited = isNote && this.favoritesSet.has(draggedPath);
const wasCurrentNote = this.currentNote === draggedPath;
const wasCurrentMedia = this.currentMedia === draggedPath;
this._optimisticRenameNote(draggedPath, newPath);
this._rebuildTreeAfterMutation();
if (wasFavorited) {
this.favorites = this.favorites.map(f => f === draggedPath ? newPath : f);
this.favoritesSet = new Set(this.favorites);
this.saveFavorites();
}
if (wasCurrentNote) this.currentNote = newPath;
if (wasCurrentMedia) this.currentMedia = newPath;
try {
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) {
const errorData = await response.json().catch(() => ({}));
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
throw new Error(errorData.detail || this.t(errorKey));
}
if (isNote) await this.loadSharedNotePaths();
} catch (error) {
console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error);
this.toast(error.message || this.t(isMedia ? 'move.failed_media' : 'move.failed_note'), { type: 'error' });
await this.loadNotes({ silent: true });
}
},
// Load a specific note
async loadNote(notePath, updateHistory = true, searchQuery = '') {
try {
// Close mobile sidebar when a note is selected
this.mobileSidebarOpen = false;
const response = await fetch(`/api/notes/${notePath}`);
// Check if note exists
if (!response.ok) {
if (response.status === 404) {
// Note not found - silently redirect to home
window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/');
this.currentNote = '';
this.noteContent = '';
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
}
this.currentMedia = '';
document.title = this.appName;
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.currentNote = notePath;
this._lastRenderedContent = ''; // Clear render cache for new note
this._lastRenderedNote = '';
this._cachedRenderedHTML = '';
this._initializedVideoSources = new Set(); // Clear video cache for new note
this.noteContent = data.content;
// Note: scroll restoration happens later in the post-$nextTick block below
// (where the existing scrollToTop call used to live), via _restoreNoteScroll().
// Doing it there means a single, unified restore path instead of two racing
// ones. See the post-$nextTick block for the full timing rationale.
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
}
this.currentMedia = ''; // Clear image viewer when loading a note
this.shareInfo = null; // Reset share info for new note
// Update browser tab title
document.title = `${this.currentNoteName} - ${this.appName}`;
this.lastSaved = false;
// Extract outline for TOC panel
this.extractOutline(data.content);
// Store backlinks from API response
this.backlinks = data.backlinks || [];
// Initialize undo/redo history for this note (with cursor at start)
this.undoHistory = [{ content: data.content, cursorPos: 0 }];
this.redoHistory = [];
this.hasPendingHistoryChanges = false;
// Update browser URL and history
if (updateHistory) {
// Encode the path properly (spaces become %20, etc.)
const pathWithoutExtension = notePath.replace('.md', '');
// Encode each path segment to handle special characters
const encodedPath = pathWithoutExtension.split('/').map(segment => encodeURIComponent(segment)).join('/');
let url = `/${encodedPath}`;
// Add search query parameter if present
if (searchQuery) {
url += `?search=${encodeURIComponent(searchQuery)}`;
}
window.history.pushState(
{
notePath: notePath,
searchQuery: searchQuery,
homepageFolder: this.selectedHomepageFolder || '' // Save current folder state
},
'',
url
);
}
// Calculate stats if plugin enabled
if (this.statsPluginEnabled) {
this.calculateStats();
}
// Parse frontmatter metadata
this.parseMetadata();
// Store search query for highlighting
if (searchQuery) {
this.currentSearchHighlight = searchQuery;
} else {
// Clear highlights if no search query
this.currentSearchHighlight = '';
}
// Expand folder tree to show the loaded note
this.expandFolderForNote(notePath);
// Use $nextTick twice to ensure Alpine.js has time to:
// 1. First tick: expand folders and update DOM
// 2. Second tick: highlight the note and setup everything else
this.$nextTick(() => {
this.$nextTick(() => {
this.refreshDOMCache();
this.setupScrollSync();
// First pass: editor is ready (textarea reflows synchronously). Preview
// may still have scrollHeight=0 because the markdown render pipeline
// (marked + MathJax + Mermaid + syntax highlighting) is async.
this._restoreNoteScroll();
// Second pass on a later frame: by now the preview pane has typically
// rendered and laid out, so its scrollTop write actually sticks. The
// helper is idempotent — re-running for the editor is a no-op.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this._restoreNoteScroll();
});
});
// Apply or clear search highlighting
if (searchQuery) {
// Pass true to focus editor when loading from search result
this.highlightSearchTerm(searchQuery, true);
} else {
this.clearSearchHighlights();
}
// Scroll note into view in sidebar if needed
this.scrollNoteIntoView(notePath);
});
});
} catch (error) {
ErrorHandler.handle('load note', error);
}
},
// Load item (note or media) from URL path
loadItemFromURL() {
// Get path from URL (e.g., /folder/note or /folder/image.png)
let path = window.location.pathname;
// Strip .md extension if present (for MKdocs/Zensical integration)
if (path.toLowerCase().endsWith('.md')) {
path = path.slice(0, -3);
// Update URL bar to show clean path without .md
window.history.replaceState(null, '', path);
}
// Skip if root path or static assets
if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) {
return;
}
// Remove leading slash and decode URL encoding (e.g., %20 -> space)
const decodedPath = decodeURIComponent(path.substring(1));
// Check if this is a media file (image, audio, video, PDF)
const matchedItem = this.notes.find(n => n.path === decodedPath);
if (matchedItem && matchedItem.type !== 'note') {
// It's a media file, view it
this.viewMedia(decodedPath, matchedItem.type, false); // false = don't update history
} else {
// It's a note, add .md extension and load it
const notePath = decodedPath + '.md';
// Parse query string for search parameter
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get('search');
// Try to load the note directly - the backend will handle 404 if it doesn't exist
// This is more robust than checking the frontend notes list
this.loadNote(notePath, false, searchParam || '');
// If there's a search parameter, populate the search box and trigger search
if (searchParam) {
this.searchQuery = searchParam;
// Trigger search to populate results list
this.searchNotes();
}
}
},
// Highlight search term in editor and preview
highlightSearchTerm(query, focusEditor = false) {
if (!query || !query.trim()) {
this.clearSearchHighlights();
return;
}
const searchTerm = query.trim();
// Highlight in editor (textarea)
this.highlightInEditor(searchTerm, focusEditor);
// Highlight in preview (rendered HTML)
this.highlightInPreview(searchTerm);
},
// Highlight search term in the editor textarea
highlightInEditor(searchTerm, shouldFocus = false) {
const editor = this._domCache.editor || document.getElementById('editor');
if (!editor) return;
// For textarea, we can't directly highlight text, but we can scroll to first match
const content = editor.value;
const lowerContent = content.toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const index = lowerContent.indexOf(lowerTerm);
if (index !== -1) {
// Calculate line number to scroll to
const textBefore = content.substring(0, index);
const lineNumber = textBefore.split('\n').length;
// Scroll to approximate position
const lineHeight = 20; // Approximate line height in pixels
editor.scrollTop = (lineNumber - 5) * lineHeight; // Scroll a bit above to show context
// Only focus and select if explicitly requested (e.g., from search result click)
if (shouldFocus) {
editor.focus();
editor.setSelectionRange(index, index + searchTerm.length);
// Blur immediately so the selection stays visible but editor isn't focused
setTimeout(() => editor.blur(), 100);
}
}
},
// Highlight search term in the preview pane
highlightInPreview(searchTerm) {
const preview = document.querySelector('.markdown-preview');
if (!preview) return;
// Remove existing highlights
this.clearSearchHighlights();
// Create a tree walker to find all text nodes
const walker = document.createTreeWalker(
preview,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
// Skip code blocks and pre tags
if (node.parentElement.tagName === 'CODE' ||
node.parentElement.tagName === 'PRE') {
continue;
}
textNodes.push(node);
}
const lowerTerm = searchTerm.toLowerCase();
let matchIndex = 0;
// Highlight matches in text nodes
textNodes.forEach(textNode => {
const text = textNode.textContent;
const lowerText = text.toLowerCase();
if (lowerText.includes(lowerTerm)) {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
let index;
while ((index = lowerText.indexOf(lowerTerm, lastIndex)) !== -1) {
// Add text before match
if (index > lastIndex) {
fragment.appendChild(
document.createTextNode(text.substring(lastIndex, index))
);
}
// Add highlighted match
const mark = document.createElement('mark');
mark.className = 'search-highlight';
mark.setAttribute('data-match-index', matchIndex);
mark.textContent = text.substring(index, index + searchTerm.length);
// First match is active (styled via CSS)
if (matchIndex === 0) {
mark.classList.add('active-match');
}
fragment.appendChild(mark);
matchIndex++;
lastIndex = index + searchTerm.length;
}
// Add remaining text
if (lastIndex < text.length) {
fragment.appendChild(
document.createTextNode(text.substring(lastIndex))
);
}
// Replace text node with highlighted fragment
textNode.parentNode.replaceChild(fragment, textNode);
}
});
// Update total matches and reset current index
this.totalMatches = matchIndex;
this.currentMatchIndex = matchIndex > 0 ? 0 : -1;
// Scroll to first match
if (this.totalMatches > 0) {
this.scrollToMatch(0);
}
},
// Navigate to next search match
nextMatch() {
if (this.totalMatches === 0) return;
this.currentMatchIndex = (this.currentMatchIndex + 1) % this.totalMatches;
this.scrollToMatch(this.currentMatchIndex);
},
// Navigate to previous search match
previousMatch() {
if (this.totalMatches === 0) return;
this.currentMatchIndex = (this.currentMatchIndex - 1 + this.totalMatches) % this.totalMatches;
this.scrollToMatch(this.currentMatchIndex);
},
// Scroll to a specific match index
scrollToMatch(index) {
const preview = document.querySelector('.markdown-preview');
if (!preview) return;
const allMatches = preview.querySelectorAll('mark.search-highlight');
if (index < 0 || index >= allMatches.length) return;
// Update styling - make current match prominent (via CSS class)
allMatches.forEach((mark, i) => {
mark.classList.toggle('active-match', i === index);
});
// Scroll to the match
const targetMatch = allMatches[index];
const previewContainer = this._domCache.previewContainer;
if (previewContainer && targetMatch) {
const elementTop = targetMatch.offsetTop;
previewContainer.scrollTop = elementTop - 100; // Scroll with some offset
}
},
// Clear search highlights
clearSearchHighlights() {
const preview = document.querySelector('.markdown-preview');
if (!preview) return;
const highlights = preview.querySelectorAll('mark.search-highlight');
highlights.forEach(mark => {
const text = document.createTextNode(mark.textContent);
mark.parentNode.replaceChild(text, mark);
});
// Normalize text nodes to merge adjacent text nodes
preview.normalize();
// Reset match counters
this.totalMatches = 0;
this.currentMatchIndex = -1;
},
// =====================================================
// DROPDOWN MENU SYSTEM
// =====================================================
// Shift+click always forces the chooser regardless of newButtonAction.
toggleNewDropdown(event) {
const wantsChooser =
(event && event.shiftKey) ||
!this.newButtonAction ||
this.newButtonAction === 'chooser';
if (wantsChooser) {
this._openNewDropdownChooser(event);
return;
}
switch (this.newButtonAction) {
case 'note': this.createNote(); break;
case 'folder': this.createFolder(); break;
case 'template': this.openTemplateModal(); break;
case 'drawing': this.createNewDrawing(); break;
default: this._openNewDropdownChooser(event);
}
},
_openNewDropdownChooser(event) {
this.showNewDropdown = true;
if (event && event.target) {
const rect = event.target.getBoundingClientRect();
let top = rect.bottom + 4;
let left = rect.left;
const dropdownWidth = 200;
const dropdownHeight = 150;
if (left + dropdownWidth > window.innerWidth) {
left = rect.right - dropdownWidth;
}
if (top + dropdownHeight > window.innerHeight) {
top = rect.top - dropdownHeight - 4;
}
this.dropdownPosition = { top, left };
}
},
openTemplateModal() {
this.showNewDropdown = false;
this.mobileSidebarOpen = false;
const hasLastUsed = !!this.lastUsedTemplate &&
this.availableTemplates.some(t => t.name === this.lastUsedTemplate);
this.selectedTemplate = hasLastUsed ? this.lastUsedTemplate : '';
this.newTemplateNoteName = this.autoFillNoteTitle ? this._autoTitleTimestamp() : '';
this.showTemplateModal = true;
// Only steal focus when the modal is one-Enter-away from submission.
if (hasLastUsed) {
this.$nextTick(() => {
const el = document.getElementById('template-note-name-input');
if (el) {
el.focus();
el.select();
}
});
}
},
closeDropdown() {
this.showNewDropdown = false;
this.dropdownTargetFolder = null; // Reset folder context
},
/**
* Parent folder for new note/folder/drawing from the + menu when no explicit path is passed.
* Same rules as the create-name modal: '' = vault root; otherwise a folder path (e.g. folder1/sub).
*/
inferredNewItemTargetFolder() {
if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
return this.dropdownTargetFolder;
}
return this.selectedHomepageFolder || '';
},
// =====================================================
// UNIFIED CREATION FUNCTIONS (reusable from anywhere)
// =====================================================
// Switch to split view (if in preview-only mode) and focus editor for new notes
focusEditorForNewNote() {
// Only switch if in preview-only mode - don't disturb edit or split mode
if (this.viewMode === 'preview') {
this.viewMode = 'split';
this.saveViewMode();
}
// Focus the editor after a short delay to ensure DOM is updated
this.$nextTick(() => {
const editor = document.getElementById('note-editor');
if (editor) editor.focus();
});
},
async createNote(folderPath = null, directPath = null) {
if (directPath) {
const notePath = directPath.endsWith('.md') ? directPath : `${directPath}.md`;
const existingNote = this.notes.find(note => note.path === notePath);
if (existingNote) {
this.toast(this.t('notes.already_exists', { name: notePath }), { type: 'warning' });
return;
}
await this._finalizeCreateNote(notePath);
return;
}
const explicitTarget = folderPath !== null && folderPath !== undefined ? folderPath : undefined;
this.openCreateNameModal('note', explicitTarget);
},
/**
* @param {'note'|'folder'} kind
* @param {string|undefined} explicitTargetFolder - if set, use as parent folder context ("" = root)
*/
openCreateNameModal(kind, explicitTargetFolder = undefined) {
const targetFolder =
explicitTargetFolder !== undefined ? explicitTargetFolder : this.inferredNewItemTargetFolder();
this.closeDropdown();
this.mobileSidebarOpen = false;
this.createNameModalKind = kind;
this.createNameModalTargetFolder = targetFolder;
this.createNameModalInput =
(kind === 'note' && this.autoFillNoteTitle) ? this._autoTitleTimestamp() : '';
this.showCreateNameModal = true;
this.$nextTick(() => {
const el = document.getElementById('create-name-modal-input');
if (el) {
el.focus();
el.select();
}
});
},
// Zettelkasten-style yyyymmddHHMMSS in local time.
_autoTitleTimestamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
`${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
},
closeCreateNameModal() {
this.showCreateNameModal = false;
this.createNameModalInput = '';
},
cancelCreateNameModal() {
this.closeCreateNameModal();
},
createNameModalHelpText() {
const tf = this.createNameModalTargetFolder;
if (this.createNameModalKind === 'note') {
return tf
? this.t('notes.prompt_name_in_folder', { folder: tf })
: this.t('notes.prompt_name_with_path');
}
return tf
? this.t('folders.prompt_name_in_folder', { folder: tf })
: this.t('folders.prompt_name_with_path');
},
createNameModalTitle() {
return this.createNameModalKind === 'note'
? this.t('sidebar.new_note')
: this.t('sidebar.new_folder');
},
createNameModalLabel() {
return this.createNameModalKind === 'note'
? this.t('notes.prompt_name')
: this.t('folders.prompt_name');
},
async submitCreateNameModal() {
const rawName = (this.createNameModalInput || '').trim();
if (!rawName) {
this.closeCreateNameModal();
return;
}
const targetFolder = this.createNameModalTargetFolder;
const kind = this.createNameModalKind;
if (kind === 'note') {
const validation = targetFolder
? FilenameValidator.validateFilename(rawName)
: FilenameValidator.validatePath(rawName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
return;
}
const validatedName = validation.sanitized;
const notePath = targetFolder
? `${targetFolder}/${validatedName}.md`
: (validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`);
const existingNote = this.notes.find(note => note.path === notePath);
if (existingNote) {
this.toast(this.t('notes.already_exists', { name: notePath }), { type: 'warning' });
return;
}
const ok = await this._finalizeCreateNote(notePath);
if (ok) this.closeCreateNameModal();
return;
}
const validation = targetFolder
? FilenameValidator.validateFilename(rawName)
: FilenameValidator.validatePath(rawName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' });
return;
}
const validatedName = validation.sanitized;
const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
const existingFolder = this.allFolders.find(folder => folder === folderPath);
if (existingFolder) {
this.toast(this.t('folders.already_exists', { name: validatedName }), { type: 'warning' });
return;
}
const ok = await this._finalizeCreateFolder(folderPath, targetFolder);
if (ok) this.closeCreateNameModal();
},
async _finalizeCreateNote(notePath) {
// Optimistic add — sidebar reflects the new note instantly. Server
// confirms; on failure we resync silently.
this._optimisticAddNote(notePath, { content: '' });
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
if (folderPart) this.expandedFolders.add(folderPart);
this._rebuildTreeAfterMutation();
try {
const response = await fetch(`/api/notes/${notePath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: '' })
});
if (!response.ok) throw new Error('Server returned error');
await this.loadNote(notePath);
this.focusEditorForNewNote();
return true;
} catch (error) {
ErrorHandler.handle('create note', error);
await this.loadNotes({ silent: true });
return false;
}
},
async _finalizeCreateFolder(folderPath, targetFolder) {
this._optimisticAddFolder(folderPath);
if (targetFolder) this.expandedFolders.add(targetFolder);
this.expandedFolders.add(folderPath);
this._rebuildTreeAfterMutation();
try {
const response = await fetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: folderPath })
});
if (!response.ok) throw new Error('Server returned error');
this.goToHomepageFolder(folderPath);
return true;
} catch (error) {
ErrorHandler.handle('create folder', error);
await this.loadNotes({ silent: true });
return false;
}
},
async createFolder(parentPath = null) {
const explicitTarget = parentPath !== null && parentPath !== undefined ? parentPath : undefined;
this.openCreateNameModal('folder', explicitTarget);
},
renameFolder(folderPath, currentName) {
this.renameFolderPath = folderPath;
this.renameFolderOldName = currentName;
this.renameFolderInput = currentName;
this.showRenameFolderModal = true;
this.mobileSidebarOpen = false;
this.$nextTick(() => {
const el = document.getElementById('rename-folder-modal-input');
if (el) {
el.focus();
el.select();
}
});
},
closeRenameFolderModal() {
this.showRenameFolderModal = false;
this.renameFolderPath = '';
this.renameFolderOldName = '';
this.renameFolderInput = '';
},
cancelRenameFolderModal() {
this.closeRenameFolderModal();
},
/**
* Theme-styled confirm dialog (replaces window.confirm).
* @param {{ message: string, title?: string, danger?: boolean, confirmLabel?: string, cancelLabel?: string }} options
* @returns {Promise}
*/
confirmModalAsk(options = {}) {
const { message, title, danger = true, confirmLabel, cancelLabel } = options;
return new Promise((resolve) => {
this.confirmModalTitle = title || this.t('common.confirm_title');
this.confirmModalMessage = message || '';
this.confirmModalDanger = danger;
this.confirmModalConfirmLabel = confirmLabel
|| (danger ? this.t('common.delete') : this.t('common.ok'));
this.confirmModalCancelLabel = cancelLabel || this.t('common.cancel');
this._confirmModalResolve = resolve;
this.showConfirmModal = true;
this.mobileSidebarOpen = false;
});
},
confirmModalConfirm() {
this._confirmModalFinish(true);
},
confirmModalCancel() {
this._confirmModalFinish(false);
},
_confirmModalFinish(ok) {
if (!this._confirmModalResolve) return;
this.showConfirmModal = false;
const fn = this._confirmModalResolve;
this._confirmModalResolve = null;
fn(!!ok);
},
async submitRenameFolderModal() {
const folderPath = this.renameFolderPath;
const currentName = this.renameFolderOldName;
const newName = (this.renameFolderInput || '').trim();
if (!newName || newName === currentName) {
this.closeRenameFolderModal();
return;
}
const validation = FilenameValidator.validateFilename(newName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' });
return;
}
const validatedName = validation.sanitized;
const pathParts = folderPath.split('/');
pathParts[pathParts.length - 1] = validatedName;
const newPath = pathParts.join('/');
const ok = await this._finalizeRenameFolder(folderPath, newPath);
if (ok) this.closeRenameFolderModal();
},
async _finalizeRenameFolder(folderPath, newPath) {
const folderPrefix = folderPath + '/';
const newFolderPrefix = newPath + '/';
// Optimistic cascade: rewrite folder + every nested folder + every
// note path. Sidebar reflects the rename instantly.
this._optimisticRenameFolderTree(folderPath, newPath);
this._rebuildTreeAfterMutation();
if (this.expandedFolders.has(folderPath)) {
this.expandedFolders.delete(folderPath);
this.expandedFolders.add(newPath);
}
const newFavorites = this.favorites.map(f =>
f.startsWith(folderPrefix) ? newFolderPrefix + f.substring(folderPrefix.length) : 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 = newFolderPrefix + this.currentNote.substring(folderPrefix.length);
}
try {
const response = await fetch('/api/folders/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPath: folderPath, newPath: newPath })
});
if (!response.ok) throw new Error('Server returned error');
return true;
} catch (error) {
ErrorHandler.handle('rename folder', error);
await this.loadNotes({ silent: true });
return false;
}
},
// Delete folder (cascade: every nested folder + note)
async deleteFolder(folderPath, folderName) {
const ok = await this.confirmModalAsk({
message: this.t('folders.confirm_delete', { name: folderName }),
});
if (!ok) return;
const folderPrefix = folderPath + '/';
this._optimisticRemoveFolderTree(folderPath);
this._rebuildTreeAfterMutation();
this.expandedFolders.delete(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();
}
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
this.currentNote = '';
this.noteContent = '';
document.title = this.appName;
}
try {
const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) throw new Error('Server returned error');
this.loadTagsDebounced();
} catch (error) {
ErrorHandler.handle('delete folder', error);
await this.loadNotes({ silent: true });
}
},
// Auto-save with debounce
autoSave() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.lastSaved = false;
// Push to undo history (but not during undo/redo operations)
if (!this.isUndoRedo) {
this.pushToHistory();
}
// Calculate stats in real-time if plugin enabled
if (this.statsPluginEnabled) {
this.calculateStats();
}
// Parse metadata in real-time
this.parseMetadata();
// Update outline (TOC) in real-time
this.extractOutline(this.noteContent);
this.saveTimeout = setTimeout(() => {
// Commit to undo history when autosave triggers (same debounce timing)
if (this.hasPendingHistoryChanges) {
this.commitToHistory();
}
this.saveNote();
}, this.autosaveDelayMs);
},
// Mark that we have pending changes (called on each keystroke)
pushToHistory() {
this.hasPendingHistoryChanges = true;
},
// Immediately commit pending changes to history (call before undo/redo)
flushHistory() {
if (this.hasPendingHistoryChanges) {
this.commitToHistory();
}
},
// Actually commit to undo history (internal)
commitToHistory() {
const editor = document.getElementById('note-editor');
const cursorPos = editor ? editor.selectionStart : 0;
// Only push if content actually changed from last history entry
if (this.undoHistory.length > 0 &&
this.undoHistory[this.undoHistory.length - 1].content === this.noteContent) {
this.hasPendingHistoryChanges = false;
return;
}
this.undoHistory.push({ content: this.noteContent, cursorPos });
// Limit history size
if (this.undoHistory.length > this.maxHistorySize) {
this.undoHistory.shift();
}
// Clear redo history when new change is made
this.redoHistory = [];
this.hasPendingHistoryChanges = false;
},
// Undo last change
undo() {
if (!this.currentNote) return;
// Flush any pending history changes first (so we don't lose unsaved edits)
this.flushHistory();
if (this.undoHistory.length <= 1) return;
const editor = document.getElementById('note-editor');
// Pop current state to redo history
const currentState = this.undoHistory.pop();
this.redoHistory.push(currentState);
// Get previous state
const previousState = this.undoHistory[this.undoHistory.length - 1];
// Apply previous state
this.isUndoRedo = true;
this.noteContent = previousState.content;
// Recalculate stats with new content
if (this.statsPluginEnabled) {
this.calculateStats();
}
// Restore cursor position from the state we're going back to
this.$nextTick(() => {
this.saveNote();
this.isUndoRedo = false;
if (editor) {
setTimeout(() => {
const newPos = Math.min(previousState.cursorPos, this.noteContent.length);
editor.setSelectionRange(newPos, newPos);
editor.focus();
}, 0);
}
});
},
// Redo last undone change
redo() {
if (!this.currentNote) return;
// Flush any pending history changes first
this.flushHistory();
if (this.redoHistory.length === 0) return;
const editor = document.getElementById('note-editor');
// Pop from redo history
const nextState = this.redoHistory.pop();
// Push to undo history
this.undoHistory.push(nextState);
// Apply next state
this.isUndoRedo = true;
this.noteContent = nextState.content;
// Recalculate stats with new content
if (this.statsPluginEnabled) {
this.calculateStats();
}
// Restore cursor position from the state we're going forward to
this.$nextTick(() => {
this.saveNote();
this.isUndoRedo = false;
if (editor) {
setTimeout(() => {
const newPos = Math.min(nextState.cursorPos, this.noteContent.length);
editor.setSelectionRange(newPos, newPos);
editor.focus();
}, 0);
}
});
},
// Markdown formatting helpers
// Trim trailing whitespace from double-click word selections.
// No-op on browsers that already exclude it (Firefox, Safari).
trimSelectionTrailingSpace(event) {
const editor = event && event.target;
if (!editor) return;
const start = editor.selectionStart;
let end = editor.selectionEnd;
if (start >= end) return;
const text = editor.value;
while (end > start && /\s/.test(text.charAt(end - 1))) end--;
if (end !== editor.selectionEnd) {
editor.setSelectionRange(start, end);
}
},
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);
// Push edge whitespace OUTSIDE inline wrappers — "**house **" is
// invalid per CommonMark. Block wrappers (with \n) keep it intact.
const isInline = !before.includes('\n') && !after.includes('\n');
let leading = '', trailing = '', core = selectedText;
if (isInline && selectedText) {
const m = selectedText.match(/^(\s*)([\s\S]*?)(\s*)$/);
leading = m[1];
core = m[2];
trailing = m[3];
}
const textToWrap = core || placeholder;
// Build the new text
const newText = leading + before + textToWrap + after + trailing;
// Update content
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
this.$nextTick(() => {
const coreStart = start + leading.length + before.length;
const coreEnd = coreStart + textToWrap.length;
editor.setSelectionRange(coreStart, coreEnd);
editor.focus();
});
// Trigger autosave
this.autoSave();
},
insertLink() {
const editor = document.getElementById('note-editor');
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = this.noteContent.substring(start, end);
// If text is selected, use it as link text; otherwise use placeholder
const linkText = selectedText || 'link text';
const linkUrl = 'url';
// Build the markdown link
const newText = `[${linkText}](${linkUrl})`;
// Update content
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
// Set cursor position to select the URL part for easy editing
this.$nextTick(() => {
const urlStart = start + linkText.length + 3; // After "[linkText]("
const urlEnd = urlStart + linkUrl.length;
editor.setSelectionRange(urlStart, urlEnd);
editor.focus();
});
// Trigger autosave
this.autoSave();
},
// Insert a markdown table placeholder
insertTable() {
const editor = document.getElementById('note-editor');
if (!editor) return;
const cursorPos = editor.selectionStart;
// Basic 3x3 table placeholder
const table = `| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
`;
// Add newline before if not at start of line
const textBefore = this.noteContent.substring(0, cursorPos);
const needsNewlineBefore = textBefore.length > 0 && !textBefore.endsWith('\n');
const prefix = needsNewlineBefore ? '\n\n' : '';
// Insert the table
this.noteContent = textBefore + prefix + table + this.noteContent.substring(cursorPos);
// Position cursor at first header for easy editing
this.$nextTick(() => {
const newPos = cursorPos + prefix.length + 2; // After "| "
editor.setSelectionRange(newPos, newPos + 8); // Select "Header 1"
editor.focus();
});
// Trigger autosave
this.autoSave();
},
// Format selected text or insert formatting at cursor
formatText(type) {
// Simple wrap cases - reuse wrapSelection()
const wrapFormats = {
'bold': ['**', '**', 'bold'],
'italic': ['*', '*', 'italic'],
'strikethrough': ['~~', '~~', 'strikethrough'],
'code': ['`', '`', 'code']
};
if (wrapFormats[type]) {
const [before, after, placeholder] = wrapFormats[type];
this.wrapSelection(before, after, placeholder);
return;
}
// Special cases that need custom handling
switch (type) {
case 'heading':
this.insertLinePrefix('## ', 'Heading');
break;
case 'quote':
this.insertLinePrefix('> ', 'quote');
break;
case 'bullet':
this.insertLinePrefix('- ', 'item');
break;
case 'numbered':
this.insertLinePrefix('1. ', 'item');
break;
case 'checkbox':
this.insertLinePrefix('- [ ] ', 'task');
break;
case 'link':
this.insertLink();
break;
case 'image':
this.wrapSelection('', 'alt text');
break;
case 'codeblock':
this.wrapSelection('```\n', '\n```', 'code');
break;
case 'table':
this.insertTable();
break;
}
},
// Insert a line prefix (for headings, lists, quotes)
insertLinePrefix(prefix, placeholder) {
const editor = document.getElementById('note-editor');
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = this.noteContent.substring(start, end);
const beforeText = this.noteContent.substring(0, start);
const afterText = this.noteContent.substring(end);
// Check if at start of line
const atLineStart = beforeText.endsWith('\n') || beforeText === '';
const newline = atLineStart ? '' : '\n';
let replacement;
if (selectedText) {
// Prefix each line of selection
replacement = newline + selectedText.split('\n').map((line, i) => {
// For numbered lists, increment the number
if (prefix === '1. ') return `${i + 1}. ${line}`;
return prefix + line;
}).join('\n');
} else {
replacement = newline + prefix + placeholder;
}
this.noteContent = beforeText + replacement + afterText;
this.$nextTick(() => {
if (selectedText) {
editor.setSelectionRange(start + newline.length, start + replacement.length);
} else {
const placeholderStart = start + newline.length + prefix.length;
editor.setSelectionRange(placeholderStart, placeholderStart + placeholder.length);
}
editor.focus();
});
this.autoSave();
},
// Save current note
async saveNote() {
if (!this.currentNote) return;
this.isSaving = true;
try {
const response = await fetch(`/api/notes/${this.currentNote}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: this.noteContent })
});
if (response.ok) {
this.lastSaved = true;
// Update only the modified timestamp for the current note (no full reload needed)
const note = this.notes.find(n => n.path === this.currentNote);
if (note) {
note.modified = new Date().toISOString();
note.size = new Blob([this.noteContent]).size;
// Parse tags from content
note.tags = this.parseTagsFromContent(this.noteContent);
}
// Reload tags to update sidebar counts (debounced to prevent spam)
this.loadTagsDebounced();
// Rebuild folder tree if tag filters are active
if (this.selectedTags.length > 0) {
this.buildFolderTree();
}
// Hide "saved" indicator
setTimeout(() => {
this.lastSaved = false;
}, CONFIG.SAVE_INDICATOR_DURATION);
} else {
ErrorHandler.handle('save note', new Error('Server returned error'));
}
} catch (error) {
ErrorHandler.handle('save note', error);
} finally {
this.isSaving = false;
}
},
// Rename current note
async renameNote() {
if (!this.currentNote) return;
const oldPath = this.currentNote;
const newName = this.currentNoteName.trim();
if (!newName) {
this.toast(this.t('notes.empty_name'), { type: 'warning' });
return;
}
// Validate the new name (single segment, no path separators)
const validation = FilenameValidator.validateFilename(newName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
// Reset the name in the UI
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
return;
}
const validatedName = validation.sanitized;
const folder = oldPath.split('/').slice(0, -1).join('/');
const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`;
if (oldPath === newPath) return;
// Check if a note with the new name already exists
const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
if (existingNote) {
this.toast(this.t('notes.already_exists', { name: validatedName }), { type: 'warning' });
// Reset the name in the UI
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
return;
}
// Optimistic rename: rewrite local path now + favorites + current
// note pointer + URL. POST new content, then DELETE old.
this._optimisticRenameNote(oldPath, newPath);
this._rebuildTreeAfterMutation();
if (this.favoritesSet.has(oldPath)) {
this.favorites = this.favorites.map(f => f === oldPath ? newPath : f);
this.favoritesSet = new Set(this.favorites);
this.saveFavorites();
}
this.currentNote = newPath;
try {
const response = await fetch(`/api/notes/${newPath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: this.noteContent })
});
if (!response.ok) throw new Error('Server returned error');
await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' });
} catch (error) {
ErrorHandler.handle('rename note', error);
await this.loadNotes({ silent: true });
}
},
// Delete current note
async deleteCurrentNote() {
if (!this.currentNote) return;
// Just call deleteNote with current note details
await this.deleteNote(this.currentNote, this.currentNoteName);
},
// Delete any note from sidebar
async deleteNote(notePath, noteName) {
const ok = await this.confirmModalAsk({
message: this.t('notes.confirm_delete', { name: noteName }),
});
if (!ok) return;
// Optimistic: remove locally + clear current note + drop favorite
// before the network round-trip. Sidebar refreshes in <1ms on any
// vault size. On error we resync silently from disk.
this._optimisticRemoveNote(notePath);
this._rebuildTreeAfterMutation();
if (this.favoritesSet.has(notePath)) {
this.favorites = this.favorites.filter(f => f !== notePath);
this.favoritesSet = new Set(this.favorites);
this.saveFavorites();
}
if (this.currentNote === notePath) {
this.currentNote = '';
this.noteContent = '';
this.currentNoteName = '';
this._lastRenderedContent = '';
this._lastRenderedNote = '';
this._cachedRenderedHTML = '';
document.title = this.appName;
window.history.replaceState({}, '', '/');
}
try {
const response = await fetch(`/api/notes/${notePath}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Server returned error');
this.loadTagsDebounced();
} catch (error) {
ErrorHandler.handle('delete note', error);
await this.loadNotes({ silent: true });
}
},
// Search notes
debouncedSearchNotes() {
if (this.searchDebounceTimeout) {
clearTimeout(this.searchDebounceTimeout);
}
const hasTextSearch = this.searchQuery.trim().length > 0;
if (!hasTextSearch) {
this.isSearching = false;
this.searchNotes();
return;
}
this.isSearching = true;
this.searchResults = [];
this.searchDebounceTimeout = setTimeout(() => {
this.searchNotes();
}, CONFIG.SEARCH_DEBOUNCE_DELAY);
},
// Search notes by text (calls unified filter logic)
async searchNotes() {
await this.applyFilters();
},
// Trigger MathJax typesetting after DOM update
typesetMath() {
if (typeof MathJax !== 'undefined' && MathJax.typesetPromise) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
const previewContent = document.querySelector('.markdown-preview');
if (previewContent) {
MathJax.typesetPromise([previewContent]).catch((err) => {
console.error('MathJax typesetting failed:', err);
});
}
}, 10);
}
},
// Render Mermaid diagrams
async renderMermaid() {
if (typeof window.mermaid === 'undefined') {
console.warn('Mermaid not loaded yet');
return;
}
// Use requestAnimationFrame for better performance than setTimeout
requestAnimationFrame(async () => {
const previewContent = document.querySelector('.markdown-preview');
if (!previewContent) return;
// Get the appropriate theme based on current app theme
const themeType = this.getThemeType();
const mermaidTheme = themeType === 'light' ? 'default' : 'dark';
// Only reinitialize if theme changed (performance optimization)
if (this.lastMermaidTheme !== mermaidTheme) {
window.mermaid.initialize({
startOnLoad: false,
theme: mermaidTheme,
securityLevel: 'strict', // Use strict for better security
fontFamily: 'inherit',
// v11 changed useMaxWidth defaults - restore responsive behavior
flowchart: { useMaxWidth: true },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
journey: { useMaxWidth: true },
timeline: { useMaxWidth: true },
class: { useMaxWidth: true },
state: { useMaxWidth: true },
er: { useMaxWidth: true },
pie: { useMaxWidth: true },
quadrantChart: { useMaxWidth: true },
requirement: { useMaxWidth: true },
mindmap: { useMaxWidth: true },
gitGraph: { useMaxWidth: true }
});
this.lastMermaidTheme = mermaidTheme;
}
// Find all code blocks with language 'mermaid'
const mermaidBlocks = previewContent.querySelectorAll('pre code.language-mermaid');
// Early return if no diagrams to render
if (mermaidBlocks.length === 0) return;
for (let i = 0; i < mermaidBlocks.length; i++) {
const block = mermaidBlocks[i];
const pre = block.parentElement;
// Skip if already rendered (performance optimization)
if (pre.querySelector('.mermaid-rendered')) continue;
try {
const code = block.textContent;
const id = `mermaid-diagram-${Date.now()}-${i}`;
// Render the diagram
const { svg } = await window.mermaid.render(id, code);
// Create a container for the rendered diagram
const container = document.createElement('div');
container.className = 'mermaid-rendered';
container.style.cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;';
container.innerHTML = svg;
// Store original code for theme re-rendering
container.dataset.originalCode = code;
// Replace the code block with the rendered diagram
pre.parentElement.replaceChild(container, pre);
} catch (error) {
console.error('Mermaid rendering error:', error);
// Add error indicator to the code block
const errorMsg = document.createElement('div');
errorMsg.style.cssText = 'color: var(--error); padding: 10px; border-left: 3px solid var(--error); margin-top: 10px;';
errorMsg.textContent = `⚠️ Mermaid diagram error: ${error.message}`;
pre.parentElement.insertBefore(errorMsg, pre.nextSibling);
}
}
});
},
// Get current theme type (light or dark)
// Returns: 'light' or 'dark'
// Used by features that need to adapt to theme brightness (e.g., Mermaid diagrams, Chart.js)
getThemeType() {
// Handle system theme
if (this.currentTheme === 'system') {
const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return isDark ? 'dark' : 'light';
}
// Try to get theme type from loaded theme metadata
const currentThemeData = this.availableThemes.find(t => t.id === this.currentTheme);
if (currentThemeData && currentThemeData.type) {
// Use metadata from theme file (light or dark)
return currentThemeData.type; // Already 'light' or 'dark'
}
// Backward compatibility: fallback to hardcoded map if metadata not available
const fallbackMap = {
'light': 'light',
'vs-blue': 'light'
};
return fallbackMap[this.currentTheme] || 'dark';
},
// Computed property for rendered markdown
get renderedMarkdown() {
if (!this.noteContent) return '
Nothing to preview yet...
';
// Performance: Return cached HTML if content hasn't changed
if (this.noteContent === this._lastRenderedContent &&
this.currentNote === this._lastRenderedNote &&
this._cachedRenderedHTML) {
return this._cachedRenderedHTML;
}
// Strip YAML frontmatter from content before rendering
let contentToRender = this.noteContent;
if (contentToRender.trim().startsWith('---')) {
const lines = contentToRender.split('\n');
if (lines[0].trim() === '---') {
// Find closing ---
let endIdx = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') {
endIdx = i;
break;
}
}
if (endIdx !== -1) {
// Remove frontmatter (including the closing ---) and any empty lines after it
contentToRender = lines.slice(endIdx + 1).join('\n').trim();
}
}
}
// Convert Obsidian-style wikilinks: [[note]] or [[note|display text]]
// Must be done before marked.parse() to avoid conflicts with markdown syntax
// BUT we need to protect code blocks first to avoid converting [[text]] inside code
const self = this; // Reference for closure
// Step 0: GFM/GLFM callouts. Runs BEFORE code-block extraction so
// fences nested in a callout blockquote lose their `> ` prefix on
// the closer — otherwise CommonMark won't see a valid fence closer
// once restored inside
and the block
// runs unclosed. Fence-aware so `> [!TIP]` literal inside a
// top-level code block is not misread as a callout.
{
const CALLOUT_RE = /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/i;
const CALLOUT_ICONS = { note: 'ℹ️', tip: '💡', important: '❗', warning: '⚠️', caution: '🛑' };
const CALLOUT_TITLES = { note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' };
// Fence opener at 0-3 spaces indent (CommonMark). Captured
// group holds the fence chars so the closer must match char + length.
const FENCE_OPEN_RE = /^\s{0,3}(`{3,}|~{3,})/;
const escapeAttr = (s) => s.replace(/&/g, '&').replace(//g, '>');
const srcLines = contentToRender.split('\n');
const outLines = [];
let li = 0;
let fenceChar = null; // '`' or '~' when inside a top-level fence
let fenceLen = 0;
while (li < srcLines.length) {
const line = srcLines[li];
// Inside a top-level fence: pass through opaquely.
if (fenceChar) {
outLines.push(line);
const closeRe = new RegExp('^\\s{0,3}' + (fenceChar === '`' ? '`' : '~') + '{' + fenceLen + ',}\\s*$');
if (closeRe.test(line)) { fenceChar = null; fenceLen = 0; }
li++;
continue;
}
const fenceOpen = line.match(FENCE_OPEN_RE);
if (fenceOpen) {
fenceChar = fenceOpen[1][0];
fenceLen = fenceOpen[1].length;
outLines.push(line);
li++;
continue;
}
const m = line.match(CALLOUT_RE);
if (!m) {
outLines.push(line);
li++;
continue;
}
const type = m[1].toLowerCase();
const title = escapeAttr((m[2] || '').trim() || CALLOUT_TITLES[type]);
const icon = CALLOUT_ICONS[type];
const bodyLines = [];
li++;
// Fence lines inside the callout also start with `>`, so
// they get absorbed and stripped, leaving a clean fence.
while (li < srcLines.length && srcLines[li].startsWith('>')) {
bodyLines.push(srcLines[li].replace(/^>\s?/, ''));
li++;
}
outLines.push(
'',
`
`,
`
${icon}${title}
`,
`
`,
'',
bodyLines.join('\n'),
'',
`
`,
`
`,
''
);
}
contentToRender = outLines.join('\n');
}
// Step 1: Temporarily replace code blocks and inline code with placeholders
const codeBlocks = [];
// Protect fenced code blocks (```...```)
contentToRender = contentToRender.replace(/```[\s\S]*?```/g, (match) => {
codeBlocks.push(match);
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
});
// Protect inline code (`...`)
contentToRender = contentToRender.replace(/`[^`]+`/g, (match) => {
codeBlocks.push(match);
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
});
// Step 2: Convert media wikilinks FIRST: ![[file.png]] or ![[file.png|alt text]]
// Must be before note wikilinks to prevent [[file.png]] from being matched first
contentToRender = contentToRender.replace(
/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, mediaName, altText) => {
const filename = mediaName.trim();
const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, '');
// Resolve media path using O(1) lookup
const mediaPath = self.resolveMediaWikilink(filename);
if (mediaPath) {
// URL-encode path segments for the API
const encodedPath = mediaPath.split('/').map(segment => {
try {
return encodeURIComponent(decodeURIComponent(segment));
} catch (e) {
return encodeURIComponent(segment);
}
}).join('/');
const safeAlt = alt.replace(/"/g, '"');
const mediaSrc = `/api/media/${encodedPath}`;
const mediaType = self.getMediaType(filename);
// Return appropriate HTML based on media type
switch (mediaType) {
case 'audio':
return `
${safeAlt}
`;
case 'video':
return ``;
case 'document':
// DOMPurify strips