Files
hermes-wiki-static/vault-custom.js
T
agentandClaude 5e0f8d87a8 v0.5: Mobile-Toggle-Pattern statt Bottom-Panel
Drei Probleme mit v0.4 Mobile-Layout:
1. 140px Bottom-Panel frisst vertikalen Platz auf Phone
2. User muss zur 3D-Graph scrollen, die nicht interaktiv nutzbar ist
3. Resize zwischen Mobile/Tablet/Desktop an fixen Breakpoints ist holprig

Fix v0.5: Graph wird als Full-Screen-Modal über Toggle-Button gezeigt.

Neues Layout-System:
- Desktop >=1025px: obv default 3-panel, keine Toggles sichtbar
- Compact 601-1024px: 2-panel (Tree + Content), Graph als Toggle-Modal,
  Toggle-Buttons für Tree UND Graph sichtbar im Header
- Mobile <=600px: 1-panel (nur Content), Tree als Hamburger (auto-hide
  nach File-Selection), Graph als Toggle-Modal

Plus Mobile-Overflow-Fix:
- word-wrap + overflow-wrap auf Markdown-Content
- pre/code: white-space: pre-wrap + word-break: break-word
- table: display:block + overflow-x: auto
- img: max-width: 100%

Graph-Modal:
- Full-Screen overlay mit Backdrop
- Title-Bar + Close-Button (×)
- Three.js re-rendered in modal-canvas (für isolierte Performance)
- Click-to-Navigate vom Modal zurück in die Page
- ESC-Taste schließt Modal

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 18:49:50 +00:00

454 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* HermesCustom — Lukas' additions to obsidian-web-viewer
*
* Loaded by vault.html via <script> tag (one-line patch).
*
* v0.5 — Mobile/Responsive overhaul:
* - Graph is NEVER a permanent bottom panel (waste of vertical space on phones)
* - Graph shown via full-screen modal toggle on Mobile + Tablet
* - Tree as hamburger on Mobile, full sidebar on Tablet
* - Desktop unchanged
*
* Companion: hermes-custom.css
*
* Layout decisions per viewport:
* Desktop (>=1025px): 3-panel, obv default, both toggles hidden
* Compact (601-1024px): 2-panel (tree+content), graph hidden by default,
* "Show Graph" button visible, "Tree" toggle visible
* Mobile (<=600px): 1-panel (content only), tree hamburger, graph modal
*/
(function() {
'use strict';
window.HermesCustom = window.HermesCustom || {};
const HermesCustom = window.HermesCustom;
// ============================================================
// Config
// ============================================================
const BREAKPOINTS = {
mobile: 600,
compact: 1024,
};
// ============================================================
// 1. UI elements (injected into header)
// ============================================================
/**
* Inject toggle buttons into header.
* - Tree toggle: only visible when tree is hidden (Compact mode hides tree by default)
* - Graph toggle: only visible when graph is hidden (Compact + Mobile)
* - Search input is already in obv's header; we don't duplicate
*/
HermesCustom.injectUI = function() {
const header = document.querySelector('.vault-header');
if (!header) {
setTimeout(HermesCustom.injectUI, 100);
return;
}
// Graph toggle button (top-right of header, before search)
if (!document.querySelector('.hermes-graph-toggle')) {
const graphBtn = document.createElement('button');
graphBtn.className = 'hermes-graph-toggle hermes-ui-btn';
graphBtn.setAttribute('aria-label', 'Toggle 3D graph');
graphBtn.innerHTML = '⊕ Graph';
graphBtn.onclick = () => HermesCustom.toggleGraphModal();
header.insertBefore(graphBtn, header.querySelector('.search-box') || header.lastChild);
}
// Tree toggle button (left-most in header)
if (!document.querySelector('.hermes-tree-toggle')) {
const treeBtn = document.createElement('button');
treeBtn.className = 'hermes-tree-toggle hermes-ui-btn';
treeBtn.setAttribute('aria-label', 'Toggle file tree');
treeBtn.innerHTML = '☰';
treeBtn.onclick = () => HermesCustom.toggleTreeSidebar();
header.insertBefore(treeBtn, header.firstChild);
}
// Backdrop for tree sidebar
if (!document.querySelector('.hermes-sidebar-backdrop')) {
const bd = document.createElement('div');
bd.className = 'hermes-sidebar-backdrop';
bd.onclick = () => HermesCustom.closeTreeSidebar();
document.body.appendChild(bd);
}
// Graph modal (hidden by default)
if (!document.querySelector('.hermes-graph-modal')) {
HermesCustom.buildGraphModal();
}
};
/**
* Build the graph modal: full-screen overlay that re-uses the
* existing canvas from obv. Strategy: clone the canvas into our modal,
* or move the existing canvas in/out. Simpler approach: create a NEW
* canvas inside the modal, and re-run the graph render there.
*/
HermesCustom.buildGraphModal = function() {
const modal = document.createElement('div');
modal.className = 'hermes-graph-modal';
const modalHeader = document.createElement('div');
modalHeader.className = 'hermes-graph-modal-header';
modalHeader.innerHTML = `
<span class="hermes-graph-modal-title">GRAPH VIEW · Tap a node to navigate</span>
<button class="hermes-graph-modal-close">× Close</button>
`;
modal.appendChild(modalHeader);
const canvasContainer = document.createElement('div');
canvasContainer.className = 'hermes-graph-modal-canvas';
canvasContainer.id = 'hermes-graph-modal-canvas';
modal.appendChild(canvasContainer);
document.body.appendChild(modal);
modal.querySelector('.hermes-graph-modal-close').onclick = () => {
HermesCustom.closeGraphModal();
};
// ESC to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('hermes-modal-open')) {
HermesCustom.closeGraphModal();
}
});
};
// ============================================================
// 2. Graph modal open/close
// ============================================================
HermesCustom.toggleGraphModal = function() {
const modal = document.querySelector('.hermes-graph-modal');
if (!modal) return;
if (modal.classList.contains('hermes-modal-open')) {
HermesCustom.closeGraphModal();
} else {
HermesCustom.openGraphModal();
}
};
HermesCustom.openGraphModal = function() {
const modal = document.querySelector('.hermes-graph-modal');
const canvasContainer = document.getElementById('hermes-graph-modal-canvas');
if (!modal || !canvasContainer) return;
// Move the existing obv canvas into our modal
const existingCanvas = document.querySelector('.vault-graph canvas');
if (existingCanvas && existingCanvas.parentElement !== canvasContainer) {
// Clone the canvas instead of moving it (keeps obv's state intact)
canvasContainer.innerHTML = '';
const clonedCanvas = document.createElement('canvas');
clonedCanvas.width = window.innerWidth;
clonedCanvas.height = window.innerHeight - 48 - 44; // viewport minus header+modal-header
canvasContainer.appendChild(clonedCanvas);
// Re-render the graph into our cloned canvas by re-running loadGraph logic
HermesCustom.renderGraphIntoCanvas(clonedCanvas);
}
modal.classList.add('hermes-modal-open');
document.body.style.overflow = 'hidden'; // prevent background scroll
console.log('[HermesCustom] Graph modal opened');
};
HermesCustom.closeGraphModal = function() {
const modal = document.querySelector('.hermes-graph-modal');
if (!modal) return;
modal.classList.remove('hermes-modal-open');
document.body.style.overflow = '';
console.log('[HermesCustom] Graph modal closed');
};
/**
* Re-render obv's graph into a different canvas.
* This is a hack: we re-fetch graph data and re-create a Three.js scene
* because obv's `loadGraph()` creates the scene with a specific canvas.
*
* Note: this duplicates Three.js scene. For mobile-only graph viewing,
* this is acceptable (small perf cost, but isolated to user action).
*/
HermesCustom.renderGraphIntoCanvas = function(canvas) {
if (!window.THREE || !HermesCustom._graphData) {
console.warn('[HermesCustom] Three.js or graph data not ready');
return;
}
const ctx = {
scene: new THREE.Scene(),
camera: null,
renderer: null,
nodeObjects: new Map(),
};
const w = canvas.clientWidth || canvas.width;
const h = canvas.clientHeight || canvas.height;
ctx.camera = new THREE.PerspectiveCamera(60, w / h, 1, 5000);
ctx.camera.position.set(0, 0, 800);
ctx.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
ctx.renderer.setSize(w, h);
ctx.renderer.setPixelRatio(window.devicePixelRatio || 1);
// Build nodes
const nodes = HermesCustom._graphData.nodes;
const nodeGeometry = new THREE.SphereGeometry(4, 16, 16);
const edges = HermesCustom._graphData.edges;
nodes.forEach(node => {
const mat = new THREE.MeshBasicMaterial({ color: 0x6c7086 });
const mesh = new THREE.Mesh(nodeGeometry, mat);
mesh.userData.nodeId = node.id;
// Random-ish positions (deterministic based on label hash)
const hash = node.label.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
mesh.position.set(
(hash % 100) * 6 - 300,
((hash * 7) % 100) * 4 - 200,
((hash * 13) % 100) * 4 - 200
);
ctx.scene.add(mesh);
ctx.nodeObjects.set(node.id, mesh);
});
// Build edges
edges.forEach(edge => {
const src = ctx.nodeObjects.get(edge.source);
const tgt = ctx.nodeObjects.get(edge.target);
if (!src || !tgt) return;
const lineGeo = new THREE.BufferGeometry().setFromPoints([
src.position, tgt.position
]);
const line = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({
color: 0x45475a, transparent: true, opacity: 0.6
}));
ctx.scene.add(line);
});
// Slow rotation
function animate() {
requestAnimationFrame(animate);
ctx.scene.rotation.y += 0.001;
ctx.renderer.render(ctx.scene, ctx.camera);
}
animate();
// Click handler — re-use the same navigation logic
HermesCustom.setupClickForCanvas(canvas, ctx.camera, ctx.nodeObjects);
};
HermesCustom.setupClickForCanvas = function(canvas, camera, nodeMap) {
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
canvas.addEventListener('click', (event) => {
const rect = canvas.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(Array.from(nodeMap.values()));
if (intersects.length > 0) {
const nodeId = intersects[0].object.userData.nodeId;
if (typeof window.loadFile === 'function') {
HermesCustom.closeGraphModal();
window.loadFile(nodeId);
window.history.pushState({}, '', '?file=' + encodeURIComponent(nodeId));
}
}
});
};
// ============================================================
// 3. Tree sidebar toggle
// ============================================================
HermesCustom.toggleTreeSidebar = function() {
const sidebar = document.querySelector('.vault-sidebar');
const backdrop = document.querySelector('.hermes-sidebar-backdrop');
if (!sidebar) return;
const opening = !sidebar.classList.contains('hermes-sidebar-open');
sidebar.classList.toggle('hermes-sidebar-open', opening);
if (backdrop) backdrop.classList.toggle('hermes-backdrop-visible', opening);
};
HermesCustom.closeTreeSidebar = function() {
const sidebar = document.querySelector('.vault-sidebar');
const backdrop = document.querySelector('.hermes-sidebar-backdrop');
if (sidebar) sidebar.classList.remove('hermes-sidebar-open');
if (backdrop) backdrop.classList.remove('hermes-backdrop-visible');
};
// ============================================================
// 4. Layout decision
// ============================================================
HermesCustom.applyLayout = function() {
const app = document.querySelector('.vault-app');
if (!app) {
setTimeout(HermesCustom.applyLayout, 100);
return;
}
// Marker class
if (!app.classList.contains('hermes-rc')) {
app.classList.add('hermes-rc');
}
const width = window.innerWidth;
app.classList.remove('hermes-mobile', 'hermes-compact', 'hermes-desktop');
if (width <= BREAKPOINTS.mobile) {
app.classList.add('hermes-mobile');
} else if (width <= BREAKPOINTS.compact) {
app.classList.add('hermes-compact');
} else {
app.classList.add('hermes-desktop');
}
// Auto-close sidebar on resize to desktop
if (width >= BREAKPOINTS.compact) {
HermesCustom.closeTreeSidebar();
}
};
HermesCustom.setupResizeHandler = function() {
let resizeTimer;
let lastWidth = window.innerWidth;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (window.innerWidth !== lastWidth) {
lastWidth = window.innerWidth;
HermesCustom.applyLayout();
}
}, 150);
});
};
// ============================================================
// 5. Current-page highlighting (desktop)
// ============================================================
HermesCustom.getCurrentFile = function() {
const params = new URLSearchParams(window.location.search);
const fileParam = params.get('file');
if (fileParam) return fileParam;
return HermesCustom._lastLoadedFile || null;
};
HermesCustom.getConnectedFiles = function(currentFile) {
if (!currentFile) return [];
const graphData = HermesCustom._graphData;
if (!graphData) return [];
return graphData.edges.filter(e => e.source === currentFile).map(e => e.target);
};
HermesCustom.highlightCurrentNode = function() {
const currentFile = HermesCustom.getCurrentFile();
if (!currentFile) return;
if (typeof window.graphNodeObjects === 'undefined' || !window.graphNodeObjects) {
setTimeout(HermesCustom.highlightCurrentNode, 500);
return;
}
const connected = new Set(HermesCustom.getConnectedFiles(currentFile));
connected.add(currentFile);
window.graphNodeObjects.forEach((mesh, nodeId) => {
const isCurrent = (nodeId === currentFile);
const isConnected = connected.has(nodeId) && !isCurrent;
if (isCurrent) {
mesh.material.color.setHex(0xFFD700);
mesh.material.emissive.setHex(0xFFD700);
mesh.material.emissiveIntensity = 0.6;
mesh.material.opacity = 1.0;
mesh.scale.set(1.5, 1.5, 1.5);
} else if (isConnected) {
mesh.material.color.setHex(0x89b4fa);
mesh.material.emissive.setHex(0x89b4fa);
mesh.material.emissiveIntensity = 0.3;
mesh.material.opacity = 1.0;
mesh.scale.set(1.1, 1.1, 1.1);
} else {
mesh.material.color.setHex(0x6c7086);
mesh.material.emissiveIntensity = 0;
mesh.material.opacity = 0.25;
mesh.material.transparent = true;
}
});
};
// ============================================================
// 6. URL navigation
// ============================================================
HermesCustom.updateURL = function(path) {
const newURL = '?file=' + encodeURIComponent(path);
window.history.pushState({}, '', newURL);
};
// ============================================================
// 7. loadFile hook
// ============================================================
HermesCustom.hookLoadFile = function() {
if (typeof window.loadFile !== 'function') {
setTimeout(HermesCustom.hookLoadFile, 200);
return;
}
if (window.loadFile._hermesPatched) return;
const originalLoadFile = window.loadFile;
window.loadFile = function(path) {
HermesCustom._lastLoadedFile = path;
HermesCustom.updateURL(path);
// Close tree sidebar after file selection (mobile UX)
if (window.innerWidth <= BREAKPOINTS.mobile) {
HermesCustom.closeTreeSidebar();
}
const result = originalLoadFile.apply(this, arguments);
setTimeout(HermesCustom.highlightCurrentNode, 300);
return result;
};
window.loadFile._hermesPatched = true;
};
// ============================================================
// 8. Graph data fetch
// ============================================================
HermesCustom.loadGraphData = function() {
fetch('/api/vault/graph')
.then(r => r.json())
.then(data => {
HermesCustom._graphData = data;
console.log('[HermesCustom] Graph data: ' + data.nodes.length + ' nodes, ' + data.edges.length + ' edges');
HermesCustom.highlightCurrentNode();
})
.catch(err => console.warn('[HermesCustom] Graph fetch failed:', err));
};
// ============================================================
// Init
// ============================================================
HermesCustom.init = function() {
console.log('[HermesCustom] init v5 (mobile-toggle-pattern)');
HermesCustom.injectUI();
HermesCustom.hookLoadFile();
HermesCustom.applyLayout();
HermesCustom.setupResizeHandler();
HermesCustom.loadGraphData();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', HermesCustom.init);
} else {
setTimeout(HermesCustom.init, 50);
}
})();