/* HermesCustom — Lukas' additions to obsidian-web-viewer * * Loaded by vault.html (one-line patch) AFTER the main script. Initializes * automatically via HermesCustom.init() at the bottom of this file. * * Features: * 1. Current-page highlighting in 3D Graph * 2. Click-to-navigate on 3D Graph nodes (Three.js raycasting) * 3. Responsive layout (graph collapses below 1024px, tree below 768px) * * Design notes: see docs/ARCHITECTURE.md */ (function() { 'use strict'; window.HermesCustom = window.HermesCustom || {}; const HermesCustom = window.HermesCustom; // ============================================================ // 1. Current-page highlighting // ============================================================ /** * Determine which file is currently being viewed. * Uses URL ?file= parameter, or falls back to extracting from the * page content (the last file fetched via loadFile). */ HermesCustom.getCurrentFile = function() { // Method 1: ?file= query param (preferred) const params = new URLSearchParams(window.location.search); const fileParam = params.get('file'); if (fileParam) return fileParam; // Method 2: Parse from URL hash if obv uses hash-based routing if (window.location.hash) { const hashMatch = window.location.hash.match(/file=([^&]+)/); if (hashMatch) return decodeURIComponent(hashMatch[1]); } // Method 3: Track via the last loadFile() call (set up in init) return HermesCustom._lastLoadedFile || null; }; /** * Find all WikiLink targets in the currently loaded file's content. * We hook into the renderMarkdown flow by tracking what was last loaded. */ HermesCustom.getConnectedFiles = function(currentFile) { if (!currentFile) return []; // Use the global graph data loaded by obv's fetch('/api/vault/graph') const graphData = HermesCustom._graphData; if (!graphData) return []; const connections = graphData.edges .filter(e => e.source === currentFile) .map(e => e.target); // Also outgoing from current file (WikiLinks from current page) // And bidirectional awareness return connections; }; /** * Highlight the current node + connected nodes in the 3D Graph. * Uses obv's global graph variables (graphScene, graphNodeObjects). */ HermesCustom.highlightCurrentNode = function() { const currentFile = HermesCustom.getCurrentFile(); if (!currentFile) return; // Hook into obv's globals (set after graph render) if (typeof window.graphScene === 'undefined' || !window.graphNodeObjects) { // Graph not yet built — try again shortly setTimeout(HermesCustom.highlightCurrentNode, 500); return; } const connected = new Set(HermesCustom.getConnectedFiles(currentFile)); connected.add(currentFile); // Iterate node meshes and adjust material window.graphNodeObjects.forEach((mesh, nodeId) => { const isCurrent = (nodeId === currentFile); const isConnected = connected.has(nodeId) && !isCurrent; if (isCurrent) { // Bright + slightly larger + emissive glow mesh.material.color.setHex(0xFFD700); // gold mesh.material.emissive.setHex(0xFFD700); mesh.material.emissiveIntensity = 0.6; mesh.scale.set(1.5, 1.5, 1.5); } else if (isConnected) { // Brighter than default, smaller than current mesh.material.color.setHex(0x89b4fa); // light blue mesh.material.emissive.setHex(0x89b4fa); mesh.material.emissiveIntensity = 0.3; mesh.scale.set(1.1, 1.1, 1.1); } else { // Dim everything else mesh.material.color.setHex(0x313244); // muted mesh.material.emissiveIntensity = 0; mesh.material.opacity = 0.2; mesh.material.transparent = true; } }); }; // ============================================================ // 2. Click-to-navigate on 3D Graph // ============================================================ HermesCustom.setupGraphClickHandler = function() { if (!window.graphRenderer || !window.graphScene || !window.graphCamera) { setTimeout(HermesCustom.setupGraphClickHandler, 500); return; } const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); window.graphRenderer.domElement.addEventListener('click', (event) => { const rect = window.graphRenderer.domElement.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, window.graphCamera); const intersects = raycaster.intersectObjects( Object.values(window.graphNodeObjects || {}) ); if (intersects.length > 0) { const nodeId = intersects[0].object.userData.nodeId; if (nodeId && typeof window.loadFile === 'function') { window.loadFile(nodeId); window.history.pushState({}, '', `?file=${encodeURIComponent(nodeId)}`); } } }); }; // ============================================================ // 3. Responsive layout // ============================================================ HermesCustom.responsiveLayout = function() { const applyLayout = () => { const app = document.querySelector('.vault-app'); if (!app) return; const width = window.innerWidth; if (width < 768) { // Mobile: tree collapses to hamburger, graph hidden by default app.classList.add('mobile-mode'); app.classList.remove('graph-visible'); } else if (width < 1024) { // Tablet: tree visible, graph collapses to bottom panel app.classList.add('tablet-mode'); app.classList.remove('mobile-mode'); } else { // Desktop: full 3-panel layout app.classList.remove('mobile-mode', 'tablet-mode'); } }; // Throttled resize handler let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(applyLayout, 150); }); applyLayout(); // Initial }; /** * Inject responsive CSS into (since vault.html doesn't have these) * Must run early so it doesn't conflict with obv's layout. */ HermesCustom.injectResponsiveCSS = function() { if (document.getElementById('hermes-custom-css')) return; const style = document.createElement('style'); style.id = 'hermes-custom-css'; style.textContent = ` /* === Tablet (768px - 1023px): graph → bottom panel === */ @media (max-width: 1023px) { .vault-app.tablet-mode { grid-template-columns: 240px 1fr !important; grid-template-rows: 48px 1fr 200px !important; } .vault-app.tablet-mode .vault-graph { grid-column: 1 / -1 !important; grid-row: 3 !important; border-left: none !important; border-top: 1px solid var(--border); } .vault-app.tablet-mode .vault-content { grid-column: 2 !important; } } /* === Mobile (<768px): tree → hamburger menu === */ @media (max-width: 767px) { .vault-app.mobile-mode { grid-template-columns: 1fr !important; grid-template-rows: 48px 1fr !important; } .vault-app.mobile-mode .vault-sidebar { position: fixed; top: 48px; left: 0; bottom: 0; width: 280px; transform: translateX(-100%); transition: transform 0.2s ease; z-index: 100; } .vault-app.mobile-mode .vault-sidebar.open { transform: translateX(0); } .vault-app.mobile-mode .vault-graph { display: none; } } /* === Hamburger toggle button (mobile only) === */ .hermes-mobile-toggle { display: none; background: var(--bg-secondary); border: 1px solid var(--border); color: var(--text-secondary); padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; } @media (max-width: 767px) { .hermes-mobile-toggle { display: inline-block; } } `; document.head.appendChild(style); // Add hamburger button to header const header = document.querySelector('.vault-header'); if (header && !document.querySelector('.hermes-mobile-toggle')) { const btn = document.createElement('button'); btn.className = 'hermes-mobile-toggle'; btn.textContent = '☰ Tree'; btn.onclick = () => { const sidebar = document.querySelector('.vault-sidebar'); if (sidebar) sidebar.classList.toggle('open'); }; header.insertBefore(btn, header.firstChild); } }; // ============================================================ // Init: wire everything together // ============================================================ HermesCustom.init = function() { console.log('[HermesCustom] init'); HermesCustom.injectResponsiveCSS(); HermesCustom.responsiveLayout(); // Hook into obv's loadFile to track current file + re-highlight if (typeof window.loadFile === 'function') { const originalLoadFile = window.loadFile; window.loadFile = function(path) { HermesCustom._lastLoadedFile = path; const result = originalLoadFile.apply(this, arguments); // Re-highlight after content renders setTimeout(HermesCustom.highlightCurrentNode, 300); return result; }; } // Fetch graph data once for highlight logic fetch('/api/vault/graph') .then(r => r.json()) .then(data => { HermesCustom._graphData = data; console.log(`[HermesCustom] Loaded graph: ${data.nodes.length} nodes, ${data.edges.length} edges`); HermesCustom.highlightCurrentNode(); }) .catch(err => console.warn('[HermesCustom] Graph fetch failed:', err)); // Click handler + responsive layout (need to wait for Three.js to init) setTimeout(() => { HermesCustom.setupGraphClickHandler(); }, 1500); }; // Auto-init when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', HermesCustom.init); } else { HermesCustom.init(); } })();