diff --git a/command-interceptor.js b/command-interceptor.js new file mode 100644 index 0000000..ea83a3c --- /dev/null +++ b/command-interceptor.js @@ -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; \ No newline at end of file diff --git a/dynamic-paths.js b/dynamic-paths.js new file mode 100644 index 0000000..b778f50 --- /dev/null +++ b/dynamic-paths.js @@ -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; \ No newline at end of file diff --git a/embed-manager.js b/embed-manager.js new file mode 100644 index 0000000..600510c --- /dev/null +++ b/embed-manager.js @@ -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; \ No newline at end of file diff --git a/main.js b/main.js index facae2a..e2be48f 100644 --- a/main.js +++ b/main.js @@ -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'); } }; \ No newline at end of file diff --git a/manifest.json b/manifest.json index e9973ac..97cf64f 100644 --- a/manifest.json +++ b/manifest.json @@ -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 diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..89eca3b --- /dev/null +++ b/settings.js @@ -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 = ` +

Basic Usage:

+ + +

Per-Embed Custom Options:

+ +

Note: Options go inside curly braces before the closing ]]

+ +

Dynamic Patterns:

+ + +

Header Management:

+ + +

Date Format Examples:

+ + +

Complete Example:

+
\`\`\`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}]]
+\`\`\`
+ +

Tips:

+ + +

Note: Dynamic patterns are cached for 1 second to improve performance.

+ `; + + // 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; \ No newline at end of file diff --git a/styles.css b/styles.css index 850392f..22f6c9f 100644 --- a/styles.css +++ b/styles.css @@ -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; + } } \ No newline at end of file diff --git a/viewport-controller.js b/viewport-controller.js new file mode 100644 index 0000000..87bb02d --- /dev/null +++ b/viewport-controller.js @@ -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; \ No newline at end of file