From d8e8566e15bf58ed9252c6e96971aa6fca32f5ff Mon Sep 17 00:00:00 2001 From: Verity <113909005+uthvah@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:33:11 +0000 Subject: [PATCH] Critical fixes - Add build tools - Fix properties offset - Fix delete/backsapce edge case errors - Unified header logic - Padding still needs some tweaking (themes? minimal, spacious etc) --- esbuild.config.mjs | 51 ++++++ package.json | 22 +++ src/embed-manager.js | 260 ++++++++--------------------- src/viewport-controller.js | 330 ++++++++++++++++++------------------- styles.css | 178 ++++++++------------ 5 files changed, 371 insertions(+), 470 deletions(-) create mode 100644 esbuild.config.mjs create mode 100644 package.json diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..a783e27 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,51 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = `/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = process.env.NODE_ENV === 'production'; + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["src/main.js"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", // Outputs to root for Obsidian + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development') + }, +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1a49241 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "obsidian-sync-embeds", + "version": "2.0.0", + "description": "Create fully editable, live-synced note embeds", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "NODE_ENV=production node esbuild.config.mjs" + }, + "keywords": ["obsidian", "plugin", "embeds"], + "author": "uthvah", + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "builtin-modules": "^3.3.0", + "esbuild": "0.17.3", + "obsidian": "latest" + }, + "dependencies": { + "monkey-around": "^3.0.0" + } +} \ No newline at end of file diff --git a/src/embed-manager.js b/src/embed-manager.js index d1de53f..a1b00cd 100644 --- a/src/embed-manager.js +++ b/src/embed-manager.js @@ -5,31 +5,19 @@ const DynamicPaths = require('./dynamic-paths'); class EmbedManager { constructor(plugin) { this.plugin = plugin; - // Use WeakMap to prevent memory leaks this.embedRegistry = new WeakMap(); - // Keep a Set for tracking active embeds for cleanup this.activeEmbeds = new Set(); this.viewportController = new ViewportController(plugin); this.dynamicPaths = new DynamicPaths(plugin); } cleanup() { - // Clean up all active embeds this.activeEmbeds.forEach(embedData => { - if (embedData.component) { - embedData.component.unload(); - } - // Properly detach leaf and view - if (embedData.leaf) { - embedData.leaf.detach(); - } + if (embedData.component) embedData.component.unload(); + if (embedData.leaf) embedData.leaf.detach(); }); this.activeEmbeds.clear(); - - // Clean up dynamic paths cache - if (this.dynamicPaths) { - this.dynamicPaths.cleanup(); - } + if (this.dynamicPaths) this.dynamicPaths.cleanup(); } getEmbedFromElement(element) { @@ -49,7 +37,6 @@ class EmbedManager { el.empty(); const syncContainer = el.createDiv('sync-container'); - // Apply CSS custom properties syncContainer.style.setProperty('--sync-embed-height', this.plugin.settings.embedHeight); syncContainer.style.setProperty('--sync-max-height', this.plugin.settings.maxEmbedHeight); syncContainer.style.setProperty('--sync-gap', this.plugin.settings.gapBetweenEmbeds); @@ -63,89 +50,59 @@ class EmbedManager { return; } - // Calculate total height for lazy loading to prevent scrollbar jumps - const estimatedHeight = embedLines.length * 200; // Rough estimate + const estimatedHeight = embedLines.length * 200; syncContainer.style.minHeight = `${estimatedHeight}px`; for (let i = 0; i < embedLines.length; i++) { await this.processEmbed(embedLines[i], syncContainer, ctx, i > 0); } - // Remove min-height after all loaded - setTimeout(() => { - syncContainer.style.minHeight = ''; - }, 100); + setTimeout(() => { syncContainer.style.minHeight = ''; }, 100); } parseEmbedOptions(line) { - // Parse options like: ![[note|alias{height:500px,title:false}]] const optionsMatch = line.match(/\{([^}]+)\}\]\]$/); const options = {}; - if (optionsMatch) { const optionsStr = optionsMatch[1]; const pairs = optionsStr.split(','); - pairs.forEach(pair => { const [key, value] = pair.split(':').map(s => s.trim()); if (key && value !== undefined) { - // Parse boolean values if (value === 'true') options[key] = true; else if (value === 'false') options[key] = false; else options[key] = value; } }); - - // Remove options from line for further parsing line = line.replace(/\{[^}]+\}\]\]$/, ']]'); } - return { line, options }; } async processEmbed(embedLine, container, ctx, addGap) { try { - // Parse custom options first const { line: cleanedLine, options } = this.parseEmbedOptions(embedLine); - - // Parse embed syntax: ![[path#section|alias]] const match = cleanedLine.match(/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/); if (!match) return; let linkText = match[1]; let displayAlias = match[2]?.trim(); - - // Check if this has dynamic patterns const hasDynamicPattern = /\{\{(date|time|title)/.test(linkText); if (hasDynamicPattern) { - // Check cache first (with 1 second TTL for date patterns) const cacheKey = `${linkText}-${ctx.sourcePath}`; const cached = this.dynamicPaths.pathCache.get(cacheKey); const now = Date.now(); - let resolvedText; + if (cached && (now - cached.timestamp < 1000)) { resolvedText = cached.value; - if (this.plugin.settings.debugMode) { - console.log('[Sync Embeds] Using cached resolution:', linkText, '→', resolvedText); - } } else { - // Resolve dynamic patterns resolvedText = this.dynamicPaths.resolve(linkText, ctx); this.dynamicPaths.pathCache.set(cacheKey, { value: resolvedText, timestamp: now }); - - if (this.plugin.settings.debugMode) { - console.log('[Sync Embeds] Fresh resolution:', linkText, '→', resolvedText); - } } - // If no alias provided, use the original pattern as display name - if (!displayAlias) { - displayAlias = linkText; - } - - // CRITICAL: Use resolved text as the actual file path + if (!displayAlias) displayAlias = linkText; linkText = resolvedText; } @@ -161,39 +118,30 @@ class EmbedManager { return; } - // Only check for direct recursion if (file.path === ctx.sourcePath) { this.renderError(container, "Cannot create a recursive embed of the same note.", addGap); return; } - // Create embed container const embedContainer = container.createDiv('sync-embed'); if (addGap) embedContainer.addClass('sync-embed-gap'); embedContainer.addClass('sync-embed-loading'); - // Store custom options on container if (Object.keys(options).length > 0) { embedContainer.dataset.customOptions = JSON.stringify(options); } - // Create placeholder for lazy loading const placeholderText = displayAlias || `${file.basename}${section ? '#' + section : ''}`; const placeholder = embedContainer.createDiv('sync-embed-placeholder'); placeholder.setText(`Loading ${placeholderText}...`); const renderAsCallout = options.callout !== undefined ? options.callout : this.plugin.settings.renderAsCallout; + if (renderAsCallout) embedContainer.addClass('is-callout-style'); - if (renderAsCallout) { - embedContainer.addClass('is-callout-style'); - } - - // Aggressive lazy loading to prevent scrollbar jumps const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { observer.disconnect(); - // Small delay to ensure smooth scrolling requestAnimationFrame(() => { this.loadEmbed(embedContainer, file, section, displayAlias, ctx, placeholder, options); }); @@ -201,7 +149,7 @@ class EmbedManager { }); }, { rootMargin: this.plugin.settings.lazyLoadThreshold, - threshold: 0.01 // Start loading as soon as 1% is visible + threshold: 0.01 }); observer.observe(embedContainer); @@ -218,7 +166,6 @@ class EmbedManager { const leaf = new WorkspaceLeaf(this.plugin.app); component.load(); - // Store leaf for proper cleanup const embedData = { containerEl: embedContainer, file, @@ -230,7 +177,6 @@ class EmbedManager { sourcePath: ctx.sourcePath }; - // Register cleanup component.addChild(new (class extends Component { constructor(manager, data) { super(); @@ -238,26 +184,15 @@ class EmbedManager { this.embedData = data; } async onunload() { - // Remove from active embeds this.manager.activeEmbeds.delete(this.embedData); - - // Clear focus if this was focused if (this.manager.plugin.currentFocusedEmbed?.containerEl === this.embedData.containerEl) { this.manager.plugin.currentFocusedEmbed = null; } - - // Properly detach the leaf and clean up the view - if (this.embedData.leaf) { - this.embedData.leaf.detach(); - } + if (this.embedData.leaf) this.embedData.leaf.detach(); } })(this, embedData)); - // Open the full file in source mode - await leaf.openFile(file, { - state: { mode: 'source' } - }); - + await leaf.openFile(file, { state: { mode: 'source' } }); const view = leaf.view; if (!(view instanceof MarkdownView)) { @@ -266,85 +201,53 @@ class EmbedManager { return; } - // Update embedData with view and editor embedData.view = view; embedData.editor = view.editor; this.embedRegistry.set(embedContainer, embedData); this.activeEmbeds.add(embedData); - // Apply custom height if specified - if (customOptions.height) { - embedContainer.style.setProperty('--sync-embed-height', customOptions.height); - } - - if (customOptions.maxHeight) { - embedContainer.style.setProperty('--sync-max-height', customOptions.maxHeight); - } - - // Handle collapse option - if (customOptions.collapse === true) { - embedContainer.addClass('is-collapsed'); - } + if (customOptions.height) embedContainer.style.setProperty('--sync-embed-height', customOptions.height); + if (customOptions.maxHeight) embedContainer.style.setProperty('--sync-max-height', customOptions.maxHeight); + if (customOptions.collapse === true) embedContainer.addClass('is-collapsed'); const renderAsCallout = customOptions.callout !== undefined ? customOptions.callout : this.plugin.settings.renderAsCallout; - - // Construct the display title: prefer alias, then "Note > Section", then just Note const headerTitle = alias || (section ? `${file.basename} > ${section}` : file.basename); - // For section embeds, validate section exists then set up if (section) { const content = view.editor.getValue(); const sectionInfo = this.viewportController.findSectionBounds(content, section); if (sectionInfo.startLine === -1) { - // Cleanup embedContainer.empty(); embedContainer.removeClass('sync-embed-loading'); embedContainer.style.height = 'auto'; embedContainer.style.minHeight = '0'; - - // Render the error this.renderError(embedContainer, `Section not found: ${section}`, false); - leaf.detach(); return; } await this.viewportController.setupSectionViewport(embedData); - - // If there's an alias, show it instead of the actual header - if (alias || renderAsCallout) { - this.setupAliasDisplay(embedData, headerTitle, true); - } - } else if (alias || renderAsCallout) { - // For whole note embeds with alias or callout style, show title - this.setupAliasDisplay(embedData, headerTitle, false); } - // Handle properties collapse - if (this.plugin.settings.collapsePropertiesByDefault) { - this.setupPropertiesCollapse(embedData); - } - - // Handle inline title visibility - const showTitle = customOptions.title !== undefined - ? customOptions.title - : this.plugin.settings.showInlineTitle; - - // Hide title if disabled, if it's a section, OR if we're rendering as a callout (since callout has its own title) - if (!showTitle || section || renderAsCallout) { - this.hideInlineTitle(embedData); - } - - // Remove placeholder and show actual content - // placeholder.remove(); - // embedContainer.appendChild(view.containerEl); - // Using replaceWith instead of remove + appendChild - placeholder.replaceWith(view.containerEl); + // UNIFIED HEADER/TITLE LOGIC + const userWantsTitle = customOptions.title !== undefined ? customOptions.title : this.plugin.settings.showInlineTitle; - embedContainer.removeClass('sync-embed-loading'); + // Callouts ALWAYS generate a header (for folding). Normal embeds respect settings. + if (renderAsCallout || userWantsTitle) { + this.setupHeaderUI(embedData, headerTitle, renderAsCallout, !!section); + } + // PROPERTIES LOGIC + if (section) { + this.hideProperties(embedData); // Sections shouldn't have properties + } else if (this.plugin.settings.collapsePropertiesByDefault) { + this.setupPropertiesCollapse(embedData); // Whole notes conditionally collapse natively + } + + placeholder.replaceWith(view.containerEl); + embedContainer.removeClass('sync-embed-loading'); ctx.addChild(component); } catch (error) { @@ -354,101 +257,78 @@ class EmbedManager { } } - setupAliasDisplay(embedData, displayAlias, isSection) { - const { view, file, section } = embedData; - const renderAsCallout = embedData.customOptions.callout !== undefined - ? embedData.customOptions.callout - : this.plugin.settings.renderAsCallout; + setupHeaderUI(embedData, displayTitle, renderAsCallout, isSection) { + const { view, file, section, containerEl } = embedData; requestAnimationFrame(() => { setTimeout(() => { - // Hide the inline title + // Completely kill the native title to prevent duplication const titleEl = view.containerEl.querySelector('.inline-title'); - if (titleEl) { - titleEl.style.display = 'none'; - } + if (titleEl) titleEl.style.display = 'none'; - // For section embeds, also hide the section header - if (isSection && embedData.sectionInfo) { - const cmContent = view.containerEl.querySelector('.cm-content'); - if (cmContent) { - const headerLineNumber = embedData.sectionInfo.startLine + 1; - const firstLine = cmContent.querySelector(`.cm-line:nth-child(${headerLineNumber})`); - if (firstLine) { - firstLine.style.display = 'none'; - } - } - } + const viewContent = view.containerEl.querySelector('.view-content'); + if (!viewContent) return; + if (view.containerEl.querySelector('.sync-embed-header')) return; // Prevent dupes + + const headerUI = document.createElement('div'); + headerUI.className = 'sync-embed-header'; - // Create and insert alias header with consistent styling - const aliasHeader = view.containerEl.createDiv('sync-embed-alias-header'); - if (renderAsCallout) { - aliasHeader.addClass('is-sticky'); + headerUI.classList.add('is-sticky'); - // Add fold button - const foldBtn = aliasHeader.createDiv('sync-embed-fold'); + const foldBtn = headerUI.createDiv('sync-embed-fold'); setIcon(foldBtn, 'chevron-down'); - // Create a clickable link const linkPath = section ? `${file.path}#${section}` : file.path; - aliasHeader.createEl('a', { + headerUI.createEl('a', { cls: 'internal-link', - text: displayAlias, - attr: { - 'href': linkPath, - 'data-href': linkPath - } + text: displayTitle, + attr: { 'href': linkPath, 'data-href': linkPath } }); - // Handle toggle (only if clicking the header background, not the link) - aliasHeader.addEventListener('click', (e) => { + headerUI.addEventListener('click', (e) => { if (e.target.closest('a')) return; e.stopPropagation(); e.preventDefault(); - embedData.containerEl.classList.toggle('is-collapsed'); + containerEl.classList.toggle('is-collapsed'); }); + + // Callout headers sit ABOVE the content to stick perfectly + view.containerEl.insertBefore(headerUI, viewContent); } else { - aliasHeader.textContent = displayAlias; - } - - const viewContent = view.containerEl.querySelector('.view-content'); - if (viewContent) { - viewContent.insertBefore(aliasHeader, viewContent.firstChild); + // Standard inline alias header sits inside the content + headerUI.textContent = displayTitle; + viewContent.insertBefore(headerUI, viewContent.firstChild); } }, 100); }); } + hideProperties(embedData) { + const { view } = embedData; + requestAnimationFrame(() => { + setTimeout(() => { + const propertiesEl = view.containerEl.querySelector('.metadata-container'); + if (propertiesEl) { + propertiesEl.style.display = 'none'; + } + }, 50); + }); + } + setupPropertiesCollapse(embedData) { const { view } = embedData; - requestAnimationFrame(() => { setTimeout(() => { const propertiesEl = view.containerEl.querySelector('.metadata-container'); if (!propertiesEl) return; - propertiesEl.classList.add('is-collapsed'); - - const toggleBtn = propertiesEl.createDiv('properties-collapse-toggle'); - toggleBtn.innerHTML = 'â–¶'; - toggleBtn.onclick = () => { - propertiesEl.classList.toggle('is-collapsed'); - toggleBtn.innerHTML = propertiesEl.classList.contains('is-collapsed') ? 'â–¶' : 'â–¼'; - }; - }, 100); - }); - } - - hideInlineTitle(embedData) { - const { view } = embedData; - requestAnimationFrame(() => { - setTimeout(() => { - const titleEl = view.containerEl.querySelector('.inline-title'); - if (titleEl) { - titleEl.style.display = 'none'; + // Fire a real click event on the heading so Obsidian natively updates its internal state + const heading = propertiesEl.querySelector('.metadata-properties-heading'); + if (heading && !propertiesEl.classList.contains('is-collapsed')) { + heading.click(); } - }, 50); + }, 100); }); } @@ -459,4 +339,4 @@ class EmbedManager { } } -module.exports = EmbedManager; +module.exports = EmbedManager; \ No newline at end of file diff --git a/src/viewport-controller.js b/src/viewport-controller.js index 9e7ea86..ccc1d0b 100644 --- a/src/viewport-controller.js +++ b/src/viewport-controller.js @@ -8,168 +8,160 @@ class ViewportController { async setupSectionViewport(embedData) { const { view, editor, file, section } = embedData; - // Wait for editor to be ready await new Promise(resolve => setTimeout(resolve, 100)); const content = editor.getValue(); const sectionInfo = this.findSectionBounds(content, section); - // Missing section should be handled by the caller. Log to console for debugging if we reach this point. if (sectionInfo.startLine === -1) { console.warn('Sync Embeds: Section not found for viewport embedding:', section); return; } - // Store section metadata embedData.sectionInfo = sectionInfo; embedData.viewportActive = true; - // Apply the viewport restriction this.applyViewportRestriction(embedData); - - // Setup header protection (simplified, no auto-correction) - this.setupHeaderProtection(embedData); - - // NEW: Setup input interception to block # typing + this.setupBoundaryProtection(embedData); this.setupHeaderInputInterception(embedData); - - // Setup cursor and content constraints this.setupContentConstraints(embedData); - - // Scroll to section this.scrollToSection(embedData); } applyViewportRestriction(embedData) { - const { view, sectionInfo } = embedData; - const { startLine, endLine } = sectionInfo; - - // Use CSS to hide lines outside the viewport + const { view } = embedData; + const style = document.createElement('style'); style.className = 'sync-viewport-style'; - // Generate unique ID for this embed const embedId = 'embed-' + Math.random().toString(36).substr(2, 9); view.containerEl.setAttribute('data-embed-id', embedId); - - // Store embed ID for dynamic updates embedData.embedId = embedId; this.updateViewportCSS(embedData, style); - view.containerEl.appendChild(style); - - // Store for cleanup and updates embedData.viewportStyle = style; } updateViewportCSS(embedData, style) { - const { sectionInfo, embedId } = embedData; + const { sectionInfo, embedId, file } = embedData; const { startLine, endLine } = sectionInfo; - // PERFORMANCE: Use efficient CSS selectors + // Frontmatter DOM Offset compensation + // CodeMirror folds frontmatter, removing all but ONE line from the DOM. + let domOffset = 0; + const fileCache = this.plugin.app.metadataCache.getFileCache(file); + if (fileCache && fileCache.frontmatterPosition) { + // No +1 needed because the first line is retained by CM6 for the fold widget + domOffset = fileCache.frontmatterPosition.end.line; + } + + const domStartLine = Math.max(0, startLine - domOffset); + const domEndLine = Math.max(0, endLine - domOffset); + const css = ` - /* Hide all lines before the section */ - [data-embed-id="${embedId}"] .cm-line:nth-child(-n+${startLine}) { + /* Hide all lines BEFORE and INCLUDING the section header */ + [data-embed-id="${embedId}"] .cm-line:nth-child(-n+${domStartLine + 1}) { display: none !important; } - /* Hide all lines after the section */ - [data-embed-id="${embedId}"] .cm-line:nth-child(n+${endLine + 1}) { + /* Hide all lines AFTER the section */ + [data-embed-id="${embedId}"] .cm-line:nth-child(n+${domEndLine + 1}) { display: none !important; } - - /* Style the header line - No background, proper theming */ - [data-embed-id="${embedId}"] .cm-line:nth-child(${startLine + 1}) { - pointer-events: none !important; - user-select: none !important; - cursor: default !important; - } `; style.textContent = css; } - setupHeaderProtection(embedData) { - const { view, editor, sectionInfo, component } = embedData; - const { startLine } = sectionInfo; - - // Store original header to restore if modified - const originalHeader = editor.getLine(startLine); - embedData.originalHeader = originalHeader; - - let isProgrammaticUpdate = false; - - const changeHandler = (changedEditor) => { - if (changedEditor !== editor || isProgrammaticUpdate) return; - if (!embedData.viewportActive) return; - - const cursorPos = editor.getCursor(); - const absoluteLine = cursorPos.line; - - // Protect the header line from any edits - if (absoluteLine === startLine) { - const currentLine = editor.getLine(startLine); - if (currentLine !== originalHeader) { - isProgrammaticUpdate = true; - editor.replaceRange( - originalHeader, - { line: startLine, ch: 0 }, - { line: startLine, ch: currentLine.length } - ); - // Move cursor to line below - editor.setCursor({ line: startLine + 1, ch: 0 }); - isProgrammaticUpdate = false; - } - } - }; - - component.registerEvent( - this.plugin.app.workspace.on('editor-change', changeHandler) - ); - } - - setupHeaderInputInterception(embedData) { - // CHANGED: Removed `sectionInfo` from destructuring to avoid a stale closure. + setupBoundaryProtection(embedData) { const { view, editor, component } = embedData; - // The header level of the main section won't change, so it's safe to get it once. - const { headerLevel } = embedData.sectionInfo; - // Always enforce in section embeds - let lastNoticeTime = 0; - const noticeDebounce = 5000; // 5 seconds between notices - - // CRITICAL: Wait for the editor to be fully ready and focused - // Use a small delay to ensure the CodeMirror editor is properly initialized const setupHandlers = () => { - // Get the actual CodeMirror editor element const cmEditor = view.containerEl.querySelector('.cm-content'); if (!cmEditor) { - // Retry if not ready yet setTimeout(setupHandlers, 50); return; } - // Handle input event to catch # at line start (use 'input' not 'beforeinput' for better timing) + const keydownHandler = (event) => { + if (!embedData.viewportActive || !embedData.sectionInfo) return; + + const { startLine, endLine } = embedData.sectionInfo; + const cursor = editor.getCursor(); + const selection = editor.getSelection(); + + // PROTECT TOP BOUNDARY (Block Backspace from deleting the invisible boundary newline) + if (event.key === 'Backspace') { + if (selection) { + const from = editor.getCursor('from'); + if (from.line <= startLine) { + event.preventDefault(); + return; + } + } else { + if (cursor.line === startLine + 1 && cursor.ch === 0) { + event.preventDefault(); + return; + } + } + } + + // PROTECT BOTTOM BOUNDARY (Block Delete from deleting the invisible boundary newline) + if (event.key === 'Delete') { + if (selection) { + const to = editor.getCursor('to'); + if (to.line >= endLine) { + event.preventDefault(); + return; + } + } else { + const lastEditableLine = endLine - 1; + const lastLineLength = editor.getLine(lastEditableLine)?.length || 0; + if (cursor.line === lastEditableLine && cursor.ch === lastLineLength) { + event.preventDefault(); + return; + } + } + } + }; + + cmEditor.addEventListener('keydown', keydownHandler, true); + component.register(() => { + cmEditor.removeEventListener('keydown', keydownHandler, true); + }); + }; + + setTimeout(setupHandlers, 100); + } + + setupHeaderInputInterception(embedData) { + const { view, editor, component } = embedData; + const { headerLevel } = embedData.sectionInfo; + + let lastNoticeTime = 0; + const noticeDebounce = 5000; + + const setupHandlers = () => { + const cmEditor = view.containerEl.querySelector('.cm-content'); + if (!cmEditor) { + setTimeout(setupHandlers, 50); + return; + } + const inputHandler = (event) => { - // Only process insertText events if (event.inputType !== 'insertText' && event.inputType !== 'insertFromPaste') return; if (event.data !== '#') return; - // Check immediately if # was typed at line start const cursor = editor.getCursor(); const line = editor.getLine(cursor.line); - - // Check if the # is at the start of the line (accounting for whitespace) const beforeHash = line.substring(0, cursor.ch - 1); const isAtLineStart = /^\s*$/.test(beforeHash); - // CHANGED: Read directly from `embedData.sectionInfo` to get the latest, non-stale bounds. if (isAtLineStart && cursor.line > embedData.sectionInfo.startLine && cursor.line < embedData.sectionInfo.endLine) { - // Remove the # that was just typed const currentLine = editor.getLine(cursor.line); const newLine = currentLine.substring(0, cursor.ch - 1) + currentLine.substring(cursor.ch); editor.replaceRange( @@ -179,146 +171,122 @@ class ViewportController { ); editor.setCursor({ line: cursor.line, ch: cursor.ch - 1 }); - // Show notice (debounced) const now = Date.now(); if (this.plugin.settings.showHeaderHints && now - lastNoticeTime > noticeDebounce) { - lastNoticeTime = now; - const availableLevels = []; for (let i = headerLevel + 1; i <= 6; i++) { availableLevels.push(`H${i} (Alt+${i})`); } - - new Notice( - `⚠️ Cannot create H1-H${headerLevel} headers in this section.\n` + - `Use: ${availableLevels.join(', ')}`, - 5000 - ); + new Notice(`⚠️ Cannot create H1-H${headerLevel} headers in this section.\nUse: ${availableLevels.join(', ')}`, 5000); } } }; - // Also handle paste events const pasteHandler = (event) => { const clipboardData = event.clipboardData?.getData('text'); if (!clipboardData) return; const cursor = editor.getCursor(); - - // CHANGED: Read directly from `embedData.sectionInfo` to get the latest, non-stale bounds. if (cursor.line > embedData.sectionInfo.startLine && cursor.line < embedData.sectionInfo.endLine) { const lines = clipboardData.split('\n'); let hasInvalidHeaders = false; - // Check if pasted content has invalid headers const adjustedLines = lines.map(line => { const match = line.match(/^(#{1,6})\s+(.*)$/); if (!match) return line; const [, hashes, content] = match; - const level = hashes.length; - - if (level <= headerLevel) { + if (hashes.length <= headerLevel) { hasInvalidHeaders = true; - // Adjust to minimum allowed level - const newLevel = headerLevel + 1; - return '#'.repeat(newLevel) + ' ' + content; + return '#'.repeat(headerLevel + 1) + ' ' + content; } return line; }); if (hasInvalidHeaders) { event.preventDefault(); - if (this.plugin.settings.showHeaderHints) { - new Notice( - 'Pasted headers adjusted to maintain section hierarchy', - 4000 - ); + new Notice('Pasted headers adjusted to maintain section hierarchy', 4000); } - - // Insert adjusted content editor.replaceSelection(adjustedLines.join('\n')); } } }; - // Use 'input' event which fires AFTER the character is inserted cmEditor.addEventListener('input', inputHandler, true); cmEditor.addEventListener('paste', pasteHandler, true); - // Cleanup component.register(() => { cmEditor.removeEventListener('input', inputHandler, true); cmEditor.removeEventListener('paste', pasteHandler, true); }); }; - // Start setup with a small delay to ensure editor is ready setTimeout(setupHandlers, 100); } setupContentConstraints(embedData) { - const { view, editor, sectionInfo, component } = embedData; - + const { view, editor, component } = embedData; let isProgrammaticUpdate = false; - // CRITICAL FIX: Update viewport IMMEDIATELY before visible changes - const updateViewportImmediately = () => { - if (!embedData.viewportActive) return; + // Force cursor strictly inside bounds (preventing up/down arrow drifting) + const enforceCursorBounds = () => { + if (isProgrammaticUpdate || !embedData.viewportActive || !embedData.sectionInfo) return; - const currentContent = editor.getValue(); - const newSectionInfo = this.findSectionBounds(currentContent, embedData.section); - - if (newSectionInfo.startLine !== -1) { - embedData.sectionInfo = newSectionInfo; - - // Update CSS synchronously to prevent flashing - if (embedData.viewportStyle) { - this.updateViewportCSS(embedData, embedData.viewportStyle); - } - } - }; - - // Monitor for content changes with instant updates - const constrainContent = () => { - if (isProgrammaticUpdate || !embedData.viewportActive) return; - - // Update viewport bounds IMMEDIATELY - updateViewportImmediately(); - - // Constrain cursor to section bounds + const { startLine, endLine } = embedData.sectionInfo; const cursor = editor.getCursor(); - const currentSectionInfo = embedData.sectionInfo; - if (cursor.line < currentSectionInfo.startLine) { + if (cursor.line <= startLine) { isProgrammaticUpdate = true; - editor.setCursor({ line: currentSectionInfo.startLine + 1, ch: 0 }); + editor.setCursor({ line: startLine + 1, ch: 0 }); isProgrammaticUpdate = false; - } else if (cursor.line >= currentSectionInfo.endLine) { + } else if (cursor.line >= endLine) { isProgrammaticUpdate = true; - const lastEditableLine = currentSectionInfo.endLine - 1; + const lastEditableLine = endLine - 1; const lastLineLength = editor.getLine(lastEditableLine)?.length || 0; editor.setCursor({ line: lastEditableLine, ch: lastLineLength }); isProgrammaticUpdate = false; } }; - // CHANGED: Removed `requestAnimationFrame`. Updates must be synchronous to prevent a race condition - // where the `input` event for typing a hash fires before the section bounds are updated. + const setupDOMListeners = () => { + const cmContent = view.containerEl.querySelector('.cm-content'); + if (!cmContent) { + setTimeout(setupDOMListeners, 50); + return; + } + + cmContent.addEventListener('mouseup', enforceCursorBounds); + cmContent.addEventListener('focusin', enforceCursorBounds); + cmContent.addEventListener('keyup', (e) => { + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(e.key)) { + enforceCursorBounds(); + } + }); + + component.register(() => { + cmContent.removeEventListener('mouseup', enforceCursorBounds); + cmContent.removeEventListener('focusin', enforceCursorBounds); + cmContent.removeEventListener('keyup', enforceCursorBounds); + }); + }; + + setTimeout(setupDOMListeners, 100); + + // Retain text-change bounds checking component.registerEvent( this.plugin.app.workspace.on('editor-change', (changedEditor) => { if (changedEditor === editor) { - constrainContent(); + this.updateViewportImmediately(embedData); + enforceCursorBounds(); } }) ); - // Prevent scrolling outside section const cmScroller = view.containerEl.querySelector('.cm-scroller'); if (cmScroller) { const preventScroll = (e) => { @@ -327,13 +295,21 @@ class ViewportController { const scrollTop = cmScroller.scrollTop; const lineHeight = editor.defaultTextHeight || 20; const firstVisibleLine = Math.floor(scrollTop / lineHeight); - const currentSectionInfo = embedData.sectionInfo; + + // Frontmatter DOM Offset compensation for scrolling bounds + let domOffset = 0; + const fileCache = this.plugin.app.metadataCache.getFileCache(embedData.file); + if (fileCache && fileCache.frontmatterPosition) { + domOffset = fileCache.frontmatterPosition.end.line; + } + + const domStartLine = Math.max(0, embedData.sectionInfo.startLine - domOffset); + const domEndLine = Math.max(0, embedData.sectionInfo.endLine - domOffset); - // Constrain scrolling to section - if (firstVisibleLine < Math.max(0, currentSectionInfo.startLine - 2)) { - cmScroller.scrollTop = Math.max(0, currentSectionInfo.startLine - 2) * lineHeight; - } else if (firstVisibleLine > currentSectionInfo.endLine - 2) { - cmScroller.scrollTop = (currentSectionInfo.endLine - 2) * lineHeight; + if (firstVisibleLine < Math.max(0, domStartLine - 2)) { + cmScroller.scrollTop = Math.max(0, domStartLine - 2) * lineHeight; + } else if (firstVisibleLine > domEndLine - 2) { + cmScroller.scrollTop = (domEndLine - 2) * lineHeight; } }; @@ -344,15 +320,27 @@ class ViewportController { } } + updateViewportImmediately(embedData) { + if (!embedData.viewportActive) return; + + const currentContent = embedData.editor.getValue(); + const newSectionInfo = this.findSectionBounds(currentContent, embedData.section); + + if (newSectionInfo.startLine !== -1) { + embedData.sectionInfo = newSectionInfo; + + if (embedData.viewportStyle) { + this.updateViewportCSS(embedData, embedData.viewportStyle); + } + } + } + scrollToSection(embedData) { - const { view, editor, sectionInfo } = embedData; + const { editor, sectionInfo } = embedData; const { startLine } = sectionInfo; setTimeout(() => { - // Scroll to the start of the section - editor.scrollIntoView({ line: startLine, ch: 0 }, true); - - // Set cursor at line after header + editor.scrollIntoView({ line: startLine + 1, ch: 0 }, true); editor.setCursor({ line: startLine + 1, ch: 0 }); }, 150); } @@ -365,7 +353,6 @@ class ViewportController { let startLine = -1; let headerLevel = 0; - // Find start of section for (let i = 0; i < lines.length; i++) { if (headerRegex.test(lines[i])) { startLine = i; @@ -378,7 +365,6 @@ class ViewportController { return { startLine: -1, endLine: -1, headerLevel: 0 }; } - // Find end of section let endLine = lines.length; for (let i = startLine + 1; i < lines.length; i++) { const match = lines[i].match(/^#+/); @@ -403,4 +389,4 @@ class ViewportController { } } -module.exports = ViewportController; +module.exports = ViewportController; \ No newline at end of file diff --git a/styles.css b/styles.css index 6337147..ca460b5 100644 --- a/styles.css +++ b/styles.css @@ -12,7 +12,7 @@ /* === EMBED WRAPPER === */ .sync-embed { - border: none !important; + border: none !important; /* Normal embeds get no border */ margin: 0 !important; padding: 0 !important; background: transparent !important; @@ -22,50 +22,82 @@ overflow-y: auto; overflow-x: hidden; position: relative; - /* border: 1px solid var(--background-modifier-border) !important; */ } -/* === STICKY ALIAS HEADER (CALLOUT STYLE) === */ -.sync-embed .markdown-source-view, -.sync-embed .cm-editor, -.sync-embed .cm-scroller, -.sync-embed .view-content, -.sync-embed .workspace-leaf-content { - overflow: visible !important; +/* Callout view applies the border to the embed container */ +.sync-embed.is-callout-style { + border: 1px solid var(--background-modifier-border) !important; + border-radius: 4px; /* Optional rounding for callouts */ } -.sync-embed-alias-header.is-sticky { +/* Compensate top padding when in callout mode to match bottom padding */ +.sync-embed.is-callout-style .cm-content { + padding-top: 12px !important; +} + +/* === UNIFIED UI HEADER === */ +.sync-embed-header { + font-size: var(--inline-title-size, var(--h1-size, 2em)); + font-weight: var(--inline-title-weight, var(--h1-weight, 700)); + line-height: var(--inline-title-line-height, var(--h1-line-height, 1.3)); + color: var(--inline-title-color, var(--h1-color, var(--text-normal))); + font-family: var(--inline-title-font, var(--h1-font, var(--font-text))); + font-variant: var(--h1-variant, normal); + letter-spacing: var(--h1-letter-spacing, normal); + + /* Matches body content padding */ + padding-top: 14px !important; + padding-left: 16px !important; + padding-right: 16px !important; + margin-bottom: 0; + + user-select: none !important; + cursor: default !important; + background: transparent !important; + border: none !important; + box-shadow: none !important; + outline: none !important; +} + +.theme-dark .sync-embed-header, +.theme-light .sync-embed-header { + color: var(--inline-title-color, var(--h1-color, var(--text-normal))); +} + +/* === STICKY CALLOUT HEADER VARIANT === */ +.sync-embed-header.is-sticky { position: sticky; top: 0; z-index: 1000; background-color: var(--background-primary) !important; - padding: 8px 40px 8px 10px !important; - font-size: var(--font-text-size); + + /* ALIGNMENT: 16px left to match body, 40px right for fold icon */ + padding: 8px 40px 8px 16px !important; + + font-size: var(--inline-title-size); display: flex !important; align-items: center !important; gap: 8px !important; box-shadow: 0 1px 2px rgba(0,0,0,0.05); cursor: pointer; - user-select: none !important; - /* border-bottom: 1px solid var(--background-modifier-border) !important; */ + border-bottom: 1px solid var(--background-modifier-border) !important; } -.sync-embed-alias-header.is-sticky:hover { +.sync-embed-header.is-sticky:hover { background-color: var(--background-primary-alt) !important; } -.sync-embed-alias-header.is-sticky a.internal-link { +.sync-embed-header.is-sticky a.internal-link { text-decoration: none; color: var(--text-accent) !important; font-weight: 600 !important; - /* Truncation */ display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.sync-embed-alias-header.is-sticky a.internal-link:hover { +.sync-embed-header.is-sticky a.internal-link:hover { text-decoration: underline; color: var(--text-accent-hover) !important; } @@ -91,12 +123,13 @@ } /* Hide everything except header when collapsed */ -.sync-embed.is-collapsed .view-content > *:not(.sync-embed-alias-header) { +.sync-embed.is-collapsed .view-content, +.sync-embed.is-collapsed .markdown-source-view { display: none !important; } /* Remove bottom border of header when collapsed */ -.sync-embed.is-collapsed .sync-embed-alias-header.is-sticky { +.sync-embed.is-collapsed .sync-embed-header.is-sticky { border-bottom: none !important; } @@ -111,20 +144,16 @@ border-top: 1px solid var(--background-modifier-border-hover); } -/* === NATIVE OBSIDIAN PADDING SYSTEM (REBUILT) === */ - -/* 1. Hide the view header */ +/* === PADDING SYSTEM === */ .sync-embed .view-header { display: none !important; } -/* 2. The view-content: NO horizontal padding here (we'll handle it in cm-content) */ .sync-embed .view-content { background-color: transparent !important; padding: 0 !important; } -/* 3. All editor components - transparent, no padding */ .sync-embed .markdown-source-view, .sync-embed .cm-editor, .sync-embed .cm-scroller { @@ -133,104 +162,42 @@ margin: 0 !important; } -/* 4. Content area - FIXED PADDING SYSTEM */ +/* 16px padding creates a flush left edge */ .sync-embed .cm-content { background-color: transparent !important; - - /* Aesthetic bottom padding - matches native Obsidian feel */ - padding-bottom: 18px !important; - - /* Native Obsidian horizontal padding - fixed at 10px regardless of readable line width */ - padding-left: 10px !important; - padding-right: 10px !important; + padding-top: 12px !important; + padding-bottom: 12px !important; + padding-left: 16px !important; + padding-right: 16px !important; } -/* 5. Ensure full width */ .sync-embed .markdown-source-view.mod-cm6 .cm-editor { width: 100%; } -/* 6. CRITICAL: Disable readable line width padding inheritance */ .sync-embed .cm-sizer { - /* Prevent extra padding from readable line width setting */ padding-inline-start: 0 !important; padding-inline-end: 0 !important; } -/* === INLINE TITLE === */ +/* === HIDE NATIVE INLINE TITLE COMPLETELY === */ .sync-embed .inline-title { - pointer-events: none !important; - user-select: none !important; - cursor: default !important; - opacity: 1 !important; - color: var(--inline-title-color, var(--h1-color, var(--text-normal))) !important; - background: transparent !important; - border: none !important; - box-shadow: none !important; - outline: none !important; - padding-top: 16px !important; - padding-left: 10px !important; - padding-right: 10px !important; -} - -/* === ALIAS HEADER === */ -.sync-embed-alias-header { - font-size: var(--inline-title-size, var(--h1-size, 2em)); - font-weight: var(--inline-title-weight, var(--h1-weight, 700)); - line-height: var(--inline-title-line-height, var(--h1-line-height, 1.3)); - color: var(--inline-title-color, var(--h1-color, var(--text-normal))); - font-family: var(--inline-title-font, var(--h1-font, var(--font-text))); - font-variant: var(--h1-variant, normal); - letter-spacing: var(--h1-letter-spacing, normal); - - /* Natural spacing like inline-title */ - padding-top: 16px !important; - padding-left: 10px !important; - padding-right: 10px !important; - margin-bottom: 0; - - user-select: none !important; - cursor: default !important; - background: transparent !important; - border: none !important; - box-shadow: none !important; - outline: none !important; -} - -.sync-embed-alias-header a.internal-link { - color: inherit !important; - text-decoration: none; -} - -/* Theme inheritance */ -.theme-dark .sync-embed-alias-header, -.theme-light .sync-embed-alias-header { - color: var(--inline-title-color, var(--h1-color, var(--text-normal))); + display: none !important; } /* === PROPERTIES/FRONTMATTER === */ +/* Scoped properly to prevent affecting parent note */ +.sync-embed .metadata-container { + /* ALIGNMENT: Matches header and body padding */ + padding-left: 16px !important; + padding-right: 16px !important; + margin-block-end: 0; +} + .metadata-container.is-collapsed .metadata-content { display: none; } -.properties-collapse-toggle { - cursor: pointer; - user-select: none; - padding: 4px 8px; - margin: 4px 0; - border-radius: 4px; - display: inline-block; - font-size: 0.9em; - color: var(--text-muted); - transition: all 0.2s ease; - font-family: var(--font-monospace); -} - -.properties-collapse-toggle:hover { - background-color: var(--background-modifier-hover); - color: var(--text-normal); -} - /* === SECTION EMBEDS === */ .sync-embed .cm-header-1, .sync-embed .HyperMD-header-1 { @@ -419,7 +386,7 @@ body.sync-embeds-no-focus-highlight .sync-embed:focus-within { padding-top: 12px; } - .sync-embed-alias-header { + .sync-embed-header { font-size: calc(var(--inline-title-size, var(--h1-size, 2em)) * 0.85); } } @@ -461,7 +428,6 @@ body.sync-embeds-no-focus-highlight .sync-embed:focus-within { } .sync-embed, - .properties-collapse-toggle, .sync-embed::-webkit-scrollbar-thumb { transition: none; } @@ -483,10 +449,6 @@ body.sync-embeds-no-focus-highlight .sync-embed:focus-within { overflow: visible !important; } - .properties-collapse-toggle { - display: none; - } - .metadata-container.is-collapsed .metadata-content { display: block; }