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!
This commit is contained in:
Parker Grey Addison 2026-02-17 22:56:17 +01:00 committed by GitHub
parent 5b27cff2f2
commit 47e058331d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 115 additions and 106 deletions

View file

@ -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;
module.exports = EmbedManager;

View file

@ -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;
module.exports = ViewportController;