This commit is contained in:
Verity 2025-11-17 22:28:28 +00:00 committed by GitHub
parent 2b2347fbf7
commit c73df3ac32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2402 additions and 494 deletions

478
command-interceptor.js Normal file
View file

@ -0,0 +1,478 @@
const { Notice } = require('obsidian');
class CommandInterceptor {
constructor(plugin) {
this.plugin = plugin;
this.handlers = new Map();
this.setupHandlers();
}
setupHandlers() {
const mappings = {
'editor:toggle-checklist-status': this.toggleChecklistCommand,
'editor:toggle-bold': this.toggleBoldCommand,
'editor:toggle-italics': this.toggleItalicCommand,
'editor:toggle-strikethrough': this.toggleStrikethroughCommand,
'editor:toggle-code': this.toggleCodeCommand,
'editor:insert-link': this.insertLinkCommand,
'editor:toggle-bullet-list': this.toggleBulletListCommand,
'editor:toggle-numbered-list': this.toggleNumberedListCommand,
'editor:indent-list': this.indentListCommand,
'editor:unindent-list': this.unindentListCommand,
'editor:insert-tag': this.insertTagCommand,
'editor:swap-line-up': this.swapLineUpCommand,
'editor:swap-line-down': this.swapLineDownCommand,
'editor:duplicate-line': this.duplicateLineCommand,
'editor:delete-line': this.deleteLineCommand,
'editor:toggle-highlight': this.toggleHighlightCommand,
'editor:insert-callout': this.insertCalloutCommand,
// NEW: Header commands (H2-H6 only, no H1)
'sync-embeds:insert-header-2': () => this.insertHeaderCommand(2),
'sync-embeds:insert-header-3': () => this.insertHeaderCommand(3),
'sync-embeds:insert-header-4': () => this.insertHeaderCommand(4),
'sync-embeds:insert-header-5': () => this.insertHeaderCommand(5),
'sync-embeds:insert-header-6': () => this.insertHeaderCommand(6),
};
for (const [commandId, handler] of Object.entries(mappings)) {
this.handlers.set(commandId, handler.bind(this));
}
}
hasHandler(commandId) {
return this.handlers.has(commandId);
}
handle(commandId, embedData, ...args) {
const handler = this.handlers.get(commandId);
if (handler) {
try {
return handler(embedData, ...args);
} catch (error) {
console.error(`Sync Embeds: Error handling command ${commandId}:`, error);
return false;
}
}
return false;
}
// === CHECKLIST ===
toggleChecklistCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
if (line.match(/^\s*- \[ \]/)) {
// Toggle unchecked to checked
const newLine = line.replace(/- \[ \]/, '- [x]');
editor.replaceRange(
newLine,
{ line: cursor.line, ch: 0 },
{ line: cursor.line, ch: line.length }
);
// Move cursor to end of line (native Obsidian behavior)
editor.setCursor({ line: cursor.line, ch: newLine.length });
} else if (line.match(/^\s*- \[x\]/i)) {
// Toggle checked to unchecked
const newLine = line.replace(/- \[x\]/i, '- [ ]');
editor.replaceRange(
newLine,
{ line: cursor.line, ch: 0 },
{ line: cursor.line, ch: line.length }
);
// Move cursor to end of line
editor.setCursor({ line: cursor.line, ch: newLine.length });
} else {
// Convert to checklist
const indent = line.match(/^\s*/)[0];
const content = line.substring(indent.length);
const newLine = `${indent}- [ ] ${content}`;
editor.replaceRange(
newLine,
{ line: cursor.line, ch: 0 },
{ line: cursor.line, ch: line.length }
);
// Move cursor to end of line (native Obsidian behavior)
editor.setCursor({ line: cursor.line, ch: newLine.length });
}
return true;
}
// === FORMATTING ===
toggleMarkdownFormatting(embedData, markdownChar) {
const { editor } = embedData;
const selection = editor.getSelection();
const len = markdownChar.length;
if (selection && selection.startsWith(markdownChar) && selection.endsWith(markdownChar)) {
// Remove formatting
const newText = selection.slice(len, -len);
editor.replaceSelection(newText);
// Keep selection on the content
const cursor = editor.getCursor('from');
editor.setSelection(
cursor,
{ line: cursor.line, ch: cursor.ch + newText.length }
);
} else if (selection) {
// Add formatting around selection
editor.replaceSelection(`${markdownChar}${selection}${markdownChar}`);
// Keep the wrapped text selected
const cursor = editor.getCursor('from');
editor.setSelection(
{ line: cursor.line, ch: cursor.ch + len },
{ line: cursor.line, ch: cursor.ch + len + selection.length }
);
} else {
// No selection: insert markers and place cursor between them
const cursor = editor.getCursor();
editor.replaceRange(markdownChar + markdownChar, cursor);
editor.setCursor({ line: cursor.line, ch: cursor.ch + len });
}
return true;
}
toggleBoldCommand(embedData) {
return this.toggleMarkdownFormatting(embedData, '**');
}
toggleItalicCommand(embedData) {
return this.toggleMarkdownFormatting(embedData, '*');
}
toggleStrikethroughCommand(embedData) {
return this.toggleMarkdownFormatting(embedData, '~~');
}
toggleCodeCommand(embedData) {
return this.toggleMarkdownFormatting(embedData, '`');
}
toggleHighlightCommand(embedData) {
return this.toggleMarkdownFormatting(embedData, '==');
}
// === LINKS ===
insertLinkCommand(embedData) {
const { editor } = embedData;
const selection = editor.getSelection();
if (selection) {
editor.replaceSelection(`[[${selection}]]`);
} else {
const cursor = editor.getCursor();
editor.replaceRange('[[]]', cursor);
editor.setCursor({ line: cursor.line, ch: cursor.ch + 2 });
}
return true;
}
// === LISTS ===
toggleBulletListCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
if (line.match(/^\s*- /)) {
// Remove bullet
const newLine = line.replace(/^\s*- /, '');
editor.replaceRange(
newLine,
{ line: cursor.line, ch: 0 },
{ line: cursor.line, ch: line.length }
);
} else {
// Add bullet
const indent = line.match(/^\s*/)[0];
const content = line.substring(indent.length);
editor.replaceRange(
`${indent}- ${content}`,
{ line: cursor.line, ch: 0 },
{ line: cursor.line, ch: line.length }
);
}
return true;
}
toggleNumberedListCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
if (line.match(/^\s*\d+\. /)) {
// Remove numbering
const newLine = line.replace(/^\s*\d+\. /, '');
editor.replaceRange(
newLine,
{ line: cursor.line, ch: 0 },
{ line: cursor.line, ch: line.length }
);
} else {
// Add numbering
const indent = line.match(/^\s*/)[0];
const content = line.substring(indent.length);
editor.replaceRange(
`${indent}1. ${content}`,
{ line: cursor.line, ch: 0 },
{ line: cursor.line, ch: line.length }
);
}
return true;
}
indentListCommand(embedData) {
const { editor } = embedData;
const from = editor.getCursor('from').line;
const to = editor.getCursor('to').line;
// Handle multiple lines if selected
for (let line = from; line <= to; line++) {
editor.replaceRange('\t', { line, ch: 0 });
}
return true;
}
unindentListCommand(embedData) {
const { editor } = embedData;
const from = editor.getCursor('from').line;
const to = editor.getCursor('to').line;
for (let line = from; line <= to; line++) {
const lineText = editor.getLine(line);
if (lineText.startsWith('\t')) {
editor.replaceRange('', { line, ch: 0 }, { line, ch: 1 });
} else if (lineText.startsWith(' ')) {
editor.replaceRange('', { line, ch: 0 }, { line, ch: 4 });
}
}
return true;
}
// === TAGS ===
insertTagCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const selection = editor.getSelection();
if (selection) {
editor.replaceSelection(`#${selection}`);
} else {
editor.replaceRange('#', cursor);
}
return true;
}
// === CALLOUTS ===
insertCalloutCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const calloutText = '> [!note]\n> ';
editor.replaceRange(calloutText, cursor);
editor.setCursor({ line: cursor.line + 1, ch: 2 });
return true;
}
// === LINE OPERATIONS ===
swapLineUpCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
if (cursor.line > 0) {
const currentLine = editor.getLine(cursor.line);
const prevLine = editor.getLine(cursor.line - 1);
// Use transaction for atomic operation
editor.transaction({
changes: [
{
from: { line: cursor.line - 1, ch: 0 },
to: { line: cursor.line - 1, ch: prevLine.length },
text: currentLine
},
{
from: { line: cursor.line, ch: 0 },
to: { line: cursor.line, ch: currentLine.length },
text: prevLine
}
],
selection: { from: { line: cursor.line - 1, ch: cursor.ch } }
});
}
return true;
}
swapLineDownCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
if (cursor.line < editor.lastLine()) {
const currentLine = editor.getLine(cursor.line);
const nextLine = editor.getLine(cursor.line + 1);
// Use transaction for atomic operation
editor.transaction({
changes: [
{
from: { line: cursor.line, ch: 0 },
to: { line: cursor.line, ch: currentLine.length },
text: nextLine
},
{
from: { line: cursor.line + 1, ch: 0 },
to: { line: cursor.line + 1, ch: nextLine.length },
text: currentLine
}
],
selection: { from: { line: cursor.line + 1, ch: cursor.ch } }
});
}
return true;
}
duplicateLineCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
editor.replaceRange(`\n${line}`, { line: cursor.line, ch: line.length });
editor.setCursor({ line: cursor.line + 1, ch: cursor.ch });
return true;
}
deleteLineCommand(embedData) {
const { editor } = embedData;
const { line } = editor.getCursor();
// Handle last line differently
if (line === editor.lastLine()) {
const prevLineLength = line > 0 ? editor.getLine(line - 1).length : 0;
editor.replaceRange('', { line, ch: 0 }, { line, ch: editor.getLine(line).length });
if (line > 0) {
editor.replaceRange('', { line: line - 1, ch: prevLineLength }, { line, ch: 0 });
}
} else {
editor.replaceRange('', { line, ch: 0 }, { line: line + 1, ch: 0 });
}
return true;
}
// === HEADER COMMANDS ===
insertHeaderCommand(level) {
return (embedData) => {
const { editor, sectionInfo } = embedData;
// Check if this is a section embed - always enforce in section embeds
if (sectionInfo) {
const { headerLevel } = sectionInfo;
// Block same-level or higher
if (level <= headerLevel) {
if (this.plugin.settings.showHeaderHints) {
const availableLevels = [];
for (let i = headerLevel + 1; i <= 6; i++) {
availableLevels.push(`H${i} (Alt+${i})`);
}
new Notice(
`⚠️ H${level} is not allowed in H${headerLevel} section.\nAvailable: ${availableLevels.join(', ')}`,
5000
);
}
return false;
}
}
return this.insertHeader(embedData, level);
};
}
insertHeader(embedData, level) {
const { editor } = embedData;
const from = editor.getCursor('from');
const to = editor.getCursor('to');
// Handle multi-line selection
if (from.line !== to.line) {
for (let line = from.line; line <= to.line; line++) {
this.toggleLineHeader(editor, line, level);
}
return true;
}
// Single line
return this.toggleLineHeader(editor, from.line, level);
}
toggleLineHeader(editor, lineNum, level) {
const line = editor.getLine(lineNum);
const cursor = editor.getCursor();
// Smart behavior: Toggle if already a header
const headerMatch = line.match(/^(\s*)(#{1,6})\s+(.*)$/);
if (headerMatch) {
const [, indent, hashes, content] = headerMatch;
const currentLevel = hashes.length;
if (currentLevel === level) {
// Remove header formatting
const newLine = `${indent}${content}`;
editor.replaceRange(
newLine,
{ line: lineNum, ch: 0 },
{ line: lineNum, ch: line.length }
);
// Move cursor to start of content
if (cursor.line === lineNum) {
editor.setCursor({ line: lineNum, ch: indent.length });
}
} else {
// Change to requested level
const newHashes = '#'.repeat(level);
const newLine = `${indent}${newHashes} ${content}`;
editor.replaceRange(
newLine,
{ line: lineNum, ch: 0 },
{ line: lineNum, ch: line.length }
);
// Keep cursor at same relative position if on this line
if (cursor.line === lineNum) {
const newCursorCh = Math.min(
cursor.ch + (newHashes.length - hashes.length),
newLine.length
);
editor.setCursor({ line: lineNum, ch: newCursorCh });
}
}
} else {
// Convert line to header
const indent = line.match(/^\s*/)[0];
const content = line.substring(indent.length);
const newHashes = '#'.repeat(level);
const newLine = `${indent}${newHashes} ${content}`;
editor.replaceRange(
newLine,
{ line: lineNum, ch: 0 },
{ line: lineNum, ch: line.length }
);
// Place cursor after the hashes and space if on this line
if (cursor.line === lineNum) {
editor.setCursor({
line: lineNum,
ch: indent.length + newHashes.length + 1
});
}
}
return true;
}
}
module.exports = CommandInterceptor;

192
dynamic-paths.js Normal file
View file

@ -0,0 +1,192 @@
const { moment } = require('obsidian');
class DynamicPaths {
constructor(plugin) {
this.plugin = plugin;
this.pathCache = new Map();
// Auto-cleanup cache every minute
this.cacheCleanupInterval = setInterval(() => {
this.cleanupCache();
}, 60000); // 1 minute
}
cleanup() {
if (this.cacheCleanupInterval) {
clearInterval(this.cacheCleanupInterval);
}
this.pathCache.clear();
}
cleanupCache() {
const now = Date.now();
const maxAge = 60000; // 1 minute
for (const [key, cached] of this.pathCache.entries()) {
if (now - cached.timestamp > maxAge) {
this.pathCache.delete(key);
}
}
if (this.plugin.settings.debugMode) {
console.log('[Sync Embeds] Cache cleanup: removed stale entries, size now:', this.pathCache.size);
}
}
resolve(linkPath, ctx) {
let resolved = linkPath;
try {
// Process in order: date offsets, then simple dates, then time, then title
// Replace date offset patterns FIRST: {{date-7d:YYYY-MM-DD}}
resolved = this.resolveDateOffsetPatterns(resolved);
// Replace simple date patterns: {{date:YYYY-MM-DD}}
resolved = this.resolveDatePatterns(resolved);
// Replace time patterns: {{time:HH:mm}}
resolved = this.resolveTimePatterns(resolved);
// Replace title pattern: {{title}}
resolved = this.resolveTitlePattern(resolved, ctx);
// Log for debugging
if (this.plugin.settings.debugMode && resolved !== linkPath) {
console.log('[Sync Embeds] Dynamic path resolved:', linkPath, '→', resolved);
}
} catch (error) {
console.error('Sync Embeds: Error resolving dynamic path:', error);
// Return original path on error
return linkPath;
}
return resolved;
}
resolveDateOffsetPatterns(linkPath) {
// Pattern: {{date[+-]NUMBER[dwmy]:FORMAT}}
// Examples: {{date-7d:YYYY-MM-DD}}, {{date+1w:YYYY-MM-DD}}
const offsetPattern = /\{\{date([+-]\d+)([dwmy]):([^}]+)\}\}/g;
return linkPath.replace(offsetPattern, (match, offset, unit, format) => {
try {
const amount = parseInt(offset, 10);
const unitMap = {
'd': 'days',
'w': 'weeks',
'm': 'months',
'y': 'years'
};
if (!unitMap[unit]) {
console.error('Sync Embeds: Invalid date offset unit:', unit);
return match;
}
// Validate format
if (!this.isValidMomentFormat(format)) {
console.warn('Sync Embeds: Potentially invalid date format:', format);
}
const result = moment().add(amount, unitMap[unit]).format(format);
if (this.plugin.settings.debugMode) {
console.log('[Sync Embeds] Date offset resolved:', match, '→', result);
}
return result;
} catch (error) {
console.error('Sync Embeds: Error in date offset pattern:', error);
return match;
}
});
}
resolveDatePatterns(linkPath) {
// Pattern: {{date:FORMAT}}
// Example: {{date:YYYY-MM-DD}}
const datePattern = /\{\{date:([^}]+)\}\}/g;
return linkPath.replace(datePattern, (match, format) => {
try {
// Validate format
if (!this.isValidMomentFormat(format)) {
console.warn('Sync Embeds: Potentially invalid date format:', format);
}
const result = moment().format(format);
if (this.plugin.settings.debugMode) {
console.log('[Sync Embeds] Date resolved:', match, '→', result);
}
return result;
} catch (error) {
console.error('Sync Embeds: Invalid date format:', format, error);
return match; // Return original if format is invalid
}
});
}
resolveTimePatterns(linkPath) {
// Pattern: {{time:FORMAT}}
// Example: {{time:HH:mm}}
const timePattern = /\{\{time:([^}]+)\}\}/g;
return linkPath.replace(timePattern, (match, format) => {
try {
// Validate format
if (!this.isValidMomentFormat(format)) {
console.warn('Sync Embeds: Potentially invalid time format:', format);
}
const result = moment().format(format);
if (this.plugin.settings.debugMode) {
console.log('[Sync Embeds] Time resolved:', match, '→', result);
}
return result;
} catch (error) {
console.error('Sync Embeds: Invalid time format:', format, error);
return match;
}
});
}
resolveTitlePattern(linkPath, ctx) {
// Pattern: {{title}}
const titlePattern = /\{\{title\}\}/g;
if (!ctx.sourcePath) {
return linkPath.replace(titlePattern, '');
}
try {
const currentFile = this.plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
const title = currentFile?.basename || '';
if (this.plugin.settings.debugMode && linkPath.includes('{{title}}')) {
console.log('[Sync Embeds] Title resolved:', '{{title}}', '→', title);
}
return linkPath.replace(titlePattern, title);
} catch (error) {
console.error('Sync Embeds: Error resolving title pattern:', error);
return linkPath.replace(titlePattern, '');
}
}
isValidMomentFormat(format) {
// Basic validation for common moment.js format tokens
// This is not exhaustive but catches obvious errors
if (!format || format.trim() === '') return false;
// Check for common valid tokens
const validTokens = /[YMDdHhmsSaAZzX]/;
return validTokens.test(format);
}
}
module.exports = DynamicPaths;

395
embed-manager.js Normal file
View file

@ -0,0 +1,395 @@
const { Component, WorkspaceLeaf, MarkdownView } = require('obsidian');
const ViewportController = require('./viewport-controller');
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();
}
});
this.activeEmbeds.clear();
// Clean up dynamic paths cache
if (this.dynamicPaths) {
this.dynamicPaths.cleanup();
}
}
getEmbedFromElement(element) {
if (!element) return null;
let current = element;
while (current && current !== document.body) {
if (current.classList && current.classList.contains('sync-embed')) {
const embedData = this.embedRegistry.get(current);
if (embedData) return embedData;
}
current = current.parentElement;
}
return null;
}
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);
syncContainer.style.setProperty('--sync-gap', this.plugin.settings.gapBetweenEmbeds);
const embedLines = source.split('\n')
.map(line => line.trim())
.filter(line => line.startsWith('![[') && line.endsWith(']]'));
if (embedLines.length === 0) {
syncContainer.createDiv('sync-empty').setText('No embeds found in sync block');
return;
}
// Calculate total height for lazy loading to prevent scrollbar jumps
const estimatedHeight = embedLines.length * 200; // Rough estimate
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);
}
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
linkText = resolvedText;
}
const linkPath = linkText.split('|')[0].trim();
let notePath = linkPath.split('#')[0];
const section = linkPath.includes('#') ? linkPath.substring(linkPath.indexOf('#') + 1) : null;
if (!notePath) notePath = ctx.sourcePath;
const file = this.plugin.app.metadataCache.getFirstLinkpathDest(notePath, ctx.sourcePath);
if (!file) {
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);
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}...`);
// 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);
});
}
});
}, {
rootMargin: this.plugin.settings.lazyLoadThreshold,
threshold: 0.01 // Start loading as soon as 1% is visible
});
observer.observe(embedContainer);
} catch (error) {
console.error('Sync Embeds: Error processing embed:', error);
this.renderError(container, `Error loading: ${error.message}`, addGap);
}
}
async loadEmbed(embedContainer, file, section, alias, ctx, placeholder, customOptions = {}) {
try {
const component = new Component();
const leaf = new WorkspaceLeaf(this.plugin.app);
component.load();
// Store leaf for proper cleanup
const embedData = {
containerEl: embedContainer,
file,
section,
alias,
component,
leaf,
customOptions
};
// Register cleanup
component.addChild(new (class extends Component {
constructor(manager, data) {
super();
this.manager = manager;
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();
}
}
})(this, embedData));
// Open the full file in source mode
await leaf.openFile(file, {
state: { mode: 'source' }
});
const view = leaf.view;
if (!(view instanceof MarkdownView)) {
this.renderError(embedContainer.parentElement, 'Failed to load a markdown view.', false);
leaf.detach();
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);
}
// For section embeds, setup viewport restriction
if (section) {
await this.viewportController.setupSectionViewport(embedData);
// If there's an alias, show it instead of the actual header
if (alias) {
this.setupAliasDisplay(embedData, alias, true);
}
} else if (alias) {
// For whole note embeds with alias, show alias as title
this.setupAliasDisplay(embedData, alias, 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;
if (!showTitle || section) {
this.hideInlineTitle(embedData);
}
// Remove placeholder and show actual content
placeholder.remove();
embedContainer.appendChild(view.containerEl);
embedContainer.removeClass('sync-embed-loading');
ctx.addChild(component);
} catch (error) {
console.error('Sync Embeds: Error loading embed:', error);
placeholder.setText(`Error: ${error.message}`);
placeholder.addClass('sync-embed-error');
}
}
setupAliasDisplay(embedData, displayAlias, isSection) {
const { view } = embedData;
requestAnimationFrame(() => {
setTimeout(() => {
// Hide the inline title
const titleEl = view.containerEl.querySelector('.inline-title');
if (titleEl) {
titleEl.style.display = 'none';
}
// For section embeds, also hide the section header
if (isSection) {
const cmContent = view.containerEl.querySelector('.cm-content');
if (cmContent) {
if (embedData.sectionInfo) {
const headerLineNumber = embedData.sectionInfo.startLine + 1;
const firstLine = cmContent.querySelector(`.cm-line:nth-child(${headerLineNumber})`);
if (firstLine) {
firstLine.style.display = 'none';
}
}
}
}
// 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);
}
}, 100);
});
}
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';
}
}, 50);
});
}
renderError(container, message, addGap) {
const errorDiv = container.createDiv('sync-embed-error');
if (addGap) errorDiv.addClass('sync-embed-gap');
errorDiv.setText(message);
}
}
module.exports = EmbedManager;

685
main.js
View file

@ -1,23 +1,46 @@
const { Plugin, MarkdownView, WorkspaceLeaf, Component, debounce } = require('obsidian');
const { Plugin, MarkdownView } = require('obsidian');
const { around } = require('monkey-around');
const EmbedManager = require('./embed-manager');
const CommandInterceptor = require('./command-interceptor');
const SyncEmbedsSettingTab = require('./settings');
const DEFAULT_SETTINGS = {
embedHeight: 'auto',
maxEmbedHeight: 'none',
collapsePropertiesByDefault: true,
showInlineTitle: true,
enableCommandInterception: true,
gapBetweenEmbeds: '16px',
lazyLoadThreshold: '100px',
showFocusHighlight: true,
showHeaderHints: true, // NEW: Header hints (enforcement is always on)
debugMode: false
};
module.exports = class SyncEmbedPlugin extends Plugin {
constructor(app, manifest) {
super(app, manifest);
this.embedRegistry = new Map(); // Maps container elements to embed data
this.originalExecuteCommand = null;
this.originalGetActiveViewOfType = null;
this.commandInterceptors = new Map(); // Maps command IDs to our handlers
this.settings = DEFAULT_SETTINGS;
this.DEFAULT_SETTINGS = DEFAULT_SETTINGS; // Expose for settings tab
this.embedManager = null;
this.commandInterceptor = null;
this.currentFocusedEmbed = null;
this.uninstallers = [];
}
async onload() {
console.log('Loading Sync Embeds Plugin - Command Interception Active');
await this.loadSettings();
this.hijackObsidianCommands();
// Initialize managers
this.embedManager = new EmbedManager(this);
this.commandInterceptor = new CommandInterceptor(this);
// Setup command interceptors for common shortcuts
this.setupCommandInterceptors();
// Setup command interception with monkey-around
if (this.settings.enableCommandInterception) {
this.setupCommandInterception();
}
// Register command to insert sync blocks
this.addCommand({
id: 'insert-synced-embed',
name: 'Insert synced embed',
@ -29,497 +52,243 @@ module.exports = class SyncEmbedPlugin extends Plugin {
}
});
this.registerMarkdownCodeBlockProcessor('sync', (source, el, ctx) => {
this.processSyncBlock(source, el, ctx);
// Register command to insert sync block with multiple embeds
this.addCommand({
id: 'insert-multi-synced-embed',
name: 'Insert sync block with multiple embeds',
editorCallback: (editor, view) => {
const textToInsert = `\`\`\`sync\n![[Note 1]]\n![[Note 2]]\n\`\`\``;
editor.replaceSelection(textToInsert);
}
});
// Global event listeners to track focus
// Register command to insert dynamic date embed
this.addCommand({
id: 'insert-dynamic-date-embed',
name: 'Insert dynamic date embed (today)',
editorCallback: (editor, view) => {
const textToInsert = `\`\`\`sync\n![[Daily/{{date:YYYY-MM-DD}}|Today]]\n\`\`\``;
editor.replaceSelection(textToInsert);
}
});
// NEW: Register header commands
this.registerHeaderCommands();
// Register markdown code block processor
this.registerMarkdownCodeBlockProcessor('sync', (source, el, ctx) => {
this.embedManager.processSyncBlock(source, el, ctx);
});
// Global focus tracking
this.registerDomEvent(document, 'focusin', this.trackFocus.bind(this));
this.registerDomEvent(document, 'focusout', this.trackFocusLoss.bind(this));
// Add settings tab
this.addSettingTab(new SyncEmbedsSettingTab(this.app, this));
// Apply focus highlight setting
this.updateFocusHighlight();
// Log successful load
this.log('Sync Embeds plugin loaded successfully');
}
onunload() {
console.log('Unloading Sync Embeds Plugin');
this.restoreObsidianCommands();
this.embedRegistry.clear();
this.log('Unloading Sync Embeds plugin');
// Clean up command interception
this.uninstallers.forEach(uninstall => uninstall());
this.uninstallers = [];
// Clean up managers
if (this.embedManager) {
this.embedManager.cleanup();
}
this.currentFocusedEmbed = null;
}
// HIJACK OBSIDIAN'S COMMAND SYSTEM
hijackObsidianCommands() {
this.originalExecuteCommand = this.app.commands.executeCommand;
this.originalGetActiveViewOfType = this.app.workspace.getActiveViewOfType;
registerHeaderCommands() {
const headerLevels = [
{ level: 2, name: 'Heading 2', key: '2' },
{ level: 3, name: 'Heading 3', key: '3' },
{ level: 4, name: 'Heading 4', key: '4' },
{ level: 5, name: 'Heading 5', key: '5' },
{ level: 6, name: 'Heading 6', key: '6' },
];
headerLevels.forEach(({ level, name, key }) => {
this.addCommand({
id: `insert-header-${level}`,
name: `Toggle ${name}`,
editorCallback: (editor, view) => {
// Check if we're in a sync embed
const focusedEmbed = this.getFocusedEmbed();
if (focusedEmbed) {
// Use our custom handler
const handler = this.commandInterceptor.insertHeaderCommand(level);
return handler(focusedEmbed);
}
// Fall back to normal behavior for non-embed editing
this.commandInterceptor.insertHeader({ editor }, level);
},
hotkeys: [
{
modifiers: ['Alt'],
key: key
}
]
});
});
this.log('Registered header commands with default hotkeys (Alt+2-6)');
}
// Override executeCommand to route to our embeds when appropriate
this.app.commands.executeCommand = (command, ...args) => {
const focusedEmbed = this.getFocusedEmbed();
setupCommandInterception() {
try {
// Patch executeCommand to intercept ALL commands for focused embeds
const executeCommandUninstall = around(this.app.commands, {
executeCommand: (old) => {
const plugin = this;
return function(command, ...args) {
const focusedEmbed = plugin.getFocusedEmbed();
// If embed is focused, check if we should intercept
if (focusedEmbed) {
// Check for our header commands first
const headerMatch = command.id.match(/^sync-embeds:insert-header-(\d+)$/);
if (headerMatch) {
const level = parseInt(headerMatch[1]);
plugin.log(`Intercepting header command: ${command.id}`);
const handler = plugin.commandInterceptor.insertHeaderCommand(level);
return handler(focusedEmbed);
}
// Check if we have a custom handler
if (plugin.commandInterceptor.hasHandler(command.id)) {
plugin.log(`Intercepting command: ${command.id}`);
return plugin.commandInterceptor.handle(command.id, focusedEmbed, ...args);
}
// For commands we don't handle, let them execute on the embed's editor
// This ensures ALL hotkeys work, including custom user-defined ones
plugin.log(`Passing through command to embed: ${command.id}`);
// Check if command has a callback that expects an editor
if (command.editorCallback && focusedEmbed.editor) {
try {
return command.editorCallback(focusedEmbed.editor, focusedEmbed.view);
} catch (error) {
plugin.log(`Error executing command ${command.id} on embed:`, error);
// Fall through to normal execution
}
}
}
return old.call(this, command, ...args);
};
}
});
this.uninstallers.push(executeCommandUninstall);
// Patch getActiveViewOfType to return embed view when focused
const getActiveViewUninstall = around(this.app.workspace, {
getActiveViewOfType: (old) => {
const plugin = this;
return function(type) {
const focusedEmbed = plugin.getFocusedEmbed();
if (focusedEmbed && focusedEmbed.view instanceof type) {
plugin.log(`Returning embed view for type: ${type.name}`);
return focusedEmbed.view;
}
return old.call(this, type);
};
}
});
this.uninstallers.push(getActiveViewUninstall);
if (focusedEmbed && this.commandInterceptors.has(command.id)) {
// Route to our custom handler
const handler = this.commandInterceptors.get(command.id);
return handler.call(this, focusedEmbed, ...args);
}
// Patch getActiveViewOfType on workspace.activeLeaf as well
const getActiveLeafUninstall = around(this.app.workspace, {
activeLeaf: {
get: (old) => {
const plugin = this;
return function() {
const focusedEmbed = plugin.getFocusedEmbed();
if (focusedEmbed && focusedEmbed.leaf) {
plugin.log('Returning embed leaf as active leaf');
return focusedEmbed.leaf;
}
return old.call(this);
};
}
}
});
this.uninstallers.push(getActiveLeafUninstall);
// Otherwise, use the default Obsidian behavior
return this.originalExecuteCommand.call(this.app.commands, command, ...args);
};
// Override getActiveViewOfType to make Obsidian think our embed is the active view
this.app.workspace.getActiveViewOfType = (type) => {
const focusedEmbed = this.getFocusedEmbed();
if (focusedEmbed && focusedEmbed.view instanceof type) {
return focusedEmbed.view;
}
return this.originalGetActiveViewOfType.call(this.app.workspace, type);
};
}
restoreObsidianCommands() {
if (this.originalExecuteCommand) {
this.app.commands.executeCommand = this.originalExecuteCommand;
}
if (this.originalGetActiveViewOfType) {
this.app.workspace.getActiveViewOfType = this.originalGetActiveViewOfType;
this.log('Command interception setup complete');
} catch (error) {
console.error('Sync Embeds: Failed to setup command interception:', error);
}
}
// SETUP COMMAND INTERCEPTORS
setupCommandInterceptors() {
const commandMappings = {
'editor:toggle-checklist-status': this.toggleChecklistCommand,
'editor:toggle-bold': this.toggleBoldCommand,
'editor:toggle-italics': this.toggleItalicCommand,
'editor:toggle-strikethrough': this.toggleStrikethroughCommand,
'editor:toggle-code': this.toggleCodeCommand,
'editor:insert-link': this.insertLinkCommand,
'editor:toggle-bullet-list': this.toggleBulletListCommand,
'editor:toggle-numbered-list': this.toggleNumberedListCommand,
'editor:indent-list': this.indentListCommand,
'editor:unindent-list': this.unindentListCommand,
'editor:insert-tag': this.insertTagCommand,
'editor:swap-line-up': this.swapLineUpCommand,
'editor:swap-line-down': this.swapLineDownCommand,
'editor:duplicate-line': this.duplicateLineCommand,
'editor:delete-line': this.deleteLineCommand
};
for (const [commandId, handler] of Object.entries(commandMappings)) {
this.commandInterceptors.set(commandId, handler);
}
}
// FOCUS TRACKING METHODS
// Focus tracking
trackFocus(event) {
const embed = this.getEmbedFromElement(event.target);
const embed = this.embedManager.getEmbedFromElement(event.target);
if (embed) {
this.currentFocusedEmbed = embed;
this.log('Focus changed to embed:', embed.file?.basename);
}
}
trackFocusLoss(event) {
const embed = this.getEmbedFromElement(event.relatedTarget);
const embed = this.embedManager.getEmbedFromElement(event.relatedTarget);
if (!embed) {
this.log('Focus lost from embed');
this.currentFocusedEmbed = null;
}
}
// UTILITY METHODS
getFocusedEmbed() {
return this.currentFocusedEmbed;
}
getEmbedFromElement(element) {
if (!element) return null;
let current = element;
while (current && current !== document.body) {
if (current.classList && current.classList.contains('sync-embed')) {
return this.embedRegistry.get(current);
}
current = current.parentElement;
}
return null;
// Settings management
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
// COMMAND IMPLEMENTATIONS
toggleChecklistCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
async saveSettings() {
await this.saveData(this.settings);
this.refreshAllEmbeds();
this.updateFocusHighlight();
}
if (line.match(/^\s*- \[ \]/)) {
editor.replaceRange(line.replace(/- \[ \]/, '- [x]'), {line: cursor.line, ch: 0}, {line: cursor.line, ch: line.length});
} else if (line.match(/^\s*- \[x\]/i)) {
editor.replaceRange(line.replace(/- \[x\]/i, '- [ ]'), {line: cursor.line, ch: 0}, {line: cursor.line, ch: line.length});
updateFocusHighlight() {
if (this.settings.showFocusHighlight) {
document.body.removeClass('sync-embeds-no-focus-highlight');
} else {
const indent = line.match(/^\s*/)[0];
const content = line.substring(indent.length);
editor.replaceRange(`${indent}- [ ] ${content}`, {line: cursor.line, ch: 0}, {line: cursor.line, ch: line.length});
}
return true;
}
// Helper for toggling markdown formatting
toggleMarkdownFormatting(embedData, markdownChar) {
const { editor } = embedData;
const selection = editor.getSelection();
const len = markdownChar.length;
if (selection && selection.startsWith(markdownChar) && selection.endsWith(markdownChar)) {
editor.replaceSelection(selection.slice(len, -len));
} else if (selection) {
editor.replaceSelection(`${markdownChar}${selection}${markdownChar}`);
} else {
const cursor = editor.getCursor();
editor.replaceRange(markdownChar + markdownChar, cursor);
editor.setCursor({ line: cursor.line, ch: cursor.ch + len });
}
return true;
}
toggleBoldCommand(embedData) { return this.toggleMarkdownFormatting(embedData, '**'); }
toggleItalicCommand(embedData) { return this.toggleMarkdownFormatting(embedData, '*'); }
toggleStrikethroughCommand(embedData) { return this.toggleMarkdownFormatting(embedData, '~~'); }
toggleCodeCommand(embedData) { return this.toggleMarkdownFormatting(embedData, '`'); }
insertLinkCommand(embedData) {
const { editor } = embedData;
const selection = editor.getSelection();
if (selection) {
editor.replaceSelection(`[[${selection}]]`);
} else {
const cursor = editor.getCursor();
editor.replaceRange('[[]]', cursor);
editor.setCursor({ line: cursor.line, ch: cursor.ch + 2 });
}
return true;
}
toggleBulletListCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
if (line.match(/^\s*- /)) {
editor.replaceRange(line.replace(/^\s*- /, ''), {line: cursor.line, ch: 0}, {line: cursor.line, ch: line.length});
} else {
editor.replaceRange(`- ${line}`, {line: cursor.line, ch: 0}, {line: cursor.line, ch: line.length});
}
return true;
}
toggleNumberedListCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
if (line.match(/^\s*\d+\. /)) {
editor.replaceRange(line.replace(/^\s*\d+\. /, ''), {line: cursor.line, ch: 0}, {line: cursor.line, ch: line.length});
} else {
editor.replaceRange(`1. ${line}`, {line: cursor.line, ch: 0}, {line: cursor.line, ch: line.length});
}
return true;
}
indentListCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
editor.replaceRange('\t', {line: cursor.line, ch: 0});
return true;
}
unindentListCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
if (line.startsWith('\t')) {
editor.replaceRange('', {line: cursor.line, ch: 0}, {line: cursor.line, ch: 1});
}
return true;
}
insertTagCommand(embedData) {
const { editor } = embedData;
editor.replaceSelection('#');
return true;
}
swapLineUpCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
if (cursor.line > 0) {
const currentLine = editor.getLine(cursor.line);
const prevLine = editor.getLine(cursor.line - 1);
editor.transaction({
changes: [
{ from: {line: cursor.line - 1, ch: 0}, to: {line: cursor.line - 1, ch: prevLine.length}, text: currentLine },
{ from: {line: cursor.line, ch: 0}, to: {line: cursor.line, ch: currentLine.length}, text: prevLine }
],
selection: { from: {line: cursor.line - 1, ch: cursor.ch} }
});
}
return true;
}
swapLineDownCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
if (cursor.line < editor.lastLine()) {
const currentLine = editor.getLine(cursor.line);
const nextLine = editor.getLine(cursor.line + 1);
editor.transaction({
changes: [
{ from: {line: cursor.line, ch: 0}, to: {line: cursor.line, ch: currentLine.length}, text: nextLine },
{ from: {line: cursor.line + 1, ch: 0}, to: {line: cursor.line + 1, ch: nextLine.length}, text: currentLine }
],
selection: { from: {line: cursor.line + 1, ch: cursor.ch} }
});
}
return true;
}
duplicateLineCommand(embedData) {
const { editor } = embedData;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
editor.replaceRange(`\n${line}`, {line: cursor.line, ch: line.length});
return true;
}
deleteLineCommand(embedData) {
const { editor } = embedData;
const { line } = editor.getCursor();
editor.replaceRange('', {line: line, ch: 0}, {line: line + 1, ch: 0});
return true;
}
// MAIN PROCESSING METHODS
async processSyncBlock(source, el, ctx) {
el.empty();
const syncContainer = el.createDiv('sync-container');
const embedLines = source.split('\n').map(line => line.trim()).filter(line => line.startsWith('![[') && line.endsWith(']]'));
if (embedLines.length === 0) {
syncContainer.createDiv('sync-empty').setText('No embeds found in sync block');
return;
}
for (let i = 0; i < embedLines.length; i++) {
await this.processEmbed(embedLines[i], syncContainer, ctx, i > 0);
document.body.addClass('sync-embeds-no-focus-highlight');
}
}
async processEmbed(embedLine, container, ctx, addGap) {
try {
const match = embedLine.match(/!\[\[([^\]]+)\]\]/);
if (!match) return;
const linkText = match[1];
const linkPath = linkText.split('|')[0].trim();
let notePath = linkPath.split('#')[0];
const section = linkPath.includes('#') ? linkPath.substring(linkPath.indexOf('#') + 1) : null;
if (!notePath) notePath = ctx.sourcePath;
const file = this.app.metadataCache.getFirstLinkpathDest(notePath, ctx.sourcePath);
if (!file) { this.renderError(container, `Note not found: ${notePath}`, addGap); return; }
if (file.path === ctx.sourcePath) { this.renderError(container, "Cannot create a recursive embed of the same note.", addGap); return; }
const embedContainer = container.createDiv('sync-embed');
if (addGap) embedContainer.addClass('sync-embed-gap');
const component = new Component();
const leaf = new WorkspaceLeaf(this.app);
component.load();
component.addChild(new (class extends Component {
constructor(plugin, containerEl) {
super();
this.plugin = plugin;
this.containerEl = containerEl;
}
async onunload() {
this.plugin.embedRegistry.delete(this.containerEl);
if (this.plugin.currentFocusedEmbed && this.plugin.currentFocusedEmbed.containerEl === this.containerEl) {
this.plugin.currentFocusedEmbed = null;
}
leaf.detach();
}
})(this, embedContainer));
await leaf.openFile(file, { state: { mode: "source" } });
const view = leaf.view;
if (!(view instanceof MarkdownView)) {
this.renderError(embedContainer, 'Failed to load a markdown view.', addGap);
leaf.detach();
return;
}
const embedData = { view, editor: view.editor, containerEl: embedContainer, file, section, component };
this.embedRegistry.set(embedContainer, embedData);
if (section) {
await this.setupSectionEmbed(embedData);
}
embedContainer.appendChild(view.containerEl);
embedContainer.style.height = 'auto';
ctx.addChild(component);
} catch (error) {
console.error('Sync Embeds: Error processing embed:', error);
this.renderError(container, `Error loading: ${error.message}`, addGap);
}
}
async setupSectionEmbed(embedData) {
const { view, file, section, component } = embedData;
refreshAllEmbeds() {
// Update CSS variables for all sync containers
document.querySelectorAll('.sync-container').forEach(container => {
container.style.setProperty('--sync-embed-height', this.settings.embedHeight);
container.style.setProperty('--sync-max-height', this.settings.maxEmbedHeight);
container.style.setProperty('--sync-gap', this.settings.gapBetweenEmbeds);
});
let isProgrammaticUpdate = false;
let isSaving = false;
let originalHeader = "";
let headerLevel = 0;
const updateHeaderState = (content) => {
originalHeader = content.split('\n')[0] || '';
headerLevel = (originalHeader.match(/^#+/)?.[0] || '#').length;
};
const loadSection = async () => {
isProgrammaticUpdate = true;
const fileContent = await this.app.vault.read(file);
const currentSectionContent = this.extractSection(fileContent, section);
updateHeaderState(currentSectionContent);
const cursor = view.editor.getCursor();
view.editor.setValue(currentSectionContent);
if (view.editor.getDoc().lineCount() > cursor.line && view.editor.getLine(cursor.line).length >= cursor.ch) {
view.editor.setCursor(cursor);
}
setTimeout(() => isProgrammaticUpdate = false, 50);
};
await loadSection();
view.file = null;
const debouncedSave = debounce(async () => {
if (isSaving) return;
isSaving = true;
const newEmbedContent = view.editor.getValue();
const currentFileContent = await this.app.vault.read(file);
const newFileContent = this.replaceSection(currentFileContent, section, newEmbedContent);
if (newFileContent !== currentFileContent) {
await this.app.vault.modify(file, newFileContent);
}
isSaving = false;
}, 750, true);
component.registerEvent(this.app.vault.on('modify', async (modifiedFile) => {
if (modifiedFile.path === file.path && !isSaving) await loadSection();
}));
let lastCorrectedLineNumber = -1;
component.registerEvent(this.app.workspace.on('editor-change', (editor) => {
if (editor !== view.editor || isProgrammaticUpdate) return;
const cursorPos = editor.getCursor();
const line = editor.getLine(cursorPos.line);
const currentLineNumber = cursorPos.line;
if (currentLineNumber === 0 && line !== originalHeader) {
isProgrammaticUpdate = true;
editor.replaceRange(originalHeader, { line: 0, ch: 0 }, { line: 0, ch: line.length });
isProgrammaticUpdate = false;
return;
}
if (currentLineNumber > 0 && line.trim().startsWith('#')) {
const currentHashes = (line.match(/^#+/) || [''])[0];
const currentLevel = currentHashes.length;
const requiredLevel = headerLevel + 1;
const requiredHashes = '#'.repeat(requiredLevel);
if (currentLineNumber === lastCorrectedLineNumber && currentLevel < requiredLevel) {
isProgrammaticUpdate = true;
editor.replaceRange("", { line: currentLineNumber, ch: 0 }, { line: currentLineNumber, ch: currentLevel });
isProgrammaticUpdate = false;
lastCorrectedLineNumber = -1;
debouncedSave();
return;
}
if (currentLevel <= headerLevel) {
isProgrammaticUpdate = true;
editor.replaceRange(requiredHashes, { line: currentLineNumber, ch: 0 }, { line: currentLineNumber, ch: currentLevel });
lastCorrectedLineNumber = currentLineNumber;
isProgrammaticUpdate = false;
} else { lastCorrectedLineNumber = -1; }
} else { lastCorrectedLineNumber = -1; }
debouncedSave();
}));
this.log('Refreshed all embeds with new settings');
}
// UTILITY AND SECTION HELPERS
renderError(container, message, addGap) {
const errorDiv = container.createDiv('sync-embed-error');
if (addGap) errorDiv.addClass('sync-embed-gap');
errorDiv.setText(message);
}
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
extractSection(content, sectionName) {
const lines = content.split('\n');
const headerRegex = new RegExp(`^#{1,6}\\s+${this.escapeRegExp(sectionName)}\\s*$`);
let startIdx = -1, sectionLevel = 0;
for (let i = 0; i < lines.length; i++) {
if (headerRegex.test(lines[i])) {
startIdx = i;
sectionLevel = (lines[i].match(/^#+/)?.[0] || '').length;
break;
}
// Debug logging
log(...args) {
if (this.settings.debugMode) {
console.log('[Sync Embeds]', ...args);
}
if (startIdx === -1) return `# ${sectionName}\n\n*Section not found.*`;
let endIdx = lines.length;
for (let i = startIdx + 1; i < lines.length; i++) {
const match = lines[i].match(/^#+/);
if (match && match[0].length <= sectionLevel) {
endIdx = i;
break;
}
}
return lines.slice(startIdx, endIdx).join('\n');
}
replaceSection(fullContent, sectionName, newSectionText) {
const lines = fullContent.split('\n');
const headerRegex = new RegExp(`^#{1,6}\\s+${this.escapeRegExp(sectionName)}\\s*$`);
let startIdx = -1, sectionLevel = 0;
for (let i = 0; i < lines.length; i++) {
if (headerRegex.test(lines[i])) {
startIdx = i;
sectionLevel = (lines[i].match(/^#+/)?.[0] || '').length;
break;
}
}
if (startIdx === -1) return `${fullContent.trim()}\n\n${newSectionText}`.trim();
let endIdx = lines.length;
for (let i = startIdx + 1; i < lines.length; i++) {
const match = lines[i].match(/^#+/);
if (match && match[0].length <= sectionLevel) {
endIdx = i;
break;
}
}
const before = lines.slice(0, startIdx);
const after = lines.slice(endIdx);
return [...before, ...newSectionText.split('\n'), ...after].join('\n');
}
};

View file

@ -1,9 +1,9 @@
{
"id": "sync-embeds",
"name": "Sync Embeds",
"version": "1.1.2",
"version": "2.0.0",
"minAppVersion": "0.15.0",
"description": "Create fully editable, live-synced note embeds.",
"description": "Create fully editable, live-synced note embeds with advanced features including section viewports, dynamic paths, and command interception.",
"author": "uthvah",
"authorUrl": "https://github.com/uthvah",
"isDesktopOnly": false

350
settings.js Normal file
View file

@ -0,0 +1,350 @@
const { PluginSettingTab, Setting, Notice } = require('obsidian');
class SyncEmbedsSettingTab extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Sync Embeds Settings' });
// === APPEARANCE SECTION ===
containerEl.createEl('h3', { text: 'Appearance' });
// Height presets + custom
new Setting(containerEl)
.setName('Embed height')
.setDesc('Default height for embeds')
.addDropdown(dropdown => dropdown
.addOption('auto', 'Auto (fit content)')
.addOption('300px', 'Compact (300px)')
.addOption('500px', 'Normal (500px)')
.addOption('700px', 'Large (700px)')
.addOption('custom', 'Custom')
.setValue(this.getHeightPreset())
.onChange(async (value) => {
if (value !== 'custom') {
this.plugin.settings.embedHeight = value;
await this.plugin.saveSettings();
this.display(); // Refresh to hide custom input
} else {
this.display(); // Show custom input
}
}));
// Show custom input if needed
if (this.getHeightPreset() === 'custom') {
new Setting(containerEl)
.setName('Custom height')
.setDesc('Enter a custom height (e.g., 450px, 60vh)')
.addText(text => text
.setPlaceholder('e.g., 450px, 60vh')
.setValue(this.plugin.settings.embedHeight)
.onChange(async (value) => {
if (this.validateCSSValue(value)) {
this.plugin.settings.embedHeight = value;
await this.plugin.saveSettings();
}
}));
}
// Max height presets + custom
new Setting(containerEl)
.setName('Maximum height')
.setDesc('Maximum height before scrolling')
.addDropdown(dropdown => dropdown
.addOption('none', 'None (no limit)')
.addOption('400px', 'Compact (400px)')
.addOption('600px', 'Normal (600px)')
.addOption('80vh', 'Large (80vh)')
.addOption('custom', 'Custom')
.setValue(this.getMaxHeightPreset())
.onChange(async (value) => {
if (value !== 'custom') {
this.plugin.settings.maxEmbedHeight = value;
await this.plugin.saveSettings();
this.display();
} else {
this.display();
}
}));
// Show custom input if needed
if (this.getMaxHeightPreset() === 'custom') {
new Setting(containerEl)
.setName('Custom maximum height')
.setDesc('Enter a custom maximum height (e.g., 550px, 70vh)')
.addText(text => text
.setPlaceholder('e.g., 550px, 70vh')
.setValue(this.plugin.settings.maxEmbedHeight)
.onChange(async (value) => {
if (this.validateCSSValue(value)) {
this.plugin.settings.maxEmbedHeight = value;
await this.plugin.saveSettings();
}
}));
}
// Gap presets + custom
new Setting(containerEl)
.setName('Gap between embeds')
.setDesc('Spacing between multiple embeds in a sync block')
.addDropdown(dropdown => dropdown
.addOption('8px', 'Compact (8px)')
.addOption('16px', 'Normal (16px)')
.addOption('24px', 'Spacious (24px)')
.addOption('custom', 'Custom')
.setValue(this.getGapPreset())
.onChange(async (value) => {
if (value !== 'custom') {
this.plugin.settings.gapBetweenEmbeds = value;
await this.plugin.saveSettings();
this.display();
} else {
this.display();
}
}));
// Show custom input if needed
if (this.getGapPreset() === 'custom') {
new Setting(containerEl)
.setName('Custom gap')
.setDesc('Enter a custom gap (e.g., 20px, 1.5rem)')
.addText(text => text
.setPlaceholder('e.g., 20px, 1.5rem')
.setValue(this.plugin.settings.gapBetweenEmbeds)
.onChange(async (value) => {
if (this.validateCSSValue(value)) {
this.plugin.settings.gapBetweenEmbeds = value;
await this.plugin.saveSettings();
}
}));
}
// === BEHAVIOR SECTION ===
containerEl.createEl('h3', { text: 'Behavior' });
new Setting(containerEl)
.setName('Collapse properties by default')
.setDesc('Hide frontmatter/properties in embeds by default')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.collapsePropertiesByDefault)
.onChange(async (value) => {
this.plugin.settings.collapsePropertiesByDefault = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Show inline title')
.setDesc('Display note title at the top of whole-note embeds (not applicable to section embeds or embeds with aliases)')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showInlineTitle)
.onChange(async (value) => {
this.plugin.settings.showInlineTitle = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Show focus highlight')
.setDesc('Highlight the focused embed with an outline')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showFocusHighlight)
.onChange(async (value) => {
this.plugin.settings.showFocusHighlight = value;
await this.plugin.saveSettings();
}));
// === HEADER MANAGEMENT SECTION ===
containerEl.createEl('h3', { text: 'Header Management' });
new Setting(containerEl)
.setName('Show header hints')
.setDesc('Display helpful notices when header creation is blocked in section embeds')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showHeaderHints)
.onChange(async (value) => {
this.plugin.settings.showHeaderHints = value;
await this.plugin.saveSettings();
}));
// === PERFORMANCE SECTION ===
containerEl.createEl('h3', { text: 'Performance' });
new Setting(containerEl)
.setName('Lazy loading threshold')
.setDesc('Start loading embeds this distance before they become visible')
.addDropdown(dropdown => dropdown
.addOption('0px', 'On screen (0px)')
.addOption('100px', 'Just before (100px)')
.addOption('200px', 'Well before (200px)')
.addOption('500px', 'Early (500px)')
.setValue(this.plugin.settings.lazyLoadThreshold)
.onChange(async (value) => {
this.plugin.settings.lazyLoadThreshold = value;
await this.plugin.saveSettings();
}));
// === ADVANCED SECTION ===
containerEl.createEl('h3', { text: 'Advanced' });
new Setting(containerEl)
.setName('Enable command interception')
.setDesc('Allow keyboard shortcuts (Ctrl+B, Ctrl+I, etc.) to work in embeds. Requires restart.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableCommandInterception)
.onChange(async (value) => {
this.plugin.settings.enableCommandInterception = value;
await this.plugin.saveSettings();
new Notice('Please restart Obsidian for this change to take effect');
}));
// Debug mode
new Setting(containerEl)
.setName('Debug mode')
.setDesc('Enable detailed console logging for troubleshooting')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.debugMode || false)
.onChange(async (value) => {
this.plugin.settings.debugMode = value;
await this.plugin.saveSettings();
}));
// === HELP SECTION ===
containerEl.createEl('h3', { text: 'Help & Documentation' });
const helpDiv = containerEl.createDiv('sync-embeds-help');
helpDiv.innerHTML = `
<p><strong>Basic Usage:</strong></p>
<ul>
<li><code>![[Note Name]]</code> - Embed entire note</li>
<li><code>![[Note Name#Section]]</code> - Embed specific section</li>
<li><code>![[Note Name|Custom Title]]</code> - Display with custom title</li>
<li><code>![[Note Name#Section|Custom Title]]</code> - Section with custom title</li>
</ul>
<p><strong>Per-Embed Custom Options:</strong></p>
<ul>
<li><code>![[Note|Alias{height:500px}]]</code> - Custom height for this embed</li>
<li><code>![[Note|Alias{maxHeight:600px}]]</code> - Custom max height</li>
<li><code>![[Note|Alias{title:false}]]</code> - Hide title for this embed</li>
<li><code>![[Note|Alias{height:400px,title:false}]]</code> - Multiple options</li>
</ul>
<p><em>Note: Options go inside curly braces before the closing ]]</em></p>
<p><strong>Dynamic Patterns:</strong></p>
<ul>
<li><code>![[Daily/{{date:YYYY-MM-DD}}|Today]]</code> - Current date with display name</li>
<li><code>![[Tasks#{{date:YYYY-MM-DD}}|Today's Tasks]]</code> - Dynamic section</li>
<li><code>{{date-7d:YYYY-MM-DD}}</code> - 7 days ago</li>
<li><code>{{date+1w:YYYY-MM-DD}}</code> - 1 week from now</li>
<li><code>{{date+2m:YYYY-MM-DD}}</code> - 2 months from now</li>
<li><code>{{time:HH:mm}}</code> - Current time</li>
<li><code>{{title}}</code> - Current note's title</li>
</ul>
<p><strong>Header Management:</strong></p>
<ul>
<li>Use <code>Alt+2</code> through <code>Alt+6</code> to insert headers (H2-H6)</li>
<li>In section embeds, only sub-headers are allowed (e.g., if section is H2, only H3-H6 work)</li>
<li>Typing <code>#</code> at line start is blocked in section embeds to prevent hierarchy violations</li>
<li>Press the same hotkey again on a header to remove formatting</li>
<li>Press a different hotkey to change header level</li>
<li>Whole-note embeds allow H1-H6 freely</li>
<li>These hotkeys can be customized in Obsidian's Hotkeys settings</li>
</ul>
<p><strong>Date Format Examples:</strong></p>
<ul>
<li><code>YYYY-MM-DD</code> - 2024-03-15</li>
<li><code>YYYY/MM/DD</code> - 2024/03/15</li>
<li><code>DD MMM YYYY</code> - 15 Mar 2024</li>
<li><code>dddd, MMMM Do YYYY</code> - Friday, March 15th 2024</li>
</ul>
<p><strong>Complete Example:</strong></p>
<pre><code>\`\`\`sync
![[Daily Notes/{{date:YYYY-MM-DD}}|Today's Note]]
![[Daily Notes/{{date-1d:YYYY-MM-DD}}|Yesterday]]
![[Tasks#Inbox|My Tasks{height:300px}]]
![[Projects/{{title}}#Notes|Project Notes{title:false}]]
\`\`\`</code></pre>
<p><strong>Tips:</strong></p>
<ul>
<li>Use aliases (text after <code>|</code>) with dynamic patterns for better display</li>
<li>Per-embed options override global settings: <code>{height:400px,title:false}</code></li>
<li>Section embeds are fully editable and changes sync immediately</li>
<li>Press Tab/Shift+Tab to navigate between embeds</li>
<li>Keyboard shortcuts work inside embeds when command interception is enabled</li>
<li>Use lazy loading for better performance with many embeds</li>
<li>Multiple embeds of the same note/section are allowed</li>
<li>Header hierarchy enforcement maintains document structure in section embeds</li>
</ul>
<p><em>Note: Dynamic patterns are cached for 1 second to improve performance.</em></p>
`;
// Reset to defaults button
containerEl.createEl('h3', { text: 'Reset' });
new Setting(containerEl)
.setName('Reset to defaults')
.setDesc('Reset all settings to their default values')
.addButton(button => button
.setButtonText('Reset')
.setWarning()
.onClick(async () => {
if (confirm('Are you sure you want to reset all settings to defaults?')) {
Object.assign(this.plugin.settings, this.plugin.DEFAULT_SETTINGS);
await this.plugin.saveSettings();
this.display();
new Notice('Settings reset to defaults');
}
}));
}
// Helper methods for presets
getHeightPreset() {
const value = this.plugin.settings.embedHeight;
if (['auto', '300px', '500px', '700px'].includes(value)) {
return value;
}
return 'custom';
}
getMaxHeightPreset() {
const value = this.plugin.settings.maxEmbedHeight;
if (['none', '400px', '600px', '80vh'].includes(value)) {
return value;
}
return 'custom';
}
getGapPreset() {
const value = this.plugin.settings.gapBetweenEmbeds;
if (['8px', '16px', '24px'].includes(value)) {
return value;
}
return 'custom';
}
validateCSSValue(value) {
// Basic validation for CSS length values
if (!value || value.trim() === '') return false;
// Allow common CSS units
const validPattern = /^(auto|none|\d+(\.\d+)?(px|em|rem|vh|vw|%))$/;
const isValid = validPattern.test(value.trim());
if (!isValid) {
new Notice('Invalid CSS value. Use units like: px, em, rem, vh, vw, %, or "auto"/"none"');
}
return isValid;
}
}
module.exports = SyncEmbedsSettingTab;

View file

@ -1,30 +1,48 @@
/* Main container for a ```sync``` code block */
/* === SYNC CONTAINER === */
.sync-container {
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 16px;
background: var(--background-primary);
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--sync-gap, 16px);
padding: 16px;
}
/* Wrapper for a single `![[...]]` embed inside the container */
/* === EMBED WRAPPER === */
.sync-embed {
border: none !important;
margin: 0 !important;
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
height: var(--sync-embed-height, auto);
max-height: var(--sync-max-height, none);
overflow-y: auto;
position: relative;
}
/* --- SEAMLESS STYLING FOR THE EMBEDDED EDITOR --- */
/* Gap between embeds */
.sync-embed-gap {
padding-top: var(--sync-gap, 16px);
border-top: 1px solid var(--background-modifier-border-hover);
}
/* === NATIVE OBSIDIAN PADDING SYSTEM (REBUILT) === */
/* 1. Hide the view header */
.sync-embed .view-header {
display: none !important;
}
.sync-embed .view-content,
/* 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 {
@ -33,46 +51,158 @@
margin: 0 !important;
}
/* 4. Content area - FIXED PADDING SYSTEM */
.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;
}
/* 5. Ensure full width */
.sync-embed .markdown-source-view.mod-cm6 .cm-editor {
width: 100%;
}
.sync-embed .view-content {
padding: 0 var(--file-margins) !important;
/* 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;
}
.sync-embed .cm-s-obsidian .cm-gutters,
.sync-embed .cm-s-obsidian .cm-content {
padding-top: 0 !important;
padding-bottom: 1em !important;
}
/* --- STAGE 2 FIXES --- */
/*
* Make the inline title visible and not greyed out.
*/
/* === INLINE TITLE === */
.sync-embed .inline-title {
pointer-events: none; /* Disables all clicks, focus, and hover */
user-select: none; /* Prevents the user from selecting the text */
opacity: 1; /* Restores the default text color */
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;
}
/*
* Make the header in a section embed uneditable.
*/
.uneditable-header {
pointer-events: none !important;
user-select: none !important;
opacity: 0.7 !important;
background-color: transparent !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;
pointer-events: none !important;
user-select: none !important;
cursor: default !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
}
/* --- UTILITY & ERROR STATE STYLING --- */
.sync-embed-gap {
padding-top: 16px;
border-top: 1px solid var(--background-modifier-border-hover);
/* 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)));
}
/* === PROPERTIES/FRONTMATTER === */
.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 {
color: var(--h1-color, var(--text-normal)) !important;
}
.sync-embed .cm-header-2,
.sync-embed .HyperMD-header-2 {
color: var(--h2-color, var(--text-normal)) !important;
}
.sync-embed .cm-header-3,
.sync-embed .HyperMD-header-3 {
color: var(--h3-color, var(--text-normal)) !important;
}
.sync-embed .cm-header-4,
.sync-embed .HyperMD-header-4 {
color: var(--h4-color, var(--text-normal)) !important;
}
.sync-embed .cm-header-5,
.sync-embed .HyperMD-header-5 {
color: var(--h5-color, var(--text-normal)) !important;
}
.sync-embed .cm-header-6,
.sync-embed .HyperMD-header-6 {
color: var(--h6-color, var(--text-normal)) !important;
}
.sync-embed .cm-scroller {
overflow-x: hidden !important;
scroll-behavior: smooth;
}
/* === LOADING STATE === */
.sync-embed-loading {
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.sync-embed-placeholder {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-style: italic;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* === ERROR STATE === */
.sync-empty {
padding: 20px;
text-align: center;
@ -81,9 +211,197 @@
}
.sync-embed-error {
padding: 8px 12px;
padding: 12px 16px;
background: var(--background-modifier-error);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
color: var(--text-error);
margin: 4px 0;
font-size: 0.9em;
}
/* === FOCUS HIGHLIGHT === */
.sync-embed {
outline: 2px solid transparent;
outline-offset: 2px;
border-radius: 4px;
transition: outline-color 0.1s ease;
}
.sync-embed:focus-within {
outline-color: var(--interactive-accent);
}
body.sync-embeds-no-focus-highlight .sync-embed:focus-within {
outline-color: transparent;
}
/* === SCROLLBAR === */
.sync-embed::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.sync-embed::-webkit-scrollbar-track {
background: transparent;
}
.sync-embed::-webkit-scrollbar-thumb {
background: var(--background-modifier-border);
border-radius: 4px;
transition: background 0.2s ease;
}
.sync-embed::-webkit-scrollbar-thumb:hover {
background: var(--background-modifier-border-hover);
}
.sync-embed {
scrollbar-width: thin;
scrollbar-color: var(--background-modifier-border) transparent;
}
/* === HELP SECTION === */
.sync-embeds-help {
background: var(--background-secondary);
padding: 16px;
border-radius: 8px;
margin-top: 12px;
line-height: 1.6;
}
.sync-embeds-help h4 {
margin-top: 12px;
margin-bottom: 8px;
color: var(--text-normal);
}
.sync-embeds-help ul {
margin-left: 20px;
margin-top: 8px;
margin-bottom: 12px;
}
.sync-embeds-help li {
margin-bottom: 4px;
}
.sync-embeds-help code {
background: var(--background-primary-alt);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--font-monospace);
font-size: 0.9em;
color: var(--code-normal);
}
.sync-embeds-help pre {
background: var(--background-primary-alt);
padding: 12px;
border-radius: 4px;
margin-top: 8px;
margin-bottom: 12px;
overflow-x: auto;
}
.sync-embeds-help pre code {
background: transparent;
padding: 0;
}
.sync-embeds-help p {
margin: 8px 0;
}
.sync-embeds-help em {
color: var(--text-muted);
font-size: 0.9em;
}
.sync-embeds-help strong {
color: var(--text-normal);
}
/* === RESPONSIVE === */
@media (max-width: 768px) {
.sync-container {
padding: 12px;
gap: 12px;
}
.sync-embed-gap {
padding-top: 12px;
}
.sync-embed-alias-header {
font-size: calc(var(--inline-title-size, var(--h1-size, 2em)) * 0.85);
}
}
/* === THEME COMPATIBILITY === */
.theme-light .sync-embed,
.theme-dark .sync-embed {
color: var(--text-normal);
}
.sync-embed .cm-link,
.sync-embed .cm-hmd-internal-link {
color: var(--link-color, var(--text-accent)) !important;
}
.sync-embed .cm-url {
color: var(--link-external-color, var(--text-accent)) !important;
}
/* === ACCESSIBILITY === */
@media (prefers-contrast: high) {
.sync-container {
border-width: 2px;
}
.sync-embed:focus-within {
outline-width: 3px;
}
.sync-embed-error {
border-width: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.sync-embed-placeholder {
animation: none;
opacity: 0.8;
}
.sync-embed,
.properties-collapse-toggle,
.sync-embed::-webkit-scrollbar-thumb {
transition: none;
}
.sync-embed .cm-scroller {
scroll-behavior: auto;
}
}
/* === PRINT === */
@media print {
.sync-container {
border: 1px solid #ccc;
page-break-inside: avoid;
}
.sync-embed {
max-height: none !important;
overflow: visible !important;
}
.properties-collapse-toggle {
display: none;
}
.metadata-container.is-collapsed .metadata-content {
display: block;
}
}

406
viewport-controller.js Normal file
View file

@ -0,0 +1,406 @@
const { Notice } = require('obsidian');
class ViewportController {
constructor(plugin) {
this.plugin = plugin;
}
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);
if (sectionInfo.startLine === -1) {
// Section not found
editor.setValue(`# ${section}\n\n*Section not found in file.*`);
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);
}
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;
}
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;
}
/* Hide all lines after the section */
[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;
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.
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 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(
newLine,
{ line: cursor.line, ch: 0 },
{ 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 &&
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
);
}
}
};
// 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) {
hasInvalidHeaders = true;
// Adjust to minimum allowed level
const newLevel = headerLevel + 1;
return '#'.repeat(newLevel) + ' ' + content;
}
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 });
isProgrammaticUpdate = false;
} else if (cursor.line >= currentSectionInfo.endLine) {
isProgrammaticUpdate = true;
const lastEditableLine = currentSectionInfo.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.
component.registerEvent(
this.plugin.app.workspace.on('editor-change', (changedEditor) => {
if (changedEditor === editor) {
constrainContent();
}
})
);
// 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;
} else if (firstVisibleLine > currentSectionInfo.endLine - 2) {
cmScroller.scrollTop = (currentSectionInfo.endLine - 2) * lineHeight;
}
};
cmScroller.addEventListener('scroll', preventScroll);
component.register(() => {
cmScroller.removeEventListener('scroll', preventScroll);
});
}
}
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);
}
findSectionBounds(content, sectionName) {
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;
// Find start of section
for (let i = 0; i < lines.length; i++) {
if (headerRegex.test(lines[i])) {
startLine = i;
headerLevel = (lines[i].match(/^#+/)?.[0] || '').length;
break;
}
}
if (startLine === -1) {
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(/^#+/);
if (match && match[0].length <= headerLevel) {
endLine = i;
break;
}
}
return { startLine, endLine, headerLevel };
}
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
cleanupViewport(embedData) {
if (embedData.viewportStyle) {
embedData.viewportStyle.remove();
}
embedData.viewportActive = false;
}
}
module.exports = ViewportController;