From 47e058331dbf796826a5d25c830478ac54c757f9 Mon Sep 17 00:00:00 2001 From: Parker Grey Addison Date: Tue, 17 Feb 2026 22:56:17 +0100 Subject: [PATCH] CRITICAL FIX #17: Render error when heading not found (#22) Fixes the destructive behavior where notes were overwritten if an embed pointed to a non-existent header. Huge thanks to @parkeraddison! --- src/embed-manager.js | 67 +++++++++------- src/viewport-controller.js | 154 ++++++++++++++++++------------------- 2 files changed, 115 insertions(+), 106 deletions(-) diff --git a/src/embed-manager.js b/src/embed-manager.js index 600510c..0ecce6f 100644 --- a/src/embed-manager.js +++ b/src/embed-manager.js @@ -25,7 +25,7 @@ class EmbedManager { } }); this.activeEmbeds.clear(); - + // Clean up dynamic paths cache if (this.dynamicPaths) { this.dynamicPaths.cleanup(); @@ -48,7 +48,7 @@ class EmbedManager { async processSyncBlock(source, el, ctx) { el.empty(); const syncContainer = el.createDiv('sync-container'); - + // Apply CSS custom properties syncContainer.style.setProperty('--sync-embed-height', this.plugin.settings.embedHeight); syncContainer.style.setProperty('--sync-max-height', this.plugin.settings.maxEmbedHeight); @@ -70,7 +70,7 @@ class EmbedManager { for (let i = 0; i < embedLines.length; i++) { await this.processEmbed(embedLines[i], syncContainer, ctx, i > 0); } - + // Remove min-height after all loaded setTimeout(() => { syncContainer.style.minHeight = ''; @@ -81,11 +81,11 @@ class EmbedManager { // Parse options like: ![[note|alias{height:500px,title:false}]] const optionsMatch = line.match(/\{([^}]+)\}\]\]$/); const options = {}; - + if (optionsMatch) { const optionsStr = optionsMatch[1]; const pairs = optionsStr.split(','); - + pairs.forEach(pair => { const [key, value] = pair.split(':').map(s => s.trim()); if (key && value !== undefined) { @@ -95,11 +95,11 @@ class EmbedManager { else options[key] = value; } }); - + // Remove options from line for further parsing line = line.replace(/\{[^}]+\}\]\]$/, ']]'); } - + return { line, options }; } @@ -107,23 +107,23 @@ class EmbedManager { try { // Parse custom options first const { line: cleanedLine, options } = this.parseEmbedOptions(embedLine); - + // Parse embed syntax: ![[path#section|alias]] const match = cleanedLine.match(/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/); if (!match) return; let linkText = match[1]; let displayAlias = match[2]?.trim(); - + // Check if this has dynamic patterns const hasDynamicPattern = /\{\{(date|time|title)/.test(linkText); - + if (hasDynamicPattern) { // Check cache first (with 1 second TTL for date patterns) const cacheKey = `${linkText}-${ctx.sourcePath}`; const cached = this.dynamicPaths.pathCache.get(cacheKey); const now = Date.now(); - + let resolvedText; if (cached && (now - cached.timestamp < 1000)) { resolvedText = cached.value; @@ -134,21 +134,21 @@ class EmbedManager { // Resolve dynamic patterns resolvedText = this.dynamicPaths.resolve(linkText, ctx); this.dynamicPaths.pathCache.set(cacheKey, { value: resolvedText, timestamp: now }); - + if (this.plugin.settings.debugMode) { console.log('[Sync Embeds] Fresh resolution:', linkText, '→', resolvedText); } } - + // If no alias provided, use the original pattern as display name if (!displayAlias) { displayAlias = linkText; } - + // CRITICAL: Use resolved text as the actual file path linkText = resolvedText; } - + const linkPath = linkText.split('|')[0].trim(); let notePath = linkPath.split('#')[0]; const section = linkPath.includes('#') ? linkPath.substring(linkPath.indexOf('#') + 1) : null; @@ -160,7 +160,7 @@ class EmbedManager { this.renderError(container, `Note not found: ${notePath}`, addGap); return; } - + // Only check for direct recursion if (file.path === ctx.sourcePath) { this.renderError(container, "Cannot create a recursive embed of the same note.", addGap); @@ -233,12 +233,12 @@ class EmbedManager { async onunload() { // Remove from active embeds this.manager.activeEmbeds.delete(this.embedData); - + // Clear focus if this was focused if (this.manager.plugin.currentFocusedEmbed?.containerEl === this.embedData.containerEl) { this.manager.plugin.currentFocusedEmbed = null; } - + // Properly detach the leaf and clean up the view if (this.embedData.leaf) { this.embedData.leaf.detach(); @@ -270,15 +270,24 @@ class EmbedManager { if (customOptions.height) { embedContainer.style.setProperty('--sync-embed-height', customOptions.height); } - + if (customOptions.maxHeight) { embedContainer.style.setProperty('--sync-max-height', customOptions.maxHeight); } - // For section embeds, setup viewport restriction + // For section embeds, validate section exists then set up if (section) { + const content = view.editor.getValue(); + const sectionInfo = this.viewportController.findSectionBounds(content, section); + + if (sectionInfo.startLine === -1) { + this.renderError(embedContainer.parentElement, `Section not found: ${section}`, false); + leaf.detach(); + return; + } + await this.viewportController.setupSectionViewport(embedData); - + // If there's an alias, show it instead of the actual header if (alias) { this.setupAliasDisplay(embedData, alias, true); @@ -294,10 +303,10 @@ class EmbedManager { } // Handle inline title visibility - const showTitle = customOptions.title !== undefined - ? customOptions.title + const showTitle = customOptions.title !== undefined + ? customOptions.title : this.plugin.settings.showInlineTitle; - + if (!showTitle || section) { this.hideInlineTitle(embedData); } @@ -306,7 +315,7 @@ class EmbedManager { placeholder.remove(); embedContainer.appendChild(view.containerEl); embedContainer.removeClass('sync-embed-loading'); - + ctx.addChild(component); } catch (error) { @@ -318,7 +327,7 @@ class EmbedManager { setupAliasDisplay(embedData, displayAlias, isSection) { const { view } = embedData; - + requestAnimationFrame(() => { setTimeout(() => { // Hide the inline title @@ -344,7 +353,7 @@ class EmbedManager { // Create and insert alias header with consistent styling const aliasHeader = view.containerEl.createDiv('sync-embed-alias-header'); aliasHeader.textContent = displayAlias; - + const viewContent = view.containerEl.querySelector('.view-content'); if (viewContent) { viewContent.insertBefore(aliasHeader, viewContent.firstChild); @@ -355,7 +364,7 @@ class EmbedManager { setupPropertiesCollapse(embedData) { const { view } = embedData; - + requestAnimationFrame(() => { setTimeout(() => { const propertiesEl = view.containerEl.querySelector('.metadata-container'); @@ -392,4 +401,4 @@ class EmbedManager { } } -module.exports = EmbedManager; \ No newline at end of file +module.exports = EmbedManager; diff --git a/src/viewport-controller.js b/src/viewport-controller.js index 87bb02d..9e7ea86 100644 --- a/src/viewport-controller.js +++ b/src/viewport-controller.js @@ -7,35 +7,35 @@ class ViewportController { async setupSectionViewport(embedData) { const { view, editor, file, section } = embedData; - + // Wait for editor to be ready await new Promise(resolve => setTimeout(resolve, 100)); - + const content = editor.getValue(); const sectionInfo = this.findSectionBounds(content, section); - + + // Missing section should be handled by the caller. Log to console for debugging if we reach this point. if (sectionInfo.startLine === -1) { - // Section not found - editor.setValue(`# ${section}\n\n*Section not found in file.*`); + console.warn('Sync Embeds: Section not found for viewport embedding:', section); return; } - + // Store section metadata embedData.sectionInfo = sectionInfo; embedData.viewportActive = true; - + // Apply the viewport restriction this.applyViewportRestriction(embedData); - + // Setup header protection (simplified, no auto-correction) this.setupHeaderProtection(embedData); - + // NEW: Setup input interception to block # typing this.setupHeaderInputInterception(embedData); - + // Setup cursor and content constraints this.setupContentConstraints(embedData); - + // Scroll to section this.scrollToSection(embedData); } @@ -43,22 +43,22 @@ class ViewportController { applyViewportRestriction(embedData) { const { view, sectionInfo } = embedData; const { startLine, endLine } = sectionInfo; - + // Use CSS to hide lines outside the viewport const style = document.createElement('style'); style.className = 'sync-viewport-style'; - + // Generate unique ID for this embed const embedId = 'embed-' + Math.random().toString(36).substr(2, 9); view.containerEl.setAttribute('data-embed-id', embedId); - + // Store embed ID for dynamic updates embedData.embedId = embedId; - + this.updateViewportCSS(embedData, style); - + view.containerEl.appendChild(style); - + // Store for cleanup and updates embedData.viewportStyle = style; } @@ -66,19 +66,19 @@ class ViewportController { updateViewportCSS(embedData, style) { const { sectionInfo, embedId } = embedData; const { startLine, endLine } = sectionInfo; - + // PERFORMANCE: Use efficient CSS selectors const css = ` /* Hide all lines before the section */ - [data-embed-id="${embedId}"] .cm-line:nth-child(-n+${startLine}) { - display: none !important; + [data-embed-id="${embedId}"] .cm-line:nth-child(-n+${startLine}) { + display: none !important; } - + /* Hide all lines after the section */ - [data-embed-id="${embedId}"] .cm-line:nth-child(n+${endLine + 1}) { - display: none !important; + [data-embed-id="${embedId}"] .cm-line:nth-child(n+${endLine + 1}) { + display: none !important; } - + /* Style the header line - No background, proper theming */ [data-embed-id="${embedId}"] .cm-line:nth-child(${startLine + 1}) { pointer-events: none !important; @@ -86,27 +86,27 @@ class ViewportController { cursor: default !important; } `; - + style.textContent = css; } setupHeaderProtection(embedData) { const { view, editor, sectionInfo, component } = embedData; const { startLine } = sectionInfo; - + // Store original header to restore if modified const originalHeader = editor.getLine(startLine); embedData.originalHeader = originalHeader; - + let isProgrammaticUpdate = false; - + const changeHandler = (changedEditor) => { if (changedEditor !== editor || isProgrammaticUpdate) return; if (!embedData.viewportActive) return; - + const cursorPos = editor.getCursor(); const absoluteLine = cursorPos.line; - + // Protect the header line from any edits if (absoluteLine === startLine) { const currentLine = editor.getLine(startLine); @@ -123,7 +123,7 @@ class ViewportController { } } }; - + component.registerEvent( this.plugin.app.workspace.on('editor-change', changeHandler) ); @@ -134,11 +134,11 @@ class ViewportController { const { view, editor, component } = embedData; // The header level of the main section won't change, so it's safe to get it once. const { headerLevel } = embedData.sectionInfo; - + // Always enforce in section embeds let lastNoticeTime = 0; const noticeDebounce = 5000; // 5 seconds between notices - + // CRITICAL: Wait for the editor to be fully ready and focused // Use a small delay to ensure the CodeMirror editor is properly initialized const setupHandlers = () => { @@ -149,26 +149,26 @@ class ViewportController { setTimeout(setupHandlers, 50); return; } - + // Handle input event to catch # at line start (use 'input' not 'beforeinput' for better timing) const inputHandler = (event) => { // Only process insertText events if (event.inputType !== 'insertText' && event.inputType !== 'insertFromPaste') return; if (event.data !== '#') return; - + // Check immediately if # was typed at line start const cursor = editor.getCursor(); const line = editor.getLine(cursor.line); - + // Check if the # is at the start of the line (accounting for whitespace) const beforeHash = line.substring(0, cursor.ch - 1); const isAtLineStart = /^\s*$/.test(beforeHash); - + // CHANGED: Read directly from `embedData.sectionInfo` to get the latest, non-stale bounds. - if (isAtLineStart && - cursor.line > embedData.sectionInfo.startLine && + if (isAtLineStart && + cursor.line > embedData.sectionInfo.startLine && cursor.line < embedData.sectionInfo.endLine) { - + // Remove the # that was just typed const currentLine = editor.getLine(cursor.line); const newLine = currentLine.substring(0, cursor.ch - 1) + currentLine.substring(cursor.ch); @@ -178,19 +178,19 @@ class ViewportController { { line: cursor.line, ch: currentLine.length } ); editor.setCursor({ line: cursor.line, ch: cursor.ch - 1 }); - + // Show notice (debounced) const now = Date.now(); - if (this.plugin.settings.showHeaderHints && + if (this.plugin.settings.showHeaderHints && now - lastNoticeTime > noticeDebounce) { - + lastNoticeTime = now; - + const availableLevels = []; for (let i = headerLevel + 1; i <= 6; i++) { availableLevels.push(`H${i} (Alt+${i})`); } - + new Notice( `⚠️ Cannot create H1-H${headerLevel} headers in this section.\n` + `Use: ${availableLevels.join(', ')}`, @@ -199,29 +199,29 @@ class ViewportController { } } }; - + // Also handle paste events const pasteHandler = (event) => { const clipboardData = event.clipboardData?.getData('text'); if (!clipboardData) return; - + const cursor = editor.getCursor(); - + // CHANGED: Read directly from `embedData.sectionInfo` to get the latest, non-stale bounds. - if (cursor.line > embedData.sectionInfo.startLine && + if (cursor.line > embedData.sectionInfo.startLine && cursor.line < embedData.sectionInfo.endLine) { - + const lines = clipboardData.split('\n'); let hasInvalidHeaders = false; - + // Check if pasted content has invalid headers const adjustedLines = lines.map(line => { const match = line.match(/^(#{1,6})\s+(.*)$/); if (!match) return line; - + const [, hashes, content] = match; const level = hashes.length; - + if (level <= headerLevel) { hasInvalidHeaders = true; // Adjust to minimum allowed level @@ -230,71 +230,71 @@ class ViewportController { } return line; }); - + if (hasInvalidHeaders) { event.preventDefault(); - + if (this.plugin.settings.showHeaderHints) { new Notice( 'Pasted headers adjusted to maintain section hierarchy', 4000 ); } - + // Insert adjusted content editor.replaceSelection(adjustedLines.join('\n')); } } }; - + // Use 'input' event which fires AFTER the character is inserted cmEditor.addEventListener('input', inputHandler, true); cmEditor.addEventListener('paste', pasteHandler, true); - + // Cleanup component.register(() => { cmEditor.removeEventListener('input', inputHandler, true); cmEditor.removeEventListener('paste', pasteHandler, true); }); }; - + // Start setup with a small delay to ensure editor is ready setTimeout(setupHandlers, 100); } setupContentConstraints(embedData) { const { view, editor, sectionInfo, component } = embedData; - + let isProgrammaticUpdate = false; - + // CRITICAL FIX: Update viewport IMMEDIATELY before visible changes const updateViewportImmediately = () => { if (!embedData.viewportActive) return; - + const currentContent = editor.getValue(); const newSectionInfo = this.findSectionBounds(currentContent, embedData.section); - + if (newSectionInfo.startLine !== -1) { embedData.sectionInfo = newSectionInfo; - + // Update CSS synchronously to prevent flashing if (embedData.viewportStyle) { this.updateViewportCSS(embedData, embedData.viewportStyle); } } }; - + // Monitor for content changes with instant updates const constrainContent = () => { if (isProgrammaticUpdate || !embedData.viewportActive) return; - + // Update viewport bounds IMMEDIATELY updateViewportImmediately(); - + // Constrain cursor to section bounds const cursor = editor.getCursor(); const currentSectionInfo = embedData.sectionInfo; - + if (cursor.line < currentSectionInfo.startLine) { isProgrammaticUpdate = true; editor.setCursor({ line: currentSectionInfo.startLine + 1, ch: 0 }); @@ -307,7 +307,7 @@ class ViewportController { isProgrammaticUpdate = false; } }; - + // CHANGED: Removed `requestAnimationFrame`. Updates must be synchronous to prevent a race condition // where the `input` event for typing a hash fires before the section bounds are updated. component.registerEvent( @@ -317,18 +317,18 @@ class ViewportController { } }) ); - + // Prevent scrolling outside section const cmScroller = view.containerEl.querySelector('.cm-scroller'); if (cmScroller) { const preventScroll = (e) => { if (!embedData.viewportActive) return; - + const scrollTop = cmScroller.scrollTop; const lineHeight = editor.defaultTextHeight || 20; const firstVisibleLine = Math.floor(scrollTop / lineHeight); const currentSectionInfo = embedData.sectionInfo; - + // Constrain scrolling to section if (firstVisibleLine < Math.max(0, currentSectionInfo.startLine - 2)) { cmScroller.scrollTop = Math.max(0, currentSectionInfo.startLine - 2) * lineHeight; @@ -336,7 +336,7 @@ class ViewportController { cmScroller.scrollTop = (currentSectionInfo.endLine - 2) * lineHeight; } }; - + cmScroller.addEventListener('scroll', preventScroll); component.register(() => { cmScroller.removeEventListener('scroll', preventScroll); @@ -347,11 +347,11 @@ class ViewportController { scrollToSection(embedData) { const { view, editor, sectionInfo } = embedData; const { startLine } = sectionInfo; - + setTimeout(() => { // Scroll to the start of the section editor.scrollIntoView({ line: startLine, ch: 0 }, true); - + // Set cursor at line after header editor.setCursor({ line: startLine + 1, ch: 0 }); }, 150); @@ -361,7 +361,7 @@ class ViewportController { const lines = content.split('\n'); const escapedName = this.escapeRegExp(sectionName); const headerRegex = new RegExp(`^#{1,6}\\s+${escapedName}\\s*$`); - + let startLine = -1; let headerLevel = 0; @@ -403,4 +403,4 @@ class ViewportController { } } -module.exports = ViewportController; \ No newline at end of file +module.exports = ViewportController;