203 lines
7.6 KiB
TypeScript
203 lines
7.6 KiB
TypeScript
import { Plugin } from 'obsidian';
|
|
|
|
const { shell } = require('electron');
|
|
|
|
|
|
export default class MenuPlugin extends Plugin {
|
|
async onload() {
|
|
this.registerMarkdownCodeBlockProcessor('menu', (source, el, ctx) => {
|
|
const lines = source.trim().split('\n');
|
|
let layoutOrClass = '';
|
|
let colors: Record<string, string> = {};
|
|
const links: string[] = [];
|
|
|
|
// Parse YAML-like properties and links
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith('layout:') || trimmed.startsWith('class:')) {
|
|
const colonIndex = trimmed.indexOf(':');
|
|
layoutOrClass = trimmed.substring(colonIndex + 1).trim();
|
|
} else if (trimmed.includes(':') && !trimmed.startsWith('[') && !trimmed.startsWith('[[')) {
|
|
// Parse color properties
|
|
const [key, ...valueParts] = trimmed.split(':');
|
|
const value = valueParts.join(':').trim();
|
|
if (key && value && !key.includes('//') && !key.includes('http')) {
|
|
colors[key.trim()] = value;
|
|
}
|
|
} else if (trimmed && !trimmed.includes(':')) {
|
|
links.push(trimmed);
|
|
} else if (trimmed.startsWith('[')) {
|
|
links.push(trimmed);
|
|
}
|
|
}
|
|
|
|
// Determine layout and classes
|
|
const builtInLayouts = new Set(['default', 'minimal', 'slate', 'horizon', 'aether']);
|
|
const container = el.createEl('div', { cls: 'menu-container' });
|
|
|
|
let selectedLayout = '';
|
|
let extraClasses: string[] = [];
|
|
|
|
if (layoutOrClass) {
|
|
const tokens = layoutOrClass.split(/\s+/).filter(Boolean);
|
|
if (tokens.length) {
|
|
// Allow built-in layout to appear anywhere in the list (e.g., "class: my-class horizon wide")
|
|
const builtInIndex = tokens.findIndex(t => builtInLayouts.has(t));
|
|
if (builtInIndex !== -1) {
|
|
selectedLayout = tokens[builtInIndex];
|
|
extraClasses = tokens.filter((_, i) => i !== builtInIndex);
|
|
} else {
|
|
// Custom class mode: do not apply plugin CSS (no data-layout)
|
|
extraClasses = tokens;
|
|
}
|
|
}
|
|
} else {
|
|
// No layout/class provided: use default built-in template
|
|
selectedLayout = 'default';
|
|
}
|
|
|
|
// Apply selected built-in template via data attribute (gates plugin CSS)
|
|
if (selectedLayout) {
|
|
container.setAttr('data-layout', selectedLayout);
|
|
}
|
|
|
|
// Apply any extra classes (e.g., "wide" or user-provided classes)
|
|
for (const cls of extraClasses) {
|
|
container.addClass(cls);
|
|
}
|
|
|
|
// Apply custom properties (whitelist only: bg, text, border, font and their -hover variants,
|
|
// plus internal-, external-, file- prefixed versions). No raw CSS props allowed.
|
|
if (Object.keys(colors).length > 0) {
|
|
const baseKeys = new Set([
|
|
'bg','text','border','font',
|
|
'hover-text','hover-bg','hover-border','hover-font',
|
|
]);
|
|
const normalizeKey = (raw: string) => {
|
|
let s = raw.trim().toLowerCase();
|
|
// Normalize synonyms/order for hover variants
|
|
s = s
|
|
// Prefer "hover-*" (matches CSS)
|
|
.replace(/\btext-hover\b/g, 'hover-text')
|
|
.replace(/\bbg-hover\b/g, 'hover-bg')
|
|
.replace(/\bborder-hover\b/g, 'hover-border')
|
|
// Old naming from earlier versions -> new "hover-*" order
|
|
.replace(/\binternal-text-hover\b/g, 'internal-hover-text')
|
|
.replace(/\binternal-bg-hover\b/g, 'internal-hover-bg')
|
|
.replace(/\binternal-border-hover\b/g, 'internal-hover-border')
|
|
.replace(/\bexternal-text-hover\b/g, 'external-hover-text')
|
|
.replace(/\bexternal-bg-hover\b/g, 'external-hover-bg')
|
|
.replace(/\bexternal-border-hover\b/g, 'external-hover-border')
|
|
.replace(/\bfile-text-hover\b/g, 'file-hover-text')
|
|
.replace(/\bfile-bg-hover\b/g, 'file-hover-bg')
|
|
.replace(/\bfile-border-hover\b/g, 'file-hover-border')
|
|
// Back-compat for "accent" -> "hover-text"
|
|
.replace(/\baccent\b/g, 'hover-text')
|
|
.replace(/\binternal-accent\b/g, 'internal-hover-text')
|
|
.replace(/\bexternal-accent\b/g, 'external-hover-text')
|
|
.replace(/\bfile-accent\b/g, 'file-hover-text')
|
|
.replace(/\bbackground\b/g, 'bg');
|
|
return s;
|
|
};
|
|
const isAllowed = (key: string) => {
|
|
if (baseKeys.has(key)) return true;
|
|
const m = key.match(/^(internal|external|file)-(.*)$/);
|
|
return !!(m && baseKeys.has(m[2]));
|
|
};
|
|
for (const [rawKey, value] of Object.entries(colors)) {
|
|
const key = normalizeKey(rawKey);
|
|
if (!isAllowed(key)) continue;
|
|
container.style.setProperty(`--${key}`, value);
|
|
}
|
|
}
|
|
|
|
// In custom class mode (no built-in layout), apply base inline styles to anchors so whitelist vars work without CSS.
|
|
const applyInlineBaseStyles = (a: HTMLElement, variant: 'internal' | 'external' | 'file' | 'generic') => {
|
|
const prefix = variant === 'generic' ? '' : `${variant}-`;
|
|
const get = (k: string) => (colors[`${prefix}${k}`] ?? colors[k]);
|
|
const bgVal = get('bg'); if (bgVal) a.style.background = bgVal as string;
|
|
const textVal = get('text'); if (textVal) a.style.color = textVal as string;
|
|
const borderVal = get('border'); if (borderVal) a.style.borderColor = borderVal as string;
|
|
const fontVal = get('font'); if (fontVal) a.style.fontFamily = fontVal as string;
|
|
// Expose hover values as CSS variables on the anchor for user CSS to consume if desired
|
|
const hoverKeys = ['hover-bg','hover-text','hover-border','hover-font'];
|
|
for (const hk of hoverKeys) {
|
|
const v = get(hk);
|
|
if (v) a.style.setProperty(`--${hk}`, v as string);
|
|
}
|
|
};
|
|
|
|
// Process each link
|
|
for (const link of links) {
|
|
if (link.startsWith('[[') && link.endsWith(']]')) {
|
|
// Internal link
|
|
const linkContent = link.slice(2, -2);
|
|
let href = linkContent;
|
|
let text = linkContent;
|
|
if (linkContent.includes('|')) {
|
|
[href, text] = linkContent.split('|');
|
|
}
|
|
const a = container.createEl('a', {
|
|
text: text,
|
|
attr: { 'data-href': href }
|
|
});
|
|
a.addClass('menu-internal-link');
|
|
if (!selectedLayout) applyInlineBaseStyles(a, 'internal');
|
|
a.style.cursor = 'pointer';
|
|
a.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const vaultName = this.app.vault.getName();
|
|
const encodedFile = encodeURIComponent(href);
|
|
const uri = `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodedFile}`;
|
|
window.open(uri);
|
|
});
|
|
} else if (link.match(/^\[.*\]\(.*\)$/)) {
|
|
// External link
|
|
const match = link.match(/^\[(.*)\]\((.*)\)$/);
|
|
if (match) {
|
|
const text = match[1];
|
|
const url = match[2];
|
|
const a = container.createEl('a', {
|
|
text: text,
|
|
attr: url.startsWith('file://') ? {} : { href: url, target: '_blank', rel: 'noopener noreferrer' }
|
|
});
|
|
a.style.cursor = 'pointer';
|
|
|
|
// Add appropriate class based on link type
|
|
if (url.startsWith('file://')) {
|
|
a.addClass('menu-file-link');
|
|
if (!selectedLayout) applyInlineBaseStyles(a, 'file');
|
|
} else {
|
|
a.addClass('menu-external-link');
|
|
if (!selectedLayout) applyInlineBaseStyles(a, 'external');
|
|
}
|
|
a.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (url.startsWith('file://')) {
|
|
try {
|
|
// Convert file URL to path and handle Windows paths
|
|
let filePath = decodeURIComponent(url.substring(7)); // Remove 'file://'
|
|
// Handle Windows paths that start with /C:
|
|
if (filePath.startsWith('/') && filePath.charAt(2) === ':') {
|
|
filePath = filePath.substring(1);
|
|
}
|
|
console.log('Opening file path:', filePath);
|
|
shell.openPath(filePath);
|
|
} catch (error) {
|
|
console.error('Failed to open file:', error);
|
|
}
|
|
} else {
|
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
onunload() {
|
|
// Cleanup if needed
|
|
}
|
|
}
|