mirror of
https://github.com/uthvah/sync-embeds.git
synced 2026-07-22 06:43:40 +00:00
V2!
This commit is contained in:
parent
2b2347fbf7
commit
c73df3ac32
8 changed files with 2402 additions and 494 deletions
478
command-interceptor.js
Normal file
478
command-interceptor.js
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
const { Notice } = require('obsidian');
|
||||
|
||||
class CommandInterceptor {
|
||||
constructor(plugin) {
|
||||
this.plugin = plugin;
|
||||
this.handlers = new Map();
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
setupHandlers() {
|
||||
const mappings = {
|
||||
'editor:toggle-checklist-status': this.toggleChecklistCommand,
|
||||
'editor:toggle-bold': this.toggleBoldCommand,
|
||||
'editor:toggle-italics': this.toggleItalicCommand,
|
||||
'editor:toggle-strikethrough': this.toggleStrikethroughCommand,
|
||||
'editor:toggle-code': this.toggleCodeCommand,
|
||||
'editor:insert-link': this.insertLinkCommand,
|
||||
'editor:toggle-bullet-list': this.toggleBulletListCommand,
|
||||
'editor:toggle-numbered-list': this.toggleNumberedListCommand,
|
||||
'editor:indent-list': this.indentListCommand,
|
||||
'editor:unindent-list': this.unindentListCommand,
|
||||
'editor:insert-tag': this.insertTagCommand,
|
||||
'editor:swap-line-up': this.swapLineUpCommand,
|
||||
'editor:swap-line-down': this.swapLineDownCommand,
|
||||
'editor:duplicate-line': this.duplicateLineCommand,
|
||||
'editor:delete-line': this.deleteLineCommand,
|
||||
'editor:toggle-highlight': this.toggleHighlightCommand,
|
||||
'editor:insert-callout': this.insertCalloutCommand,
|
||||
// NEW: Header commands (H2-H6 only, no H1)
|
||||
'sync-embeds:insert-header-2': () => this.insertHeaderCommand(2),
|
||||
'sync-embeds:insert-header-3': () => this.insertHeaderCommand(3),
|
||||
'sync-embeds:insert-header-4': () => this.insertHeaderCommand(4),
|
||||
'sync-embeds:insert-header-5': () => this.insertHeaderCommand(5),
|
||||
'sync-embeds:insert-header-6': () => this.insertHeaderCommand(6),
|
||||
};
|
||||
|
||||
for (const [commandId, handler] of Object.entries(mappings)) {
|
||||
this.handlers.set(commandId, handler.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
hasHandler(commandId) {
|
||||
return this.handlers.has(commandId);
|
||||
}
|
||||
|
||||
handle(commandId, embedData, ...args) {
|
||||
const handler = this.handlers.get(commandId);
|
||||
if (handler) {
|
||||
try {
|
||||
return handler(embedData, ...args);
|
||||
} catch (error) {
|
||||
console.error(`Sync Embeds: Error handling command ${commandId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// === CHECKLIST ===
|
||||
toggleChecklistCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
|
||||
if (line.match(/^\s*- \[ \]/)) {
|
||||
// Toggle unchecked to checked
|
||||
const newLine = line.replace(/- \[ \]/, '- [x]');
|
||||
editor.replaceRange(
|
||||
newLine,
|
||||
{ line: cursor.line, ch: 0 },
|
||||
{ line: cursor.line, ch: line.length }
|
||||
);
|
||||
// Move cursor to end of line (native Obsidian behavior)
|
||||
editor.setCursor({ line: cursor.line, ch: newLine.length });
|
||||
} else if (line.match(/^\s*- \[x\]/i)) {
|
||||
// Toggle checked to unchecked
|
||||
const newLine = line.replace(/- \[x\]/i, '- [ ]');
|
||||
editor.replaceRange(
|
||||
newLine,
|
||||
{ line: cursor.line, ch: 0 },
|
||||
{ line: cursor.line, ch: line.length }
|
||||
);
|
||||
// Move cursor to end of line
|
||||
editor.setCursor({ line: cursor.line, ch: newLine.length });
|
||||
} else {
|
||||
// Convert to checklist
|
||||
const indent = line.match(/^\s*/)[0];
|
||||
const content = line.substring(indent.length);
|
||||
const newLine = `${indent}- [ ] ${content}`;
|
||||
editor.replaceRange(
|
||||
newLine,
|
||||
{ line: cursor.line, ch: 0 },
|
||||
{ line: cursor.line, ch: line.length }
|
||||
);
|
||||
// Move cursor to end of line (native Obsidian behavior)
|
||||
editor.setCursor({ line: cursor.line, ch: newLine.length });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// === FORMATTING ===
|
||||
toggleMarkdownFormatting(embedData, markdownChar) {
|
||||
const { editor } = embedData;
|
||||
const selection = editor.getSelection();
|
||||
const len = markdownChar.length;
|
||||
|
||||
if (selection && selection.startsWith(markdownChar) && selection.endsWith(markdownChar)) {
|
||||
// Remove formatting
|
||||
const newText = selection.slice(len, -len);
|
||||
editor.replaceSelection(newText);
|
||||
|
||||
// Keep selection on the content
|
||||
const cursor = editor.getCursor('from');
|
||||
editor.setSelection(
|
||||
cursor,
|
||||
{ line: cursor.line, ch: cursor.ch + newText.length }
|
||||
);
|
||||
} else if (selection) {
|
||||
// Add formatting around selection
|
||||
editor.replaceSelection(`${markdownChar}${selection}${markdownChar}`);
|
||||
|
||||
// Keep the wrapped text selected
|
||||
const cursor = editor.getCursor('from');
|
||||
editor.setSelection(
|
||||
{ line: cursor.line, ch: cursor.ch + len },
|
||||
{ line: cursor.line, ch: cursor.ch + len + selection.length }
|
||||
);
|
||||
} else {
|
||||
// No selection: insert markers and place cursor between them
|
||||
const cursor = editor.getCursor();
|
||||
editor.replaceRange(markdownChar + markdownChar, cursor);
|
||||
editor.setCursor({ line: cursor.line, ch: cursor.ch + len });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
toggleBoldCommand(embedData) {
|
||||
return this.toggleMarkdownFormatting(embedData, '**');
|
||||
}
|
||||
|
||||
toggleItalicCommand(embedData) {
|
||||
return this.toggleMarkdownFormatting(embedData, '*');
|
||||
}
|
||||
|
||||
toggleStrikethroughCommand(embedData) {
|
||||
return this.toggleMarkdownFormatting(embedData, '~~');
|
||||
}
|
||||
|
||||
toggleCodeCommand(embedData) {
|
||||
return this.toggleMarkdownFormatting(embedData, '`');
|
||||
}
|
||||
|
||||
toggleHighlightCommand(embedData) {
|
||||
return this.toggleMarkdownFormatting(embedData, '==');
|
||||
}
|
||||
|
||||
// === LINKS ===
|
||||
insertLinkCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const selection = editor.getSelection();
|
||||
|
||||
if (selection) {
|
||||
editor.replaceSelection(`[[${selection}]]`);
|
||||
} else {
|
||||
const cursor = editor.getCursor();
|
||||
editor.replaceRange('[[]]', cursor);
|
||||
editor.setCursor({ line: cursor.line, ch: cursor.ch + 2 });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// === LISTS ===
|
||||
toggleBulletListCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
|
||||
if (line.match(/^\s*- /)) {
|
||||
// Remove bullet
|
||||
const newLine = line.replace(/^\s*- /, '');
|
||||
editor.replaceRange(
|
||||
newLine,
|
||||
{ line: cursor.line, ch: 0 },
|
||||
{ line: cursor.line, ch: line.length }
|
||||
);
|
||||
} else {
|
||||
// Add bullet
|
||||
const indent = line.match(/^\s*/)[0];
|
||||
const content = line.substring(indent.length);
|
||||
editor.replaceRange(
|
||||
`${indent}- ${content}`,
|
||||
{ line: cursor.line, ch: 0 },
|
||||
{ line: cursor.line, ch: line.length }
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
toggleNumberedListCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
|
||||
if (line.match(/^\s*\d+\. /)) {
|
||||
// Remove numbering
|
||||
const newLine = line.replace(/^\s*\d+\. /, '');
|
||||
editor.replaceRange(
|
||||
newLine,
|
||||
{ line: cursor.line, ch: 0 },
|
||||
{ line: cursor.line, ch: line.length }
|
||||
);
|
||||
} else {
|
||||
// Add numbering
|
||||
const indent = line.match(/^\s*/)[0];
|
||||
const content = line.substring(indent.length);
|
||||
editor.replaceRange(
|
||||
`${indent}1. ${content}`,
|
||||
{ line: cursor.line, ch: 0 },
|
||||
{ line: cursor.line, ch: line.length }
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
indentListCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const from = editor.getCursor('from').line;
|
||||
const to = editor.getCursor('to').line;
|
||||
|
||||
// Handle multiple lines if selected
|
||||
for (let line = from; line <= to; line++) {
|
||||
editor.replaceRange('\t', { line, ch: 0 });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
unindentListCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const from = editor.getCursor('from').line;
|
||||
const to = editor.getCursor('to').line;
|
||||
|
||||
for (let line = from; line <= to; line++) {
|
||||
const lineText = editor.getLine(line);
|
||||
if (lineText.startsWith('\t')) {
|
||||
editor.replaceRange('', { line, ch: 0 }, { line, ch: 1 });
|
||||
} else if (lineText.startsWith(' ')) {
|
||||
editor.replaceRange('', { line, ch: 0 }, { line, ch: 4 });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// === TAGS ===
|
||||
insertTagCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const cursor = editor.getCursor();
|
||||
const selection = editor.getSelection();
|
||||
|
||||
if (selection) {
|
||||
editor.replaceSelection(`#${selection}`);
|
||||
} else {
|
||||
editor.replaceRange('#', cursor);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// === CALLOUTS ===
|
||||
insertCalloutCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const cursor = editor.getCursor();
|
||||
const calloutText = '> [!note]\n> ';
|
||||
|
||||
editor.replaceRange(calloutText, cursor);
|
||||
editor.setCursor({ line: cursor.line + 1, ch: 2 });
|
||||
return true;
|
||||
}
|
||||
|
||||
// === LINE OPERATIONS ===
|
||||
swapLineUpCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const cursor = editor.getCursor();
|
||||
|
||||
if (cursor.line > 0) {
|
||||
const currentLine = editor.getLine(cursor.line);
|
||||
const prevLine = editor.getLine(cursor.line - 1);
|
||||
|
||||
// Use transaction for atomic operation
|
||||
editor.transaction({
|
||||
changes: [
|
||||
{
|
||||
from: { line: cursor.line - 1, ch: 0 },
|
||||
to: { line: cursor.line - 1, ch: prevLine.length },
|
||||
text: currentLine
|
||||
},
|
||||
{
|
||||
from: { line: cursor.line, ch: 0 },
|
||||
to: { line: cursor.line, ch: currentLine.length },
|
||||
text: prevLine
|
||||
}
|
||||
],
|
||||
selection: { from: { line: cursor.line - 1, ch: cursor.ch } }
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
swapLineDownCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const cursor = editor.getCursor();
|
||||
|
||||
if (cursor.line < editor.lastLine()) {
|
||||
const currentLine = editor.getLine(cursor.line);
|
||||
const nextLine = editor.getLine(cursor.line + 1);
|
||||
|
||||
// Use transaction for atomic operation
|
||||
editor.transaction({
|
||||
changes: [
|
||||
{
|
||||
from: { line: cursor.line, ch: 0 },
|
||||
to: { line: cursor.line, ch: currentLine.length },
|
||||
text: nextLine
|
||||
},
|
||||
{
|
||||
from: { line: cursor.line + 1, ch: 0 },
|
||||
to: { line: cursor.line + 1, ch: nextLine.length },
|
||||
text: currentLine
|
||||
}
|
||||
],
|
||||
selection: { from: { line: cursor.line + 1, ch: cursor.ch } }
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
duplicateLineCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
|
||||
editor.replaceRange(`\n${line}`, { line: cursor.line, ch: line.length });
|
||||
editor.setCursor({ line: cursor.line + 1, ch: cursor.ch });
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteLineCommand(embedData) {
|
||||
const { editor } = embedData;
|
||||
const { line } = editor.getCursor();
|
||||
|
||||
// Handle last line differently
|
||||
if (line === editor.lastLine()) {
|
||||
const prevLineLength = line > 0 ? editor.getLine(line - 1).length : 0;
|
||||
editor.replaceRange('', { line, ch: 0 }, { line, ch: editor.getLine(line).length });
|
||||
if (line > 0) {
|
||||
editor.replaceRange('', { line: line - 1, ch: prevLineLength }, { line, ch: 0 });
|
||||
}
|
||||
} else {
|
||||
editor.replaceRange('', { line, ch: 0 }, { line: line + 1, ch: 0 });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// === HEADER COMMANDS ===
|
||||
insertHeaderCommand(level) {
|
||||
return (embedData) => {
|
||||
const { editor, sectionInfo } = embedData;
|
||||
|
||||
// Check if this is a section embed - always enforce in section embeds
|
||||
if (sectionInfo) {
|
||||
const { headerLevel } = sectionInfo;
|
||||
|
||||
// Block same-level or higher
|
||||
if (level <= headerLevel) {
|
||||
if (this.plugin.settings.showHeaderHints) {
|
||||
const availableLevels = [];
|
||||
for (let i = headerLevel + 1; i <= 6; i++) {
|
||||
availableLevels.push(`H${i} (Alt+${i})`);
|
||||
}
|
||||
new Notice(
|
||||
`⚠️ H${level} is not allowed in H${headerLevel} section.\nAvailable: ${availableLevels.join(', ')}`,
|
||||
5000
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return this.insertHeader(embedData, level);
|
||||
};
|
||||
}
|
||||
|
||||
insertHeader(embedData, level) {
|
||||
const { editor } = embedData;
|
||||
const from = editor.getCursor('from');
|
||||
const to = editor.getCursor('to');
|
||||
|
||||
// Handle multi-line selection
|
||||
if (from.line !== to.line) {
|
||||
for (let line = from.line; line <= to.line; line++) {
|
||||
this.toggleLineHeader(editor, line, level);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Single line
|
||||
return this.toggleLineHeader(editor, from.line, level);
|
||||
}
|
||||
|
||||
toggleLineHeader(editor, lineNum, level) {
|
||||
const line = editor.getLine(lineNum);
|
||||
const cursor = editor.getCursor();
|
||||
|
||||
// Smart behavior: Toggle if already a header
|
||||
const headerMatch = line.match(/^(\s*)(#{1,6})\s+(.*)$/);
|
||||
|
||||
if (headerMatch) {
|
||||
const [, indent, hashes, content] = headerMatch;
|
||||
const currentLevel = hashes.length;
|
||||
|
||||
if (currentLevel === level) {
|
||||
// Remove header formatting
|
||||
const newLine = `${indent}${content}`;
|
||||
editor.replaceRange(
|
||||
newLine,
|
||||
{ line: lineNum, ch: 0 },
|
||||
{ line: lineNum, ch: line.length }
|
||||
);
|
||||
// Move cursor to start of content
|
||||
if (cursor.line === lineNum) {
|
||||
editor.setCursor({ line: lineNum, ch: indent.length });
|
||||
}
|
||||
} else {
|
||||
// Change to requested level
|
||||
const newHashes = '#'.repeat(level);
|
||||
const newLine = `${indent}${newHashes} ${content}`;
|
||||
editor.replaceRange(
|
||||
newLine,
|
||||
{ line: lineNum, ch: 0 },
|
||||
{ line: lineNum, ch: line.length }
|
||||
);
|
||||
// Keep cursor at same relative position if on this line
|
||||
if (cursor.line === lineNum) {
|
||||
const newCursorCh = Math.min(
|
||||
cursor.ch + (newHashes.length - hashes.length),
|
||||
newLine.length
|
||||
);
|
||||
editor.setCursor({ line: lineNum, ch: newCursorCh });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Convert line to header
|
||||
const indent = line.match(/^\s*/)[0];
|
||||
const content = line.substring(indent.length);
|
||||
const newHashes = '#'.repeat(level);
|
||||
const newLine = `${indent}${newHashes} ${content}`;
|
||||
|
||||
editor.replaceRange(
|
||||
newLine,
|
||||
{ line: lineNum, ch: 0 },
|
||||
{ line: lineNum, ch: line.length }
|
||||
);
|
||||
|
||||
// Place cursor after the hashes and space if on this line
|
||||
if (cursor.line === lineNum) {
|
||||
editor.setCursor({
|
||||
line: lineNum,
|
||||
ch: indent.length + newHashes.length + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CommandInterceptor;
|
||||
192
dynamic-paths.js
Normal file
192
dynamic-paths.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
const { moment } = require('obsidian');
|
||||
|
||||
class DynamicPaths {
|
||||
constructor(plugin) {
|
||||
this.plugin = plugin;
|
||||
this.pathCache = new Map();
|
||||
|
||||
// Auto-cleanup cache every minute
|
||||
this.cacheCleanupInterval = setInterval(() => {
|
||||
this.cleanupCache();
|
||||
}, 60000); // 1 minute
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.cacheCleanupInterval) {
|
||||
clearInterval(this.cacheCleanupInterval);
|
||||
}
|
||||
this.pathCache.clear();
|
||||
}
|
||||
|
||||
cleanupCache() {
|
||||
const now = Date.now();
|
||||
const maxAge = 60000; // 1 minute
|
||||
|
||||
for (const [key, cached] of this.pathCache.entries()) {
|
||||
if (now - cached.timestamp > maxAge) {
|
||||
this.pathCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.plugin.settings.debugMode) {
|
||||
console.log('[Sync Embeds] Cache cleanup: removed stale entries, size now:', this.pathCache.size);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(linkPath, ctx) {
|
||||
let resolved = linkPath;
|
||||
|
||||
try {
|
||||
// Process in order: date offsets, then simple dates, then time, then title
|
||||
|
||||
// Replace date offset patterns FIRST: {{date-7d:YYYY-MM-DD}}
|
||||
resolved = this.resolveDateOffsetPatterns(resolved);
|
||||
|
||||
// Replace simple date patterns: {{date:YYYY-MM-DD}}
|
||||
resolved = this.resolveDatePatterns(resolved);
|
||||
|
||||
// Replace time patterns: {{time:HH:mm}}
|
||||
resolved = this.resolveTimePatterns(resolved);
|
||||
|
||||
// Replace title pattern: {{title}}
|
||||
resolved = this.resolveTitlePattern(resolved, ctx);
|
||||
|
||||
// Log for debugging
|
||||
if (this.plugin.settings.debugMode && resolved !== linkPath) {
|
||||
console.log('[Sync Embeds] Dynamic path resolved:', linkPath, '→', resolved);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync Embeds: Error resolving dynamic path:', error);
|
||||
// Return original path on error
|
||||
return linkPath;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
resolveDateOffsetPatterns(linkPath) {
|
||||
// Pattern: {{date[+-]NUMBER[dwmy]:FORMAT}}
|
||||
// Examples: {{date-7d:YYYY-MM-DD}}, {{date+1w:YYYY-MM-DD}}
|
||||
const offsetPattern = /\{\{date([+-]\d+)([dwmy]):([^}]+)\}\}/g;
|
||||
|
||||
return linkPath.replace(offsetPattern, (match, offset, unit, format) => {
|
||||
try {
|
||||
const amount = parseInt(offset, 10);
|
||||
const unitMap = {
|
||||
'd': 'days',
|
||||
'w': 'weeks',
|
||||
'm': 'months',
|
||||
'y': 'years'
|
||||
};
|
||||
|
||||
if (!unitMap[unit]) {
|
||||
console.error('Sync Embeds: Invalid date offset unit:', unit);
|
||||
return match;
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (!this.isValidMomentFormat(format)) {
|
||||
console.warn('Sync Embeds: Potentially invalid date format:', format);
|
||||
}
|
||||
|
||||
const result = moment().add(amount, unitMap[unit]).format(format);
|
||||
|
||||
if (this.plugin.settings.debugMode) {
|
||||
console.log('[Sync Embeds] Date offset resolved:', match, '→', result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Sync Embeds: Error in date offset pattern:', error);
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolveDatePatterns(linkPath) {
|
||||
// Pattern: {{date:FORMAT}}
|
||||
// Example: {{date:YYYY-MM-DD}}
|
||||
const datePattern = /\{\{date:([^}]+)\}\}/g;
|
||||
|
||||
return linkPath.replace(datePattern, (match, format) => {
|
||||
try {
|
||||
// Validate format
|
||||
if (!this.isValidMomentFormat(format)) {
|
||||
console.warn('Sync Embeds: Potentially invalid date format:', format);
|
||||
}
|
||||
|
||||
const result = moment().format(format);
|
||||
|
||||
if (this.plugin.settings.debugMode) {
|
||||
console.log('[Sync Embeds] Date resolved:', match, '→', result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Sync Embeds: Invalid date format:', format, error);
|
||||
return match; // Return original if format is invalid
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolveTimePatterns(linkPath) {
|
||||
// Pattern: {{time:FORMAT}}
|
||||
// Example: {{time:HH:mm}}
|
||||
const timePattern = /\{\{time:([^}]+)\}\}/g;
|
||||
|
||||
return linkPath.replace(timePattern, (match, format) => {
|
||||
try {
|
||||
// Validate format
|
||||
if (!this.isValidMomentFormat(format)) {
|
||||
console.warn('Sync Embeds: Potentially invalid time format:', format);
|
||||
}
|
||||
|
||||
const result = moment().format(format);
|
||||
|
||||
if (this.plugin.settings.debugMode) {
|
||||
console.log('[Sync Embeds] Time resolved:', match, '→', result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Sync Embeds: Invalid time format:', format, error);
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolveTitlePattern(linkPath, ctx) {
|
||||
// Pattern: {{title}}
|
||||
const titlePattern = /\{\{title\}\}/g;
|
||||
|
||||
if (!ctx.sourcePath) {
|
||||
return linkPath.replace(titlePattern, '');
|
||||
}
|
||||
|
||||
try {
|
||||
const currentFile = this.plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
|
||||
const title = currentFile?.basename || '';
|
||||
|
||||
if (this.plugin.settings.debugMode && linkPath.includes('{{title}}')) {
|
||||
console.log('[Sync Embeds] Title resolved:', '{{title}}', '→', title);
|
||||
}
|
||||
|
||||
return linkPath.replace(titlePattern, title);
|
||||
} catch (error) {
|
||||
console.error('Sync Embeds: Error resolving title pattern:', error);
|
||||
return linkPath.replace(titlePattern, '');
|
||||
}
|
||||
}
|
||||
|
||||
isValidMomentFormat(format) {
|
||||
// Basic validation for common moment.js format tokens
|
||||
// This is not exhaustive but catches obvious errors
|
||||
if (!format || format.trim() === '') return false;
|
||||
|
||||
// Check for common valid tokens
|
||||
const validTokens = /[YMDdHhmsSaAZzX]/;
|
||||
return validTokens.test(format);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DynamicPaths;
|
||||
395
embed-manager.js
Normal file
395
embed-manager.js
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
const { Component, WorkspaceLeaf, MarkdownView } = require('obsidian');
|
||||
const ViewportController = require('./viewport-controller');
|
||||
const DynamicPaths = require('./dynamic-paths');
|
||||
|
||||
class EmbedManager {
|
||||
constructor(plugin) {
|
||||
this.plugin = plugin;
|
||||
// Use WeakMap to prevent memory leaks
|
||||
this.embedRegistry = new WeakMap();
|
||||
// Keep a Set for tracking active embeds for cleanup
|
||||
this.activeEmbeds = new Set();
|
||||
this.viewportController = new ViewportController(plugin);
|
||||
this.dynamicPaths = new DynamicPaths(plugin);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// Clean up all active embeds
|
||||
this.activeEmbeds.forEach(embedData => {
|
||||
if (embedData.component) {
|
||||
embedData.component.unload();
|
||||
}
|
||||
// Properly detach leaf and view
|
||||
if (embedData.leaf) {
|
||||
embedData.leaf.detach();
|
||||
}
|
||||
});
|
||||
this.activeEmbeds.clear();
|
||||
|
||||
// Clean up dynamic paths cache
|
||||
if (this.dynamicPaths) {
|
||||
this.dynamicPaths.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
getEmbedFromElement(element) {
|
||||
if (!element) return null;
|
||||
let current = element;
|
||||
while (current && current !== document.body) {
|
||||
if (current.classList && current.classList.contains('sync-embed')) {
|
||||
const embedData = this.embedRegistry.get(current);
|
||||
if (embedData) return embedData;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async processSyncBlock(source, el, ctx) {
|
||||
el.empty();
|
||||
const syncContainer = el.createDiv('sync-container');
|
||||
|
||||
// Apply CSS custom properties
|
||||
syncContainer.style.setProperty('--sync-embed-height', this.plugin.settings.embedHeight);
|
||||
syncContainer.style.setProperty('--sync-max-height', this.plugin.settings.maxEmbedHeight);
|
||||
syncContainer.style.setProperty('--sync-gap', this.plugin.settings.gapBetweenEmbeds);
|
||||
|
||||
const embedLines = source.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.startsWith('![[') && line.endsWith(']]'));
|
||||
|
||||
if (embedLines.length === 0) {
|
||||
syncContainer.createDiv('sync-empty').setText('No embeds found in sync block');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate total height for lazy loading to prevent scrollbar jumps
|
||||
const estimatedHeight = embedLines.length * 200; // Rough estimate
|
||||
syncContainer.style.minHeight = `${estimatedHeight}px`;
|
||||
|
||||
for (let i = 0; i < embedLines.length; i++) {
|
||||
await this.processEmbed(embedLines[i], syncContainer, ctx, i > 0);
|
||||
}
|
||||
|
||||
// Remove min-height after all loaded
|
||||
setTimeout(() => {
|
||||
syncContainer.style.minHeight = '';
|
||||
}, 100);
|
||||
}
|
||||
|
||||
parseEmbedOptions(line) {
|
||||
// Parse options like: ![[note|alias{height:500px,title:false}]]
|
||||
const optionsMatch = line.match(/\{([^}]+)\}\]\]$/);
|
||||
const options = {};
|
||||
|
||||
if (optionsMatch) {
|
||||
const optionsStr = optionsMatch[1];
|
||||
const pairs = optionsStr.split(',');
|
||||
|
||||
pairs.forEach(pair => {
|
||||
const [key, value] = pair.split(':').map(s => s.trim());
|
||||
if (key && value !== undefined) {
|
||||
// Parse boolean values
|
||||
if (value === 'true') options[key] = true;
|
||||
else if (value === 'false') options[key] = false;
|
||||
else options[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Remove options from line for further parsing
|
||||
line = line.replace(/\{[^}]+\}\]\]$/, ']]');
|
||||
}
|
||||
|
||||
return { line, options };
|
||||
}
|
||||
|
||||
async processEmbed(embedLine, container, ctx, addGap) {
|
||||
try {
|
||||
// Parse custom options first
|
||||
const { line: cleanedLine, options } = this.parseEmbedOptions(embedLine);
|
||||
|
||||
// Parse embed syntax: ![[path#section|alias]]
|
||||
const match = cleanedLine.match(/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
|
||||
if (!match) return;
|
||||
|
||||
let linkText = match[1];
|
||||
let displayAlias = match[2]?.trim();
|
||||
|
||||
// Check if this has dynamic patterns
|
||||
const hasDynamicPattern = /\{\{(date|time|title)/.test(linkText);
|
||||
|
||||
if (hasDynamicPattern) {
|
||||
// Check cache first (with 1 second TTL for date patterns)
|
||||
const cacheKey = `${linkText}-${ctx.sourcePath}`;
|
||||
const cached = this.dynamicPaths.pathCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
|
||||
let resolvedText;
|
||||
if (cached && (now - cached.timestamp < 1000)) {
|
||||
resolvedText = cached.value;
|
||||
if (this.plugin.settings.debugMode) {
|
||||
console.log('[Sync Embeds] Using cached resolution:', linkText, '→', resolvedText);
|
||||
}
|
||||
} else {
|
||||
// Resolve dynamic patterns
|
||||
resolvedText = this.dynamicPaths.resolve(linkText, ctx);
|
||||
this.dynamicPaths.pathCache.set(cacheKey, { value: resolvedText, timestamp: now });
|
||||
|
||||
if (this.plugin.settings.debugMode) {
|
||||
console.log('[Sync Embeds] Fresh resolution:', linkText, '→', resolvedText);
|
||||
}
|
||||
}
|
||||
|
||||
// If no alias provided, use the original pattern as display name
|
||||
if (!displayAlias) {
|
||||
displayAlias = linkText;
|
||||
}
|
||||
|
||||
// CRITICAL: Use resolved text as the actual file path
|
||||
linkText = resolvedText;
|
||||
}
|
||||
|
||||
const linkPath = linkText.split('|')[0].trim();
|
||||
let notePath = linkPath.split('#')[0];
|
||||
const section = linkPath.includes('#') ? linkPath.substring(linkPath.indexOf('#') + 1) : null;
|
||||
|
||||
if (!notePath) notePath = ctx.sourcePath;
|
||||
|
||||
const file = this.plugin.app.metadataCache.getFirstLinkpathDest(notePath, ctx.sourcePath);
|
||||
if (!file) {
|
||||
this.renderError(container, `Note not found: ${notePath}`, addGap);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check for direct recursion
|
||||
if (file.path === ctx.sourcePath) {
|
||||
this.renderError(container, "Cannot create a recursive embed of the same note.", addGap);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create embed container
|
||||
const embedContainer = container.createDiv('sync-embed');
|
||||
if (addGap) embedContainer.addClass('sync-embed-gap');
|
||||
embedContainer.addClass('sync-embed-loading');
|
||||
|
||||
// Store custom options on container
|
||||
if (Object.keys(options).length > 0) {
|
||||
embedContainer.dataset.customOptions = JSON.stringify(options);
|
||||
}
|
||||
|
||||
// Create placeholder for lazy loading
|
||||
const placeholderText = displayAlias || `${file.basename}${section ? '#' + section : ''}`;
|
||||
const placeholder = embedContainer.createDiv('sync-embed-placeholder');
|
||||
placeholder.setText(`Loading ${placeholderText}...`);
|
||||
|
||||
// Aggressive lazy loading to prevent scrollbar jumps
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
observer.disconnect();
|
||||
// Small delay to ensure smooth scrolling
|
||||
requestAnimationFrame(() => {
|
||||
this.loadEmbed(embedContainer, file, section, displayAlias, ctx, placeholder, options);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: this.plugin.settings.lazyLoadThreshold,
|
||||
threshold: 0.01 // Start loading as soon as 1% is visible
|
||||
});
|
||||
|
||||
observer.observe(embedContainer);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Sync Embeds: Error processing embed:', error);
|
||||
this.renderError(container, `Error loading: ${error.message}`, addGap);
|
||||
}
|
||||
}
|
||||
|
||||
async loadEmbed(embedContainer, file, section, alias, ctx, placeholder, customOptions = {}) {
|
||||
try {
|
||||
const component = new Component();
|
||||
const leaf = new WorkspaceLeaf(this.plugin.app);
|
||||
component.load();
|
||||
|
||||
// Store leaf for proper cleanup
|
||||
const embedData = {
|
||||
containerEl: embedContainer,
|
||||
file,
|
||||
section,
|
||||
alias,
|
||||
component,
|
||||
leaf,
|
||||
customOptions
|
||||
};
|
||||
|
||||
// Register cleanup
|
||||
component.addChild(new (class extends Component {
|
||||
constructor(manager, data) {
|
||||
super();
|
||||
this.manager = manager;
|
||||
this.embedData = data;
|
||||
}
|
||||
async onunload() {
|
||||
// Remove from active embeds
|
||||
this.manager.activeEmbeds.delete(this.embedData);
|
||||
|
||||
// Clear focus if this was focused
|
||||
if (this.manager.plugin.currentFocusedEmbed?.containerEl === this.embedData.containerEl) {
|
||||
this.manager.plugin.currentFocusedEmbed = null;
|
||||
}
|
||||
|
||||
// Properly detach the leaf and clean up the view
|
||||
if (this.embedData.leaf) {
|
||||
this.embedData.leaf.detach();
|
||||
}
|
||||
}
|
||||
})(this, embedData));
|
||||
|
||||
// Open the full file in source mode
|
||||
await leaf.openFile(file, {
|
||||
state: { mode: 'source' }
|
||||
});
|
||||
|
||||
const view = leaf.view;
|
||||
|
||||
if (!(view instanceof MarkdownView)) {
|
||||
this.renderError(embedContainer.parentElement, 'Failed to load a markdown view.', false);
|
||||
leaf.detach();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update embedData with view and editor
|
||||
embedData.view = view;
|
||||
embedData.editor = view.editor;
|
||||
|
||||
this.embedRegistry.set(embedContainer, embedData);
|
||||
this.activeEmbeds.add(embedData);
|
||||
|
||||
// Apply custom height if specified
|
||||
if (customOptions.height) {
|
||||
embedContainer.style.setProperty('--sync-embed-height', customOptions.height);
|
||||
}
|
||||
|
||||
if (customOptions.maxHeight) {
|
||||
embedContainer.style.setProperty('--sync-max-height', customOptions.maxHeight);
|
||||
}
|
||||
|
||||
// For section embeds, setup viewport restriction
|
||||
if (section) {
|
||||
await this.viewportController.setupSectionViewport(embedData);
|
||||
|
||||
// If there's an alias, show it instead of the actual header
|
||||
if (alias) {
|
||||
this.setupAliasDisplay(embedData, alias, true);
|
||||
}
|
||||
} else if (alias) {
|
||||
// For whole note embeds with alias, show alias as title
|
||||
this.setupAliasDisplay(embedData, alias, false);
|
||||
}
|
||||
|
||||
// Handle properties collapse
|
||||
if (this.plugin.settings.collapsePropertiesByDefault) {
|
||||
this.setupPropertiesCollapse(embedData);
|
||||
}
|
||||
|
||||
// Handle inline title visibility
|
||||
const showTitle = customOptions.title !== undefined
|
||||
? customOptions.title
|
||||
: this.plugin.settings.showInlineTitle;
|
||||
|
||||
if (!showTitle || section) {
|
||||
this.hideInlineTitle(embedData);
|
||||
}
|
||||
|
||||
// Remove placeholder and show actual content
|
||||
placeholder.remove();
|
||||
embedContainer.appendChild(view.containerEl);
|
||||
embedContainer.removeClass('sync-embed-loading');
|
||||
|
||||
ctx.addChild(component);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Sync Embeds: Error loading embed:', error);
|
||||
placeholder.setText(`Error: ${error.message}`);
|
||||
placeholder.addClass('sync-embed-error');
|
||||
}
|
||||
}
|
||||
|
||||
setupAliasDisplay(embedData, displayAlias, isSection) {
|
||||
const { view } = embedData;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
// Hide the inline title
|
||||
const titleEl = view.containerEl.querySelector('.inline-title');
|
||||
if (titleEl) {
|
||||
titleEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// For section embeds, also hide the section header
|
||||
if (isSection) {
|
||||
const cmContent = view.containerEl.querySelector('.cm-content');
|
||||
if (cmContent) {
|
||||
if (embedData.sectionInfo) {
|
||||
const headerLineNumber = embedData.sectionInfo.startLine + 1;
|
||||
const firstLine = cmContent.querySelector(`.cm-line:nth-child(${headerLineNumber})`);
|
||||
if (firstLine) {
|
||||
firstLine.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and insert alias header with consistent styling
|
||||
const aliasHeader = view.containerEl.createDiv('sync-embed-alias-header');
|
||||
aliasHeader.textContent = displayAlias;
|
||||
|
||||
const viewContent = view.containerEl.querySelector('.view-content');
|
||||
if (viewContent) {
|
||||
viewContent.insertBefore(aliasHeader, viewContent.firstChild);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
setupPropertiesCollapse(embedData) {
|
||||
const { view } = embedData;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
const propertiesEl = view.containerEl.querySelector('.metadata-container');
|
||||
if (!propertiesEl) return;
|
||||
|
||||
propertiesEl.classList.add('is-collapsed');
|
||||
|
||||
const toggleBtn = propertiesEl.createDiv('properties-collapse-toggle');
|
||||
toggleBtn.innerHTML = 'â–¶';
|
||||
toggleBtn.onclick = () => {
|
||||
propertiesEl.classList.toggle('is-collapsed');
|
||||
toggleBtn.innerHTML = propertiesEl.classList.contains('is-collapsed') ? 'â–¶' : 'â–¼';
|
||||
};
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
hideInlineTitle(embedData) {
|
||||
const { view } = embedData;
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
const titleEl = view.containerEl.querySelector('.inline-title');
|
||||
if (titleEl) {
|
||||
titleEl.style.display = 'none';
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
renderError(container, message, addGap) {
|
||||
const errorDiv = container.createDiv('sync-embed-error');
|
||||
if (addGap) errorDiv.addClass('sync-embed-gap');
|
||||
errorDiv.setText(message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EmbedManager;
|
||||
685
main.js
685
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');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "sync-embeds",
|
||||
"name": "Sync Embeds",
|
||||
"version": "1.1.2",
|
||||
"version": "2.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Create fully editable, live-synced note embeds.",
|
||||
"description": "Create fully editable, live-synced note embeds with advanced features including section viewports, dynamic paths, and command interception.",
|
||||
"author": "uthvah",
|
||||
"authorUrl": "https://github.com/uthvah",
|
||||
"isDesktopOnly": false
|
||||
|
|
|
|||
350
settings.js
Normal file
350
settings.js
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
const { PluginSettingTab, Setting, Notice } = require('obsidian');
|
||||
|
||||
class SyncEmbedsSettingTab extends PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display() {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl('h2', { text: 'Sync Embeds Settings' });
|
||||
|
||||
// === APPEARANCE SECTION ===
|
||||
containerEl.createEl('h3', { text: 'Appearance' });
|
||||
|
||||
// Height presets + custom
|
||||
new Setting(containerEl)
|
||||
.setName('Embed height')
|
||||
.setDesc('Default height for embeds')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('auto', 'Auto (fit content)')
|
||||
.addOption('300px', 'Compact (300px)')
|
||||
.addOption('500px', 'Normal (500px)')
|
||||
.addOption('700px', 'Large (700px)')
|
||||
.addOption('custom', 'Custom')
|
||||
.setValue(this.getHeightPreset())
|
||||
.onChange(async (value) => {
|
||||
if (value !== 'custom') {
|
||||
this.plugin.settings.embedHeight = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display(); // Refresh to hide custom input
|
||||
} else {
|
||||
this.display(); // Show custom input
|
||||
}
|
||||
}));
|
||||
|
||||
// Show custom input if needed
|
||||
if (this.getHeightPreset() === 'custom') {
|
||||
new Setting(containerEl)
|
||||
.setName('Custom height')
|
||||
.setDesc('Enter a custom height (e.g., 450px, 60vh)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('e.g., 450px, 60vh')
|
||||
.setValue(this.plugin.settings.embedHeight)
|
||||
.onChange(async (value) => {
|
||||
if (this.validateCSSValue(value)) {
|
||||
this.plugin.settings.embedHeight = value;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Max height presets + custom
|
||||
new Setting(containerEl)
|
||||
.setName('Maximum height')
|
||||
.setDesc('Maximum height before scrolling')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('none', 'None (no limit)')
|
||||
.addOption('400px', 'Compact (400px)')
|
||||
.addOption('600px', 'Normal (600px)')
|
||||
.addOption('80vh', 'Large (80vh)')
|
||||
.addOption('custom', 'Custom')
|
||||
.setValue(this.getMaxHeightPreset())
|
||||
.onChange(async (value) => {
|
||||
if (value !== 'custom') {
|
||||
this.plugin.settings.maxEmbedHeight = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
} else {
|
||||
this.display();
|
||||
}
|
||||
}));
|
||||
|
||||
// Show custom input if needed
|
||||
if (this.getMaxHeightPreset() === 'custom') {
|
||||
new Setting(containerEl)
|
||||
.setName('Custom maximum height')
|
||||
.setDesc('Enter a custom maximum height (e.g., 550px, 70vh)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('e.g., 550px, 70vh')
|
||||
.setValue(this.plugin.settings.maxEmbedHeight)
|
||||
.onChange(async (value) => {
|
||||
if (this.validateCSSValue(value)) {
|
||||
this.plugin.settings.maxEmbedHeight = value;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Gap presets + custom
|
||||
new Setting(containerEl)
|
||||
.setName('Gap between embeds')
|
||||
.setDesc('Spacing between multiple embeds in a sync block')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('8px', 'Compact (8px)')
|
||||
.addOption('16px', 'Normal (16px)')
|
||||
.addOption('24px', 'Spacious (24px)')
|
||||
.addOption('custom', 'Custom')
|
||||
.setValue(this.getGapPreset())
|
||||
.onChange(async (value) => {
|
||||
if (value !== 'custom') {
|
||||
this.plugin.settings.gapBetweenEmbeds = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
} else {
|
||||
this.display();
|
||||
}
|
||||
}));
|
||||
|
||||
// Show custom input if needed
|
||||
if (this.getGapPreset() === 'custom') {
|
||||
new Setting(containerEl)
|
||||
.setName('Custom gap')
|
||||
.setDesc('Enter a custom gap (e.g., 20px, 1.5rem)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('e.g., 20px, 1.5rem')
|
||||
.setValue(this.plugin.settings.gapBetweenEmbeds)
|
||||
.onChange(async (value) => {
|
||||
if (this.validateCSSValue(value)) {
|
||||
this.plugin.settings.gapBetweenEmbeds = value;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// === BEHAVIOR SECTION ===
|
||||
containerEl.createEl('h3', { text: 'Behavior' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Collapse properties by default')
|
||||
.setDesc('Hide frontmatter/properties in embeds by default')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.collapsePropertiesByDefault)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.collapsePropertiesByDefault = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show inline title')
|
||||
.setDesc('Display note title at the top of whole-note embeds (not applicable to section embeds or embeds with aliases)')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.showInlineTitle)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showInlineTitle = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show focus highlight')
|
||||
.setDesc('Highlight the focused embed with an outline')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.showFocusHighlight)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showFocusHighlight = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// === HEADER MANAGEMENT SECTION ===
|
||||
containerEl.createEl('h3', { text: 'Header Management' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show header hints')
|
||||
.setDesc('Display helpful notices when header creation is blocked in section embeds')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.showHeaderHints)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showHeaderHints = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// === PERFORMANCE SECTION ===
|
||||
containerEl.createEl('h3', { text: 'Performance' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Lazy loading threshold')
|
||||
.setDesc('Start loading embeds this distance before they become visible')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('0px', 'On screen (0px)')
|
||||
.addOption('100px', 'Just before (100px)')
|
||||
.addOption('200px', 'Well before (200px)')
|
||||
.addOption('500px', 'Early (500px)')
|
||||
.setValue(this.plugin.settings.lazyLoadThreshold)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.lazyLoadThreshold = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// === ADVANCED SECTION ===
|
||||
containerEl.createEl('h3', { text: 'Advanced' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable command interception')
|
||||
.setDesc('Allow keyboard shortcuts (Ctrl+B, Ctrl+I, etc.) to work in embeds. Requires restart.')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableCommandInterception)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableCommandInterception = value;
|
||||
await this.plugin.saveSettings();
|
||||
new Notice('Please restart Obsidian for this change to take effect');
|
||||
}));
|
||||
|
||||
// Debug mode
|
||||
new Setting(containerEl)
|
||||
.setName('Debug mode')
|
||||
.setDesc('Enable detailed console logging for troubleshooting')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.debugMode || false)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.debugMode = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// === HELP SECTION ===
|
||||
containerEl.createEl('h3', { text: 'Help & Documentation' });
|
||||
|
||||
const helpDiv = containerEl.createDiv('sync-embeds-help');
|
||||
helpDiv.innerHTML = `
|
||||
<p><strong>Basic Usage:</strong></p>
|
||||
<ul>
|
||||
<li><code>![[Note Name]]</code> - Embed entire note</li>
|
||||
<li><code>![[Note Name#Section]]</code> - Embed specific section</li>
|
||||
<li><code>![[Note Name|Custom Title]]</code> - Display with custom title</li>
|
||||
<li><code>![[Note Name#Section|Custom Title]]</code> - Section with custom title</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Per-Embed Custom Options:</strong></p>
|
||||
<ul>
|
||||
<li><code>![[Note|Alias{height:500px}]]</code> - Custom height for this embed</li>
|
||||
<li><code>![[Note|Alias{maxHeight:600px}]]</code> - Custom max height</li>
|
||||
<li><code>![[Note|Alias{title:false}]]</code> - Hide title for this embed</li>
|
||||
<li><code>![[Note|Alias{height:400px,title:false}]]</code> - Multiple options</li>
|
||||
</ul>
|
||||
<p><em>Note: Options go inside curly braces before the closing ]]</em></p>
|
||||
|
||||
<p><strong>Dynamic Patterns:</strong></p>
|
||||
<ul>
|
||||
<li><code>![[Daily/{{date:YYYY-MM-DD}}|Today]]</code> - Current date with display name</li>
|
||||
<li><code>![[Tasks#{{date:YYYY-MM-DD}}|Today's Tasks]]</code> - Dynamic section</li>
|
||||
<li><code>{{date-7d:YYYY-MM-DD}}</code> - 7 days ago</li>
|
||||
<li><code>{{date+1w:YYYY-MM-DD}}</code> - 1 week from now</li>
|
||||
<li><code>{{date+2m:YYYY-MM-DD}}</code> - 2 months from now</li>
|
||||
<li><code>{{time:HH:mm}}</code> - Current time</li>
|
||||
<li><code>{{title}}</code> - Current note's title</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Header Management:</strong></p>
|
||||
<ul>
|
||||
<li>Use <code>Alt+2</code> through <code>Alt+6</code> to insert headers (H2-H6)</li>
|
||||
<li>In section embeds, only sub-headers are allowed (e.g., if section is H2, only H3-H6 work)</li>
|
||||
<li>Typing <code>#</code> at line start is blocked in section embeds to prevent hierarchy violations</li>
|
||||
<li>Press the same hotkey again on a header to remove formatting</li>
|
||||
<li>Press a different hotkey to change header level</li>
|
||||
<li>Whole-note embeds allow H1-H6 freely</li>
|
||||
<li>These hotkeys can be customized in Obsidian's Hotkeys settings</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Date Format Examples:</strong></p>
|
||||
<ul>
|
||||
<li><code>YYYY-MM-DD</code> - 2024-03-15</li>
|
||||
<li><code>YYYY/MM/DD</code> - 2024/03/15</li>
|
||||
<li><code>DD MMM YYYY</code> - 15 Mar 2024</li>
|
||||
<li><code>dddd, MMMM Do YYYY</code> - Friday, March 15th 2024</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Complete Example:</strong></p>
|
||||
<pre><code>\`\`\`sync
|
||||
![[Daily Notes/{{date:YYYY-MM-DD}}|Today's Note]]
|
||||
![[Daily Notes/{{date-1d:YYYY-MM-DD}}|Yesterday]]
|
||||
![[Tasks#Inbox|My Tasks{height:300px}]]
|
||||
![[Projects/{{title}}#Notes|Project Notes{title:false}]]
|
||||
\`\`\`</code></pre>
|
||||
|
||||
<p><strong>Tips:</strong></p>
|
||||
<ul>
|
||||
<li>Use aliases (text after <code>|</code>) with dynamic patterns for better display</li>
|
||||
<li>Per-embed options override global settings: <code>{height:400px,title:false}</code></li>
|
||||
<li>Section embeds are fully editable and changes sync immediately</li>
|
||||
<li>Press Tab/Shift+Tab to navigate between embeds</li>
|
||||
<li>Keyboard shortcuts work inside embeds when command interception is enabled</li>
|
||||
<li>Use lazy loading for better performance with many embeds</li>
|
||||
<li>Multiple embeds of the same note/section are allowed</li>
|
||||
<li>Header hierarchy enforcement maintains document structure in section embeds</li>
|
||||
</ul>
|
||||
|
||||
<p><em>Note: Dynamic patterns are cached for 1 second to improve performance.</em></p>
|
||||
`;
|
||||
|
||||
// Reset to defaults button
|
||||
containerEl.createEl('h3', { text: 'Reset' });
|
||||
new Setting(containerEl)
|
||||
.setName('Reset to defaults')
|
||||
.setDesc('Reset all settings to their default values')
|
||||
.addButton(button => button
|
||||
.setButtonText('Reset')
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
if (confirm('Are you sure you want to reset all settings to defaults?')) {
|
||||
Object.assign(this.plugin.settings, this.plugin.DEFAULT_SETTINGS);
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
new Notice('Settings reset to defaults');
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Helper methods for presets
|
||||
getHeightPreset() {
|
||||
const value = this.plugin.settings.embedHeight;
|
||||
if (['auto', '300px', '500px', '700px'].includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
getMaxHeightPreset() {
|
||||
const value = this.plugin.settings.maxEmbedHeight;
|
||||
if (['none', '400px', '600px', '80vh'].includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
getGapPreset() {
|
||||
const value = this.plugin.settings.gapBetweenEmbeds;
|
||||
if (['8px', '16px', '24px'].includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
validateCSSValue(value) {
|
||||
// Basic validation for CSS length values
|
||||
if (!value || value.trim() === '') return false;
|
||||
|
||||
// Allow common CSS units
|
||||
const validPattern = /^(auto|none|\d+(\.\d+)?(px|em|rem|vh|vw|%))$/;
|
||||
const isValid = validPattern.test(value.trim());
|
||||
|
||||
if (!isValid) {
|
||||
new Notice('Invalid CSS value. Use units like: px, em, rem, vh, vw, %, or "auto"/"none"');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SyncEmbedsSettingTab;
|
||||
386
styles.css
386
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;
|
||||
}
|
||||
}
|
||||
406
viewport-controller.js
Normal file
406
viewport-controller.js
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
const { Notice } = require('obsidian');
|
||||
|
||||
class ViewportController {
|
||||
constructor(plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
async setupSectionViewport(embedData) {
|
||||
const { view, editor, file, section } = embedData;
|
||||
|
||||
// Wait for editor to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const content = editor.getValue();
|
||||
const sectionInfo = this.findSectionBounds(content, section);
|
||||
|
||||
if (sectionInfo.startLine === -1) {
|
||||
// Section not found
|
||||
editor.setValue(`# ${section}\n\n*Section not found in file.*`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store section metadata
|
||||
embedData.sectionInfo = sectionInfo;
|
||||
embedData.viewportActive = true;
|
||||
|
||||
// Apply the viewport restriction
|
||||
this.applyViewportRestriction(embedData);
|
||||
|
||||
// Setup header protection (simplified, no auto-correction)
|
||||
this.setupHeaderProtection(embedData);
|
||||
|
||||
// NEW: Setup input interception to block # typing
|
||||
this.setupHeaderInputInterception(embedData);
|
||||
|
||||
// Setup cursor and content constraints
|
||||
this.setupContentConstraints(embedData);
|
||||
|
||||
// Scroll to section
|
||||
this.scrollToSection(embedData);
|
||||
}
|
||||
|
||||
applyViewportRestriction(embedData) {
|
||||
const { view, sectionInfo } = embedData;
|
||||
const { startLine, endLine } = sectionInfo;
|
||||
|
||||
// Use CSS to hide lines outside the viewport
|
||||
const style = document.createElement('style');
|
||||
style.className = 'sync-viewport-style';
|
||||
|
||||
// Generate unique ID for this embed
|
||||
const embedId = 'embed-' + Math.random().toString(36).substr(2, 9);
|
||||
view.containerEl.setAttribute('data-embed-id', embedId);
|
||||
|
||||
// Store embed ID for dynamic updates
|
||||
embedData.embedId = embedId;
|
||||
|
||||
this.updateViewportCSS(embedData, style);
|
||||
|
||||
view.containerEl.appendChild(style);
|
||||
|
||||
// Store for cleanup and updates
|
||||
embedData.viewportStyle = style;
|
||||
}
|
||||
|
||||
updateViewportCSS(embedData, style) {
|
||||
const { sectionInfo, embedId } = embedData;
|
||||
const { startLine, endLine } = sectionInfo;
|
||||
|
||||
// PERFORMANCE: Use efficient CSS selectors
|
||||
const css = `
|
||||
/* Hide all lines before the section */
|
||||
[data-embed-id="${embedId}"] .cm-line:nth-child(-n+${startLine}) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Hide all lines after the section */
|
||||
[data-embed-id="${embedId}"] .cm-line:nth-child(n+${endLine + 1}) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Style the header line - No background, proper theming */
|
||||
[data-embed-id="${embedId}"] .cm-line:nth-child(${startLine + 1}) {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
`;
|
||||
|
||||
style.textContent = css;
|
||||
}
|
||||
|
||||
setupHeaderProtection(embedData) {
|
||||
const { view, editor, sectionInfo, component } = embedData;
|
||||
const { startLine } = sectionInfo;
|
||||
|
||||
// Store original header to restore if modified
|
||||
const originalHeader = editor.getLine(startLine);
|
||||
embedData.originalHeader = originalHeader;
|
||||
|
||||
let isProgrammaticUpdate = false;
|
||||
|
||||
const changeHandler = (changedEditor) => {
|
||||
if (changedEditor !== editor || isProgrammaticUpdate) return;
|
||||
if (!embedData.viewportActive) return;
|
||||
|
||||
const cursorPos = editor.getCursor();
|
||||
const absoluteLine = cursorPos.line;
|
||||
|
||||
// Protect the header line from any edits
|
||||
if (absoluteLine === startLine) {
|
||||
const currentLine = editor.getLine(startLine);
|
||||
if (currentLine !== originalHeader) {
|
||||
isProgrammaticUpdate = true;
|
||||
editor.replaceRange(
|
||||
originalHeader,
|
||||
{ line: startLine, ch: 0 },
|
||||
{ line: startLine, ch: currentLine.length }
|
||||
);
|
||||
// Move cursor to line below
|
||||
editor.setCursor({ line: startLine + 1, ch: 0 });
|
||||
isProgrammaticUpdate = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
component.registerEvent(
|
||||
this.plugin.app.workspace.on('editor-change', changeHandler)
|
||||
);
|
||||
}
|
||||
|
||||
setupHeaderInputInterception(embedData) {
|
||||
// CHANGED: Removed `sectionInfo` from destructuring to avoid a stale closure.
|
||||
const { view, editor, component } = embedData;
|
||||
// The header level of the main section won't change, so it's safe to get it once.
|
||||
const { headerLevel } = embedData.sectionInfo;
|
||||
|
||||
// Always enforce in section embeds
|
||||
let lastNoticeTime = 0;
|
||||
const noticeDebounce = 5000; // 5 seconds between notices
|
||||
|
||||
// CRITICAL: Wait for the editor to be fully ready and focused
|
||||
// Use a small delay to ensure the CodeMirror editor is properly initialized
|
||||
const setupHandlers = () => {
|
||||
// Get the actual CodeMirror editor element
|
||||
const cmEditor = view.containerEl.querySelector('.cm-content');
|
||||
if (!cmEditor) {
|
||||
// Retry if not ready yet
|
||||
setTimeout(setupHandlers, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle input event to catch # at line start (use 'input' not 'beforeinput' for better timing)
|
||||
const inputHandler = (event) => {
|
||||
// Only process insertText events
|
||||
if (event.inputType !== 'insertText' && event.inputType !== 'insertFromPaste') return;
|
||||
if (event.data !== '#') return;
|
||||
|
||||
// Check immediately if # was typed at line start
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
|
||||
// Check if the # is at the start of the line (accounting for whitespace)
|
||||
const beforeHash = line.substring(0, cursor.ch - 1);
|
||||
const isAtLineStart = /^\s*$/.test(beforeHash);
|
||||
|
||||
// CHANGED: Read directly from `embedData.sectionInfo` to get the latest, non-stale bounds.
|
||||
if (isAtLineStart &&
|
||||
cursor.line > embedData.sectionInfo.startLine &&
|
||||
cursor.line < embedData.sectionInfo.endLine) {
|
||||
|
||||
// Remove the # that was just typed
|
||||
const currentLine = editor.getLine(cursor.line);
|
||||
const newLine = currentLine.substring(0, cursor.ch - 1) + currentLine.substring(cursor.ch);
|
||||
editor.replaceRange(
|
||||
newLine,
|
||||
{ line: cursor.line, ch: 0 },
|
||||
{ line: cursor.line, ch: currentLine.length }
|
||||
);
|
||||
editor.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
|
||||
|
||||
// Show notice (debounced)
|
||||
const now = Date.now();
|
||||
if (this.plugin.settings.showHeaderHints &&
|
||||
now - lastNoticeTime > noticeDebounce) {
|
||||
|
||||
lastNoticeTime = now;
|
||||
|
||||
const availableLevels = [];
|
||||
for (let i = headerLevel + 1; i <= 6; i++) {
|
||||
availableLevels.push(`H${i} (Alt+${i})`);
|
||||
}
|
||||
|
||||
new Notice(
|
||||
`⚠️ Cannot create H1-H${headerLevel} headers in this section.\n` +
|
||||
`Use: ${availableLevels.join(', ')}`,
|
||||
5000
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Also handle paste events
|
||||
const pasteHandler = (event) => {
|
||||
const clipboardData = event.clipboardData?.getData('text');
|
||||
if (!clipboardData) return;
|
||||
|
||||
const cursor = editor.getCursor();
|
||||
|
||||
// CHANGED: Read directly from `embedData.sectionInfo` to get the latest, non-stale bounds.
|
||||
if (cursor.line > embedData.sectionInfo.startLine &&
|
||||
cursor.line < embedData.sectionInfo.endLine) {
|
||||
|
||||
const lines = clipboardData.split('\n');
|
||||
let hasInvalidHeaders = false;
|
||||
|
||||
// Check if pasted content has invalid headers
|
||||
const adjustedLines = lines.map(line => {
|
||||
const match = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (!match) return line;
|
||||
|
||||
const [, hashes, content] = match;
|
||||
const level = hashes.length;
|
||||
|
||||
if (level <= headerLevel) {
|
||||
hasInvalidHeaders = true;
|
||||
// Adjust to minimum allowed level
|
||||
const newLevel = headerLevel + 1;
|
||||
return '#'.repeat(newLevel) + ' ' + content;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
if (hasInvalidHeaders) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.plugin.settings.showHeaderHints) {
|
||||
new Notice(
|
||||
'Pasted headers adjusted to maintain section hierarchy',
|
||||
4000
|
||||
);
|
||||
}
|
||||
|
||||
// Insert adjusted content
|
||||
editor.replaceSelection(adjustedLines.join('\n'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Use 'input' event which fires AFTER the character is inserted
|
||||
cmEditor.addEventListener('input', inputHandler, true);
|
||||
cmEditor.addEventListener('paste', pasteHandler, true);
|
||||
|
||||
// Cleanup
|
||||
component.register(() => {
|
||||
cmEditor.removeEventListener('input', inputHandler, true);
|
||||
cmEditor.removeEventListener('paste', pasteHandler, true);
|
||||
});
|
||||
};
|
||||
|
||||
// Start setup with a small delay to ensure editor is ready
|
||||
setTimeout(setupHandlers, 100);
|
||||
}
|
||||
|
||||
setupContentConstraints(embedData) {
|
||||
const { view, editor, sectionInfo, component } = embedData;
|
||||
|
||||
let isProgrammaticUpdate = false;
|
||||
|
||||
// CRITICAL FIX: Update viewport IMMEDIATELY before visible changes
|
||||
const updateViewportImmediately = () => {
|
||||
if (!embedData.viewportActive) return;
|
||||
|
||||
const currentContent = editor.getValue();
|
||||
const newSectionInfo = this.findSectionBounds(currentContent, embedData.section);
|
||||
|
||||
if (newSectionInfo.startLine !== -1) {
|
||||
embedData.sectionInfo = newSectionInfo;
|
||||
|
||||
// Update CSS synchronously to prevent flashing
|
||||
if (embedData.viewportStyle) {
|
||||
this.updateViewportCSS(embedData, embedData.viewportStyle);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Monitor for content changes with instant updates
|
||||
const constrainContent = () => {
|
||||
if (isProgrammaticUpdate || !embedData.viewportActive) return;
|
||||
|
||||
// Update viewport bounds IMMEDIATELY
|
||||
updateViewportImmediately();
|
||||
|
||||
// Constrain cursor to section bounds
|
||||
const cursor = editor.getCursor();
|
||||
const currentSectionInfo = embedData.sectionInfo;
|
||||
|
||||
if (cursor.line < currentSectionInfo.startLine) {
|
||||
isProgrammaticUpdate = true;
|
||||
editor.setCursor({ line: currentSectionInfo.startLine + 1, ch: 0 });
|
||||
isProgrammaticUpdate = false;
|
||||
} else if (cursor.line >= currentSectionInfo.endLine) {
|
||||
isProgrammaticUpdate = true;
|
||||
const lastEditableLine = currentSectionInfo.endLine - 1;
|
||||
const lastLineLength = editor.getLine(lastEditableLine)?.length || 0;
|
||||
editor.setCursor({ line: lastEditableLine, ch: lastLineLength });
|
||||
isProgrammaticUpdate = false;
|
||||
}
|
||||
};
|
||||
|
||||
// CHANGED: Removed `requestAnimationFrame`. Updates must be synchronous to prevent a race condition
|
||||
// where the `input` event for typing a hash fires before the section bounds are updated.
|
||||
component.registerEvent(
|
||||
this.plugin.app.workspace.on('editor-change', (changedEditor) => {
|
||||
if (changedEditor === editor) {
|
||||
constrainContent();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Prevent scrolling outside section
|
||||
const cmScroller = view.containerEl.querySelector('.cm-scroller');
|
||||
if (cmScroller) {
|
||||
const preventScroll = (e) => {
|
||||
if (!embedData.viewportActive) return;
|
||||
|
||||
const scrollTop = cmScroller.scrollTop;
|
||||
const lineHeight = editor.defaultTextHeight || 20;
|
||||
const firstVisibleLine = Math.floor(scrollTop / lineHeight);
|
||||
const currentSectionInfo = embedData.sectionInfo;
|
||||
|
||||
// Constrain scrolling to section
|
||||
if (firstVisibleLine < Math.max(0, currentSectionInfo.startLine - 2)) {
|
||||
cmScroller.scrollTop = Math.max(0, currentSectionInfo.startLine - 2) * lineHeight;
|
||||
} else if (firstVisibleLine > currentSectionInfo.endLine - 2) {
|
||||
cmScroller.scrollTop = (currentSectionInfo.endLine - 2) * lineHeight;
|
||||
}
|
||||
};
|
||||
|
||||
cmScroller.addEventListener('scroll', preventScroll);
|
||||
component.register(() => {
|
||||
cmScroller.removeEventListener('scroll', preventScroll);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scrollToSection(embedData) {
|
||||
const { view, editor, sectionInfo } = embedData;
|
||||
const { startLine } = sectionInfo;
|
||||
|
||||
setTimeout(() => {
|
||||
// Scroll to the start of the section
|
||||
editor.scrollIntoView({ line: startLine, ch: 0 }, true);
|
||||
|
||||
// Set cursor at line after header
|
||||
editor.setCursor({ line: startLine + 1, ch: 0 });
|
||||
}, 150);
|
||||
}
|
||||
|
||||
findSectionBounds(content, sectionName) {
|
||||
const lines = content.split('\n');
|
||||
const escapedName = this.escapeRegExp(sectionName);
|
||||
const headerRegex = new RegExp(`^#{1,6}\\s+${escapedName}\\s*$`);
|
||||
|
||||
let startLine = -1;
|
||||
let headerLevel = 0;
|
||||
|
||||
// Find start of section
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (headerRegex.test(lines[i])) {
|
||||
startLine = i;
|
||||
headerLevel = (lines[i].match(/^#+/)?.[0] || '').length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startLine === -1) {
|
||||
return { startLine: -1, endLine: -1, headerLevel: 0 };
|
||||
}
|
||||
|
||||
// Find end of section
|
||||
let endLine = lines.length;
|
||||
for (let i = startLine + 1; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^#+/);
|
||||
if (match && match[0].length <= headerLevel) {
|
||||
endLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { startLine, endLine, headerLevel };
|
||||
}
|
||||
|
||||
escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
cleanupViewport(embedData) {
|
||||
if (embedData.viewportStyle) {
|
||||
embedData.viewportStyle.remove();
|
||||
}
|
||||
embedData.viewportActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ViewportController;
|
||||
Loading…
Reference in a new issue