Critical fixes

- Add build tools
- Fix properties offset
- Fix delete/backsapce edge case errors
- Unified header logic
- Padding still needs some tweaking (themes? minimal, spacious etc)
This commit is contained in:
Verity 2026-02-21 23:33:11 +00:00
parent bfa0c26c1c
commit d8e8566e15
5 changed files with 371 additions and 470 deletions

51
esbuild.config.mjs Normal file
View file

@ -0,0 +1,51 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = process.env.NODE_ENV === 'production';
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.js"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js", // Outputs to root for Obsidian
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development')
},
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "obsidian-sync-embeds",
"version": "2.0.0",
"description": "Create fully editable, live-synced note embeds",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "NODE_ENV=production node esbuild.config.mjs"
},
"keywords": ["obsidian", "plugin", "embeds"],
"author": "uthvah",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"builtin-modules": "^3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest"
},
"dependencies": {
"monkey-around": "^3.0.0"
}
}

View file

@ -5,31 +5,19 @@ const DynamicPaths = require('./dynamic-paths');
class EmbedManager {
constructor(plugin) {
this.plugin = plugin;
// Use WeakMap to prevent memory leaks
this.embedRegistry = new WeakMap();
// Keep a Set for tracking active embeds for cleanup
this.activeEmbeds = new Set();
this.viewportController = new ViewportController(plugin);
this.dynamicPaths = new DynamicPaths(plugin);
}
cleanup() {
// Clean up all active embeds
this.activeEmbeds.forEach(embedData => {
if (embedData.component) {
embedData.component.unload();
}
// Properly detach leaf and view
if (embedData.leaf) {
embedData.leaf.detach();
}
if (embedData.component) embedData.component.unload();
if (embedData.leaf) embedData.leaf.detach();
});
this.activeEmbeds.clear();
// Clean up dynamic paths cache
if (this.dynamicPaths) {
this.dynamicPaths.cleanup();
}
if (this.dynamicPaths) this.dynamicPaths.cleanup();
}
getEmbedFromElement(element) {
@ -49,7 +37,6 @@ class EmbedManager {
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);
syncContainer.style.setProperty('--sync-gap', this.plugin.settings.gapBetweenEmbeds);
@ -63,89 +50,59 @@ class EmbedManager {
return;
}
// Calculate total height for lazy loading to prevent scrollbar jumps
const estimatedHeight = embedLines.length * 200; // Rough estimate
const estimatedHeight = embedLines.length * 200;
syncContainer.style.minHeight = `${estimatedHeight}px`;
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 = '';
}, 100);
setTimeout(() => { syncContainer.style.minHeight = ''; }, 100);
}
parseEmbedOptions(line) {
// 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) {
// Parse boolean values
if (value === 'true') options[key] = true;
else if (value === 'false') options[key] = false;
else options[key] = value;
}
});
// Remove options from line for further parsing
line = line.replace(/\{[^}]+\}\]\]$/, ']]');
}
return { line, options };
}
async processEmbed(embedLine, container, ctx, addGap) {
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;
if (this.plugin.settings.debugMode) {
console.log('[Sync Embeds] Using cached resolution:', linkText, '→', resolvedText);
}
} else {
// 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
if (!displayAlias) displayAlias = linkText;
linkText = resolvedText;
}
@ -161,39 +118,30 @@ class EmbedManager {
return;
}
// Only check for direct recursion
if (file.path === ctx.sourcePath) {
this.renderError(container, "Cannot create a recursive embed of the same note.", addGap);
return;
}
// Create embed container
const embedContainer = container.createDiv('sync-embed');
if (addGap) embedContainer.addClass('sync-embed-gap');
embedContainer.addClass('sync-embed-loading');
// Store custom options on container
if (Object.keys(options).length > 0) {
embedContainer.dataset.customOptions = JSON.stringify(options);
}
// Create placeholder for lazy loading
const placeholderText = displayAlias || `${file.basename}${section ? '#' + section : ''}`;
const placeholder = embedContainer.createDiv('sync-embed-placeholder');
placeholder.setText(`Loading ${placeholderText}...`);
const renderAsCallout = options.callout !== undefined ? options.callout : this.plugin.settings.renderAsCallout;
if (renderAsCallout) embedContainer.addClass('is-callout-style');
if (renderAsCallout) {
embedContainer.addClass('is-callout-style');
}
// Aggressive lazy loading to prevent scrollbar jumps
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
observer.disconnect();
// Small delay to ensure smooth scrolling
requestAnimationFrame(() => {
this.loadEmbed(embedContainer, file, section, displayAlias, ctx, placeholder, options);
});
@ -201,7 +149,7 @@ class EmbedManager {
});
}, {
rootMargin: this.plugin.settings.lazyLoadThreshold,
threshold: 0.01 // Start loading as soon as 1% is visible
threshold: 0.01
});
observer.observe(embedContainer);
@ -218,7 +166,6 @@ class EmbedManager {
const leaf = new WorkspaceLeaf(this.plugin.app);
component.load();
// Store leaf for proper cleanup
const embedData = {
containerEl: embedContainer,
file,
@ -230,7 +177,6 @@ class EmbedManager {
sourcePath: ctx.sourcePath
};
// Register cleanup
component.addChild(new (class extends Component {
constructor(manager, data) {
super();
@ -238,26 +184,15 @@ class EmbedManager {
this.embedData = data;
}
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();
}
if (this.embedData.leaf) this.embedData.leaf.detach();
}
})(this, embedData));
// Open the full file in source mode
await leaf.openFile(file, {
state: { mode: 'source' }
});
await leaf.openFile(file, { state: { mode: 'source' } });
const view = leaf.view;
if (!(view instanceof MarkdownView)) {
@ -266,85 +201,53 @@ class EmbedManager {
return;
}
// Update embedData with view and editor
embedData.view = view;
embedData.editor = view.editor;
this.embedRegistry.set(embedContainer, embedData);
this.activeEmbeds.add(embedData);
// Apply custom height if specified
if (customOptions.height) {
embedContainer.style.setProperty('--sync-embed-height', customOptions.height);
}
if (customOptions.maxHeight) {
embedContainer.style.setProperty('--sync-max-height', customOptions.maxHeight);
}
// Handle collapse option
if (customOptions.collapse === true) {
embedContainer.addClass('is-collapsed');
}
if (customOptions.height) embedContainer.style.setProperty('--sync-embed-height', customOptions.height);
if (customOptions.maxHeight) embedContainer.style.setProperty('--sync-max-height', customOptions.maxHeight);
if (customOptions.collapse === true) embedContainer.addClass('is-collapsed');
const renderAsCallout = customOptions.callout !== undefined ? customOptions.callout : this.plugin.settings.renderAsCallout;
// Construct the display title: prefer alias, then "Note > Section", then just Note
const headerTitle = alias || (section ? `${file.basename} > ${section}` : file.basename);
// 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) {
// Cleanup
embedContainer.empty();
embedContainer.removeClass('sync-embed-loading');
embedContainer.style.height = 'auto';
embedContainer.style.minHeight = '0';
// Render the error
this.renderError(embedContainer, `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 || renderAsCallout) {
this.setupAliasDisplay(embedData, headerTitle, true);
}
} else if (alias || renderAsCallout) {
// For whole note embeds with alias or callout style, show title
this.setupAliasDisplay(embedData, headerTitle, false);
}
// Handle properties collapse
if (this.plugin.settings.collapsePropertiesByDefault) {
this.setupPropertiesCollapse(embedData);
}
// Handle inline title visibility
const showTitle = customOptions.title !== undefined
? customOptions.title
: this.plugin.settings.showInlineTitle;
// Hide title if disabled, if it's a section, OR if we're rendering as a callout (since callout has its own title)
if (!showTitle || section || renderAsCallout) {
this.hideInlineTitle(embedData);
}
// Remove placeholder and show actual content
// placeholder.remove();
// embedContainer.appendChild(view.containerEl);
// Using replaceWith instead of remove + appendChild
placeholder.replaceWith(view.containerEl);
// UNIFIED HEADER/TITLE LOGIC
const userWantsTitle = customOptions.title !== undefined ? customOptions.title : this.plugin.settings.showInlineTitle;
embedContainer.removeClass('sync-embed-loading');
// Callouts ALWAYS generate a header (for folding). Normal embeds respect settings.
if (renderAsCallout || userWantsTitle) {
this.setupHeaderUI(embedData, headerTitle, renderAsCallout, !!section);
}
// PROPERTIES LOGIC
if (section) {
this.hideProperties(embedData); // Sections shouldn't have properties
} else if (this.plugin.settings.collapsePropertiesByDefault) {
this.setupPropertiesCollapse(embedData); // Whole notes conditionally collapse natively
}
placeholder.replaceWith(view.containerEl);
embedContainer.removeClass('sync-embed-loading');
ctx.addChild(component);
} catch (error) {
@ -354,101 +257,78 @@ class EmbedManager {
}
}
setupAliasDisplay(embedData, displayAlias, isSection) {
const { view, file, section } = embedData;
const renderAsCallout = embedData.customOptions.callout !== undefined
? embedData.customOptions.callout
: this.plugin.settings.renderAsCallout;
setupHeaderUI(embedData, displayTitle, renderAsCallout, isSection) {
const { view, file, section, containerEl } = embedData;
requestAnimationFrame(() => {
setTimeout(() => {
// Hide the inline title
// Completely kill the native title to prevent duplication
const titleEl = view.containerEl.querySelector('.inline-title');
if (titleEl) {
titleEl.style.display = 'none';
}
if (titleEl) titleEl.style.display = 'none';
// For section embeds, also hide the section header
if (isSection && embedData.sectionInfo) {
const cmContent = view.containerEl.querySelector('.cm-content');
if (cmContent) {
const headerLineNumber = embedData.sectionInfo.startLine + 1;
const firstLine = cmContent.querySelector(`.cm-line:nth-child(${headerLineNumber})`);
if (firstLine) {
firstLine.style.display = 'none';
}
}
}
const viewContent = view.containerEl.querySelector('.view-content');
if (!viewContent) return;
if (view.containerEl.querySelector('.sync-embed-header')) return; // Prevent dupes
const headerUI = document.createElement('div');
headerUI.className = 'sync-embed-header';
// Create and insert alias header with consistent styling
const aliasHeader = view.containerEl.createDiv('sync-embed-alias-header');
if (renderAsCallout) {
aliasHeader.addClass('is-sticky');
headerUI.classList.add('is-sticky');
// Add fold button
const foldBtn = aliasHeader.createDiv('sync-embed-fold');
const foldBtn = headerUI.createDiv('sync-embed-fold');
setIcon(foldBtn, 'chevron-down');
// Create a clickable link
const linkPath = section ? `${file.path}#${section}` : file.path;
aliasHeader.createEl('a', {
headerUI.createEl('a', {
cls: 'internal-link',
text: displayAlias,
attr: {
'href': linkPath,
'data-href': linkPath
}
text: displayTitle,
attr: { 'href': linkPath, 'data-href': linkPath }
});
// Handle toggle (only if clicking the header background, not the link)
aliasHeader.addEventListener('click', (e) => {
headerUI.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
e.stopPropagation();
e.preventDefault();
embedData.containerEl.classList.toggle('is-collapsed');
containerEl.classList.toggle('is-collapsed');
});
// Callout headers sit ABOVE the content to stick perfectly
view.containerEl.insertBefore(headerUI, viewContent);
} else {
aliasHeader.textContent = displayAlias;
}
const viewContent = view.containerEl.querySelector('.view-content');
if (viewContent) {
viewContent.insertBefore(aliasHeader, viewContent.firstChild);
// Standard inline alias header sits inside the content
headerUI.textContent = displayTitle;
viewContent.insertBefore(headerUI, viewContent.firstChild);
}
}, 100);
});
}
hideProperties(embedData) {
const { view } = embedData;
requestAnimationFrame(() => {
setTimeout(() => {
const propertiesEl = view.containerEl.querySelector('.metadata-container');
if (propertiesEl) {
propertiesEl.style.display = 'none';
}
}, 50);
});
}
setupPropertiesCollapse(embedData) {
const { view } = embedData;
requestAnimationFrame(() => {
setTimeout(() => {
const propertiesEl = view.containerEl.querySelector('.metadata-container');
if (!propertiesEl) return;
propertiesEl.classList.add('is-collapsed');
const toggleBtn = propertiesEl.createDiv('properties-collapse-toggle');
toggleBtn.innerHTML = 'â–¶';
toggleBtn.onclick = () => {
propertiesEl.classList.toggle('is-collapsed');
toggleBtn.innerHTML = propertiesEl.classList.contains('is-collapsed') ? 'â–¶' : 'â–¼';
};
}, 100);
});
}
hideInlineTitle(embedData) {
const { view } = embedData;
requestAnimationFrame(() => {
setTimeout(() => {
const titleEl = view.containerEl.querySelector('.inline-title');
if (titleEl) {
titleEl.style.display = 'none';
// Fire a real click event on the heading so Obsidian natively updates its internal state
const heading = propertiesEl.querySelector('.metadata-properties-heading');
if (heading && !propertiesEl.classList.contains('is-collapsed')) {
heading.click();
}
}, 50);
}, 100);
});
}
@ -459,4 +339,4 @@ class EmbedManager {
}
}
module.exports = EmbedManager;
module.exports = EmbedManager;

View file

@ -8,168 +8,160 @@ 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) {
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.setupBoundaryProtection(embedData);
this.setupHeaderInputInterception(embedData);
// Setup cursor and content constraints
this.setupContentConstraints(embedData);
// Scroll to section
this.scrollToSection(embedData);
}
applyViewportRestriction(embedData) {
const { view, sectionInfo } = embedData;
const { startLine, endLine } = sectionInfo;
// Use CSS to hide lines outside the viewport
const { view } = embedData;
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;
}
updateViewportCSS(embedData, style) {
const { sectionInfo, embedId } = embedData;
const { sectionInfo, embedId, file } = embedData;
const { startLine, endLine } = sectionInfo;
// PERFORMANCE: Use efficient CSS selectors
// Frontmatter DOM Offset compensation
// CodeMirror folds frontmatter, removing all but ONE line from the DOM.
let domOffset = 0;
const fileCache = this.plugin.app.metadataCache.getFileCache(file);
if (fileCache && fileCache.frontmatterPosition) {
// No +1 needed because the first line is retained by CM6 for the fold widget
domOffset = fileCache.frontmatterPosition.end.line;
}
const domStartLine = Math.max(0, startLine - domOffset);
const domEndLine = Math.max(0, endLine - domOffset);
const css = `
/* Hide all lines before the section */
[data-embed-id="${embedId}"] .cm-line:nth-child(-n+${startLine}) {
/* Hide all lines BEFORE and INCLUDING the section header */
[data-embed-id="${embedId}"] .cm-line:nth-child(-n+${domStartLine + 1}) {
display: none !important;
}
/* Hide all lines after the section */
[data-embed-id="${embedId}"] .cm-line:nth-child(n+${endLine + 1}) {
/* Hide all lines AFTER the section */
[data-embed-id="${embedId}"] .cm-line:nth-child(n+${domEndLine + 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;
user-select: none !important;
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);
if (currentLine !== originalHeader) {
isProgrammaticUpdate = true;
editor.replaceRange(
originalHeader,
{ line: startLine, ch: 0 },
{ line: startLine, ch: currentLine.length }
);
// Move cursor to line below
editor.setCursor({ line: startLine + 1, ch: 0 });
isProgrammaticUpdate = false;
}
}
};
component.registerEvent(
this.plugin.app.workspace.on('editor-change', changeHandler)
);
}
setupHeaderInputInterception(embedData) {
// CHANGED: Removed `sectionInfo` from destructuring to avoid a stale closure.
setupBoundaryProtection(embedData) {
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 = () => {
// Get the actual CodeMirror editor element
const cmEditor = view.containerEl.querySelector('.cm-content');
if (!cmEditor) {
// Retry if not ready yet
setTimeout(setupHandlers, 50);
return;
}
// Handle input event to catch # at line start (use 'input' not 'beforeinput' for better timing)
const keydownHandler = (event) => {
if (!embedData.viewportActive || !embedData.sectionInfo) return;
const { startLine, endLine } = embedData.sectionInfo;
const cursor = editor.getCursor();
const selection = editor.getSelection();
// PROTECT TOP BOUNDARY (Block Backspace from deleting the invisible boundary newline)
if (event.key === 'Backspace') {
if (selection) {
const from = editor.getCursor('from');
if (from.line <= startLine) {
event.preventDefault();
return;
}
} else {
if (cursor.line === startLine + 1 && cursor.ch === 0) {
event.preventDefault();
return;
}
}
}
// PROTECT BOTTOM BOUNDARY (Block Delete from deleting the invisible boundary newline)
if (event.key === 'Delete') {
if (selection) {
const to = editor.getCursor('to');
if (to.line >= endLine) {
event.preventDefault();
return;
}
} else {
const lastEditableLine = endLine - 1;
const lastLineLength = editor.getLine(lastEditableLine)?.length || 0;
if (cursor.line === lastEditableLine && cursor.ch === lastLineLength) {
event.preventDefault();
return;
}
}
}
};
cmEditor.addEventListener('keydown', keydownHandler, true);
component.register(() => {
cmEditor.removeEventListener('keydown', keydownHandler, true);
});
};
setTimeout(setupHandlers, 100);
}
setupHeaderInputInterception(embedData) {
const { view, editor, component } = embedData;
const { headerLevel } = embedData.sectionInfo;
let lastNoticeTime = 0;
const noticeDebounce = 5000;
const setupHandlers = () => {
const cmEditor = view.containerEl.querySelector('.cm-content');
if (!cmEditor) {
setTimeout(setupHandlers, 50);
return;
}
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 &&
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);
editor.replaceRange(
@ -179,146 +171,122 @@ class ViewportController {
);
editor.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
// Show notice (debounced)
const now = Date.now();
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(', ')}`,
5000
);
new Notice(`⚠️ Cannot create H1-H${headerLevel} headers in this section.\nUse: ${availableLevels.join(', ')}`, 5000);
}
}
};
// 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 &&
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) {
if (hashes.length <= headerLevel) {
hasInvalidHeaders = true;
// Adjust to minimum allowed level
const newLevel = headerLevel + 1;
return '#'.repeat(newLevel) + ' ' + content;
return '#'.repeat(headerLevel + 1) + ' ' + content;
}
return line;
});
if (hasInvalidHeaders) {
event.preventDefault();
if (this.plugin.settings.showHeaderHints) {
new Notice(
'Pasted headers adjusted to maintain section hierarchy',
4000
);
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;
const { view, editor, component } = embedData;
let isProgrammaticUpdate = false;
// CRITICAL FIX: Update viewport IMMEDIATELY before visible changes
const updateViewportImmediately = () => {
if (!embedData.viewportActive) return;
// Force cursor strictly inside bounds (preventing up/down arrow drifting)
const enforceCursorBounds = () => {
if (isProgrammaticUpdate || !embedData.viewportActive || !embedData.sectionInfo) 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 { startLine, endLine } = embedData.sectionInfo;
const cursor = editor.getCursor();
const currentSectionInfo = embedData.sectionInfo;
if (cursor.line < currentSectionInfo.startLine) {
if (cursor.line <= startLine) {
isProgrammaticUpdate = true;
editor.setCursor({ line: currentSectionInfo.startLine + 1, ch: 0 });
editor.setCursor({ line: startLine + 1, ch: 0 });
isProgrammaticUpdate = false;
} else if (cursor.line >= currentSectionInfo.endLine) {
} else if (cursor.line >= endLine) {
isProgrammaticUpdate = true;
const lastEditableLine = currentSectionInfo.endLine - 1;
const lastEditableLine = endLine - 1;
const lastLineLength = editor.getLine(lastEditableLine)?.length || 0;
editor.setCursor({ line: lastEditableLine, ch: lastLineLength });
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.
const setupDOMListeners = () => {
const cmContent = view.containerEl.querySelector('.cm-content');
if (!cmContent) {
setTimeout(setupDOMListeners, 50);
return;
}
cmContent.addEventListener('mouseup', enforceCursorBounds);
cmContent.addEventListener('focusin', enforceCursorBounds);
cmContent.addEventListener('keyup', (e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(e.key)) {
enforceCursorBounds();
}
});
component.register(() => {
cmContent.removeEventListener('mouseup', enforceCursorBounds);
cmContent.removeEventListener('focusin', enforceCursorBounds);
cmContent.removeEventListener('keyup', enforceCursorBounds);
});
};
setTimeout(setupDOMListeners, 100);
// Retain text-change bounds checking
component.registerEvent(
this.plugin.app.workspace.on('editor-change', (changedEditor) => {
if (changedEditor === editor) {
constrainContent();
this.updateViewportImmediately(embedData);
enforceCursorBounds();
}
})
);
// Prevent scrolling outside section
const cmScroller = view.containerEl.querySelector('.cm-scroller');
if (cmScroller) {
const preventScroll = (e) => {
@ -327,13 +295,21 @@ class ViewportController {
const scrollTop = cmScroller.scrollTop;
const lineHeight = editor.defaultTextHeight || 20;
const firstVisibleLine = Math.floor(scrollTop / lineHeight);
const currentSectionInfo = embedData.sectionInfo;
// Frontmatter DOM Offset compensation for scrolling bounds
let domOffset = 0;
const fileCache = this.plugin.app.metadataCache.getFileCache(embedData.file);
if (fileCache && fileCache.frontmatterPosition) {
domOffset = fileCache.frontmatterPosition.end.line;
}
const domStartLine = Math.max(0, embedData.sectionInfo.startLine - domOffset);
const domEndLine = Math.max(0, embedData.sectionInfo.endLine - domOffset);
// Constrain scrolling to section
if (firstVisibleLine < Math.max(0, currentSectionInfo.startLine - 2)) {
cmScroller.scrollTop = Math.max(0, currentSectionInfo.startLine - 2) * lineHeight;
} else if (firstVisibleLine > currentSectionInfo.endLine - 2) {
cmScroller.scrollTop = (currentSectionInfo.endLine - 2) * lineHeight;
if (firstVisibleLine < Math.max(0, domStartLine - 2)) {
cmScroller.scrollTop = Math.max(0, domStartLine - 2) * lineHeight;
} else if (firstVisibleLine > domEndLine - 2) {
cmScroller.scrollTop = (domEndLine - 2) * lineHeight;
}
};
@ -344,15 +320,27 @@ class ViewportController {
}
}
updateViewportImmediately(embedData) {
if (!embedData.viewportActive) return;
const currentContent = embedData.editor.getValue();
const newSectionInfo = this.findSectionBounds(currentContent, embedData.section);
if (newSectionInfo.startLine !== -1) {
embedData.sectionInfo = newSectionInfo;
if (embedData.viewportStyle) {
this.updateViewportCSS(embedData, embedData.viewportStyle);
}
}
}
scrollToSection(embedData) {
const { view, editor, sectionInfo } = embedData;
const { 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.scrollIntoView({ line: startLine + 1, ch: 0 }, true);
editor.setCursor({ line: startLine + 1, ch: 0 });
}, 150);
}
@ -365,7 +353,6 @@ class ViewportController {
let startLine = -1;
let headerLevel = 0;
// Find start of section
for (let i = 0; i < lines.length; i++) {
if (headerRegex.test(lines[i])) {
startLine = i;
@ -378,7 +365,6 @@ class ViewportController {
return { startLine: -1, endLine: -1, headerLevel: 0 };
}
// Find end of section
let endLine = lines.length;
for (let i = startLine + 1; i < lines.length; i++) {
const match = lines[i].match(/^#+/);
@ -403,4 +389,4 @@ class ViewportController {
}
}
module.exports = ViewportController;
module.exports = ViewportController;

View file

@ -12,7 +12,7 @@
/* === EMBED WRAPPER === */
.sync-embed {
border: none !important;
border: none !important; /* Normal embeds get no border */
margin: 0 !important;
padding: 0 !important;
background: transparent !important;
@ -22,50 +22,82 @@
overflow-y: auto;
overflow-x: hidden;
position: relative;
/* border: 1px solid var(--background-modifier-border) !important; */
}
/* === STICKY ALIAS HEADER (CALLOUT STYLE) === */
.sync-embed .markdown-source-view,
.sync-embed .cm-editor,
.sync-embed .cm-scroller,
.sync-embed .view-content,
.sync-embed .workspace-leaf-content {
overflow: visible !important;
/* Callout view applies the border to the embed container */
.sync-embed.is-callout-style {
border: 1px solid var(--background-modifier-border) !important;
border-radius: 4px; /* Optional rounding for callouts */
}
.sync-embed-alias-header.is-sticky {
/* Compensate top padding when in callout mode to match bottom padding */
.sync-embed.is-callout-style .cm-content {
padding-top: 12px !important;
}
/* === UNIFIED UI HEADER === */
.sync-embed-header {
font-size: var(--inline-title-size, var(--h1-size, 2em));
font-weight: var(--inline-title-weight, var(--h1-weight, 700));
line-height: var(--inline-title-line-height, var(--h1-line-height, 1.3));
color: var(--inline-title-color, var(--h1-color, var(--text-normal)));
font-family: var(--inline-title-font, var(--h1-font, var(--font-text)));
font-variant: var(--h1-variant, normal);
letter-spacing: var(--h1-letter-spacing, normal);
/* Matches body content padding */
padding-top: 14px !important;
padding-left: 16px !important;
padding-right: 16px !important;
margin-bottom: 0;
user-select: none !important;
cursor: default !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
}
.theme-dark .sync-embed-header,
.theme-light .sync-embed-header {
color: var(--inline-title-color, var(--h1-color, var(--text-normal)));
}
/* === STICKY CALLOUT HEADER VARIANT === */
.sync-embed-header.is-sticky {
position: sticky;
top: 0;
z-index: 1000;
background-color: var(--background-primary) !important;
padding: 8px 40px 8px 10px !important;
font-size: var(--font-text-size);
/* ALIGNMENT: 16px left to match body, 40px right for fold icon */
padding: 8px 40px 8px 16px !important;
font-size: var(--inline-title-size);
display: flex !important;
align-items: center !important;
gap: 8px !important;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
cursor: pointer;
user-select: none !important;
/* border-bottom: 1px solid var(--background-modifier-border) !important; */
border-bottom: 1px solid var(--background-modifier-border) !important;
}
.sync-embed-alias-header.is-sticky:hover {
.sync-embed-header.is-sticky:hover {
background-color: var(--background-primary-alt) !important;
}
.sync-embed-alias-header.is-sticky a.internal-link {
.sync-embed-header.is-sticky a.internal-link {
text-decoration: none;
color: var(--text-accent) !important;
font-weight: 600 !important;
/* Truncation */
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sync-embed-alias-header.is-sticky a.internal-link:hover {
.sync-embed-header.is-sticky a.internal-link:hover {
text-decoration: underline;
color: var(--text-accent-hover) !important;
}
@ -91,12 +123,13 @@
}
/* Hide everything except header when collapsed */
.sync-embed.is-collapsed .view-content > *:not(.sync-embed-alias-header) {
.sync-embed.is-collapsed .view-content,
.sync-embed.is-collapsed .markdown-source-view {
display: none !important;
}
/* Remove bottom border of header when collapsed */
.sync-embed.is-collapsed .sync-embed-alias-header.is-sticky {
.sync-embed.is-collapsed .sync-embed-header.is-sticky {
border-bottom: none !important;
}
@ -111,20 +144,16 @@
border-top: 1px solid var(--background-modifier-border-hover);
}
/* === NATIVE OBSIDIAN PADDING SYSTEM (REBUILT) === */
/* 1. Hide the view header */
/* === PADDING SYSTEM === */
.sync-embed .view-header {
display: none !important;
}
/* 2. The view-content: NO horizontal padding here (we'll handle it in cm-content) */
.sync-embed .view-content {
background-color: transparent !important;
padding: 0 !important;
}
/* 3. All editor components - transparent, no padding */
.sync-embed .markdown-source-view,
.sync-embed .cm-editor,
.sync-embed .cm-scroller {
@ -133,104 +162,42 @@
margin: 0 !important;
}
/* 4. Content area - FIXED PADDING SYSTEM */
/* 16px padding creates a flush left edge */
.sync-embed .cm-content {
background-color: transparent !important;
/* Aesthetic bottom padding - matches native Obsidian feel */
padding-bottom: 18px !important;
/* Native Obsidian horizontal padding - fixed at 10px regardless of readable line width */
padding-left: 10px !important;
padding-right: 10px !important;
padding-top: 12px !important;
padding-bottom: 12px !important;
padding-left: 16px !important;
padding-right: 16px !important;
}
/* 5. Ensure full width */
.sync-embed .markdown-source-view.mod-cm6 .cm-editor {
width: 100%;
}
/* 6. CRITICAL: Disable readable line width padding inheritance */
.sync-embed .cm-sizer {
/* Prevent extra padding from readable line width setting */
padding-inline-start: 0 !important;
padding-inline-end: 0 !important;
}
/* === INLINE TITLE === */
/* === HIDE NATIVE INLINE TITLE COMPLETELY === */
.sync-embed .inline-title {
pointer-events: none !important;
user-select: none !important;
cursor: default !important;
opacity: 1 !important;
color: var(--inline-title-color, var(--h1-color, var(--text-normal))) !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
padding-top: 16px !important;
padding-left: 10px !important;
padding-right: 10px !important;
}
/* === ALIAS HEADER === */
.sync-embed-alias-header {
font-size: var(--inline-title-size, var(--h1-size, 2em));
font-weight: var(--inline-title-weight, var(--h1-weight, 700));
line-height: var(--inline-title-line-height, var(--h1-line-height, 1.3));
color: var(--inline-title-color, var(--h1-color, var(--text-normal)));
font-family: var(--inline-title-font, var(--h1-font, var(--font-text)));
font-variant: var(--h1-variant, normal);
letter-spacing: var(--h1-letter-spacing, normal);
/* Natural spacing like inline-title */
padding-top: 16px !important;
padding-left: 10px !important;
padding-right: 10px !important;
margin-bottom: 0;
user-select: none !important;
cursor: default !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
}
.sync-embed-alias-header a.internal-link {
color: inherit !important;
text-decoration: none;
}
/* Theme inheritance */
.theme-dark .sync-embed-alias-header,
.theme-light .sync-embed-alias-header {
color: var(--inline-title-color, var(--h1-color, var(--text-normal)));
display: none !important;
}
/* === PROPERTIES/FRONTMATTER === */
/* Scoped properly to prevent affecting parent note */
.sync-embed .metadata-container {
/* ALIGNMENT: Matches header and body padding */
padding-left: 16px !important;
padding-right: 16px !important;
margin-block-end: 0;
}
.metadata-container.is-collapsed .metadata-content {
display: none;
}
.properties-collapse-toggle {
cursor: pointer;
user-select: none;
padding: 4px 8px;
margin: 4px 0;
border-radius: 4px;
display: inline-block;
font-size: 0.9em;
color: var(--text-muted);
transition: all 0.2s ease;
font-family: var(--font-monospace);
}
.properties-collapse-toggle:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
/* === SECTION EMBEDS === */
.sync-embed .cm-header-1,
.sync-embed .HyperMD-header-1 {
@ -419,7 +386,7 @@ body.sync-embeds-no-focus-highlight .sync-embed:focus-within {
padding-top: 12px;
}
.sync-embed-alias-header {
.sync-embed-header {
font-size: calc(var(--inline-title-size, var(--h1-size, 2em)) * 0.85);
}
}
@ -461,7 +428,6 @@ body.sync-embeds-no-focus-highlight .sync-embed:focus-within {
}
.sync-embed,
.properties-collapse-toggle,
.sync-embed::-webkit-scrollbar-thumb {
transition: none;
}
@ -483,10 +449,6 @@ body.sync-embeds-no-focus-highlight .sync-embed:focus-within {
overflow: visible !important;
}
.properties-collapse-toggle {
display: none;
}
.metadata-container.is-collapsed .metadata-content {
display: block;
}