Fork of DanielCheer/obsidian-web-viewer (MIT, 2026-04) with Lukas' first additions for the Hermes Wiki workflow. Additive customization layer (vault-custom.js) — keeps upstream-merge trivial via a one-line script tag in vault.html. Features added: 1. Current-page highlighting in 3D Graph — current node gold+glow, connected nodes light-blue, others dim to 20% opacity 2. Click-to-navigate on graph — Three.js raycaster triggers file load 3. Responsive layout — graph collapses below 1024px, tree below 768px 4. URL-based deep-linking via ?file=<path> query param 5. Server-side ?file= support in /api/vault/file/ endpoint Modified files: - server.py: +7 lines (Lukas-add: ?file= query param parsing) - vault.html: +1 line (script tag for vault-custom.js) New files: - vault-custom.js: 11KB, all customizations in one place under HermesCustom namespace - README.md: fork intro, quick start, customization guide - CHANGELOG.md: Lukas-additions tracking - docs/ARCHITECTURE.md: design rationale - docs/CUSTOMIZATIONS.md: feature spec - .gitignore: standard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
303 lines
11 KiB
JavaScript
303 lines
11 KiB
JavaScript
/* 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=<path> 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 <head> (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();
|
|
}
|
|
|
|
})();
|