Drei Probleme, drei Fixes:
1. Touch-Drag auf Graph geht nicht:
obv's mousedown/mousemove/mouseup sind Mausevents only.
Fix: zusätzlich touchstart/touchmove/touchend/touchcancel
+ getPos() Helper der touches[0] oder clientX nutzt
+ preventDefault() auf Drag (sonst versucht iOS zu scrollen)
2. Suche und Content-Scroll blockiert:
obv's animateGraph() läuft mit requestAnimationFrame endlos
und blockt den Main-Thread auf iOS. Plus iOS-300ms-Tap-Delay
auf inline onclick=.
Fix:
- document.hidden check (skip rendering wenn Tab im Hintergrund)
- autoRotate pausiert 3s nach User-Interaktion
- html, body { touch-action: manipulation } (entfernt 300ms delay)
- -webkit-tap-highlight-color: transparent (entfernt blauen Flash)
- .vault-content, .vault-sidebar { touch-action: pan-y, -webkit-overflow-scrolling: touch }
- .vault-graph canvas { touch-action: none } (damit Browser-Default Three.js-Drag nicht blockt)
Nicht angefasst: obv's Inline-onclick-Handler (Suche-Eingabe, File-Click,
WikiLinks) — diese funktionieren sobald der 300ms-Tap-Delay weg ist
und der Main-Thread nicht mehr blockt.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
399 lines
15 KiB
HTML
399 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Vault Viewer</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0a0a14; --bg-secondary: #0e0e1a; --bg-surface: #1a1a2e;
|
|
--text-primary: #cdd6f4; --text-secondary: #a6adc8; --text-muted: #6c7086;
|
|
--accent: #FFD700; --link: #FFD700; --border: #313244;
|
|
--green: #a6e3a1; --red: #f38ba8; --blue: #89b4fa; --teal: #94e2d5;
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
|
html, body { touch-action: manipulation; }
|
|
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, 'Segoe UI', Inter, sans-serif; height: 100vh; overflow: hidden; }
|
|
|
|
.vault-app {
|
|
display: grid; grid-template-columns: 280px 1fr 260px;
|
|
grid-template-rows: 48px 1fr; height: 100vh;
|
|
}
|
|
.vault-app.graph-hidden { grid-template-columns: 280px 1fr; }
|
|
|
|
/* Header */
|
|
.vault-header {
|
|
grid-column: 1 / -1; background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border); display: flex; align-items: center;
|
|
padding: 0 16px; gap: 12px;
|
|
}
|
|
.vault-header h1 { color: var(--accent); font-size: 14px; font-weight: 700; letter-spacing: 1px; }
|
|
.vault-header .search-box {
|
|
flex: 1; max-width: 400px; position: relative;
|
|
}
|
|
.vault-header input {
|
|
width: 100%; padding: 6px 12px 6px 32px; background: rgba(30,30,50,0.6);
|
|
border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);
|
|
font-size: 13px; outline: none;
|
|
}
|
|
.vault-header input:focus { border-color: var(--accent); }
|
|
.vault-header .toggle-btn {
|
|
background: none; border: 1px solid var(--border); color: var(--text-muted);
|
|
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
|
|
}
|
|
.vault-header .toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
/* Sidebar file tree */
|
|
.vault-sidebar {
|
|
background: var(--bg-secondary); border-right: 1px solid var(--border);
|
|
overflow-y: auto; -webkit-overflow-scrolling: touch;
|
|
touch-action: pan-y; padding: 8px 0; font-size: 13px;
|
|
}
|
|
.tree-folder { cursor: pointer; user-select: none; }
|
|
.tree-folder-header {
|
|
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
|
|
color: var(--text-secondary); border-radius: 4px; margin: 1px 4px;
|
|
}
|
|
.tree-folder-header:hover { background: rgba(255,215,0,0.05); }
|
|
.tree-folder-header .arrow { color: var(--text-muted); font-size: 10px; width: 12px; }
|
|
.tree-children { padding-left: 12px; display: none; }
|
|
.tree-children.open { display: block; }
|
|
.tree-file {
|
|
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
|
|
color: var(--text-primary); cursor: pointer; border-radius: 4px; margin: 1px 4px;
|
|
font-size: 13px;
|
|
}
|
|
.tree-file:hover { background: rgba(255,215,0,0.05); }
|
|
.tree-file.active { background: rgba(255,215,0,0.1); color: var(--accent); }
|
|
|
|
/* Content area */
|
|
.vault-content {
|
|
overflow-y: auto; -webkit-overflow-scrolling: touch;
|
|
touch-action: pan-y; padding: 32px 48px; max-width: 900px;
|
|
line-height: 1.7; font-size: 15px;
|
|
}
|
|
.vault-content h1 { color: var(--accent); font-size: 28px; margin: 24px 0 12px; }
|
|
.vault-content h2 { color: var(--accent); font-size: 22px; margin: 20px 0 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
|
|
.vault-content h3 { color: var(--text-primary); font-size: 18px; margin: 16px 0 8px; }
|
|
.vault-content p { margin: 8px 0; }
|
|
.vault-content a { color: var(--link); text-decoration: none; }
|
|
.vault-content a:hover { text-decoration: underline; }
|
|
.vault-content code { background: var(--bg-surface); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: var(--teal); }
|
|
.vault-content pre { background: var(--bg-surface); padding: 16px; border-radius: 8px; overflow-x: auto; margin: 12px 0; }
|
|
.vault-content pre code { padding: 0; background: none; }
|
|
.vault-content blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--text-secondary); margin: 12px 0; }
|
|
.vault-content ul, .vault-content ol { padding-left: 24px; }
|
|
.vault-content li { margin: 4px 0; }
|
|
.vault-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
|
.vault-content th, .vault-content td { border: 1px solid var(--border); padding: 8px 12px; text-align: left; }
|
|
.vault-content th { background: var(--bg-surface); color: var(--accent); font-size: 13px; }
|
|
.vault-content img { max-width: 100%; border-radius: 8px; }
|
|
.frontmatter { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; font-size: 12px; }
|
|
.frontmatter .fm-key { color: var(--text-muted); }
|
|
.frontmatter .fm-val { color: var(--text-primary); margin-left: 8px; }
|
|
|
|
/* Graph panel */
|
|
.vault-graph {
|
|
background: var(--bg-secondary); border-left: 1px solid var(--border);
|
|
position: relative; overflow: hidden; touch-action: none;
|
|
}
|
|
.vault-graph canvas { width: 100% !important; height: 100% !important; touch-action: none; }
|
|
|
|
/* Search results overlay */
|
|
#search-results {
|
|
position: absolute; top: 48px; left: 50%; transform: translateX(-50%);
|
|
width: 420px; max-height: 400px; overflow-y: auto;
|
|
background: rgba(14,14,26,0.95); border: 1px solid var(--border);
|
|
border-radius: 8px; z-index: 100; display: none;
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
.search-item {
|
|
padding: 8px 14px; cursor: pointer; border-bottom: 1px solid rgba(49,50,68,0.5);
|
|
}
|
|
.search-item:hover { background: rgba(255,215,0,0.05); }
|
|
.search-item .name { color: var(--accent); font-weight: 600; }
|
|
.search-item .folder { color: var(--text-muted); font-size: 11px; margin-left: 8px; }
|
|
.search-item .snippet { color: var(--text-secondary); font-size: 12px; margin-top: 2px; }
|
|
|
|
/* Welcome screen */
|
|
.welcome {
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
height: 100%; color: var(--text-muted); text-align: center;
|
|
}
|
|
.welcome h2 { color: var(--accent); margin-bottom: 8px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="vault-app" id="app">
|
|
<div class="vault-header">
|
|
<h1>Vault</h1>
|
|
<div class="search-box">
|
|
<input type="text" id="search-input" placeholder="Search notes..." oninput="onSearch(this.value)" onfocus="showSearch()" onblur="setTimeout(hideSearch, 200)">
|
|
</div>
|
|
<button class="toggle-btn" onclick="toggleGraph()">Graph</button>
|
|
</div>
|
|
|
|
<div class="vault-sidebar" id="sidebar"></div>
|
|
|
|
<div class="vault-content" id="content">
|
|
<div class="welcome">
|
|
<h2>Welcome to Vault Viewer</h2>
|
|
<p>Select a note from the file tree or use search.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="vault-graph" id="graph-panel">
|
|
<canvas id="graph-canvas"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="search-results"></div>
|
|
|
|
<script>
|
|
let currentFile = null;
|
|
let graphVisible = true;
|
|
let graphData = null;
|
|
|
|
// Load file tree
|
|
async function loadTree() {
|
|
const resp = await fetch('/api/vault/tree');
|
|
const tree = await resp.json();
|
|
document.getElementById('sidebar').innerHTML = renderTree(tree, '');
|
|
}
|
|
|
|
function renderTree(node, path) {
|
|
if (node.type === 'file') {
|
|
const fullPath = path ? path + '/' + node.name : node.name;
|
|
return `<div class="tree-file" onclick="loadFile('${fullPath.replace(/'/g, "\\'")}')" data-path="${fullPath}">${node.name.replace('.md', '')}</div>`;
|
|
}
|
|
const fullPath = path ? path + '/' + node.name : node.name;
|
|
let html = '';
|
|
if (node.name) {
|
|
html += `<div class="tree-folder">
|
|
<div class="tree-folder-header" onclick="this.nextElementSibling.classList.toggle('open');this.querySelector('.arrow').textContent=this.nextElementSibling.classList.contains('open')?'v':'>'">
|
|
<span class="arrow">></span> ${node.name}
|
|
</div>
|
|
<div class="tree-children">`;
|
|
}
|
|
(node.children || []).forEach(c => { html += renderTree(c, fullPath); });
|
|
if (node.name) html += '</div></div>';
|
|
return html;
|
|
}
|
|
|
|
// Load file content
|
|
async function loadFile(path) {
|
|
// Highlight active
|
|
document.querySelectorAll('.tree-file').forEach(el => el.classList.remove('active'));
|
|
const activeEl = document.querySelector(`.tree-file[data-path="${path}"]`);
|
|
if (activeEl) activeEl.classList.add('active');
|
|
|
|
const resp = await fetch('/api/vault/file/' + encodeURIComponent(path));
|
|
const data = await resp.json();
|
|
if (data.error) {
|
|
document.getElementById('content').innerHTML = `<p style="color:var(--red)">${data.error}</p>`;
|
|
return;
|
|
}
|
|
|
|
currentFile = path;
|
|
let html = '';
|
|
|
|
// Frontmatter
|
|
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
|
html += '<div class="frontmatter">';
|
|
Object.entries(data.frontmatter).forEach(([k, v]) => {
|
|
const val = Array.isArray(v) ? v.join(', ') : String(v);
|
|
html += `<div><span class="fm-key">${k}:</span><span class="fm-val">${val}</span></div>`;
|
|
});
|
|
html += '</div>';
|
|
}
|
|
|
|
// Markdown body — convert wikilinks first
|
|
let body = data.body || '';
|
|
body = body.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (match, target, alias) => {
|
|
const display = alias || target;
|
|
return `<a href="#" onclick="navigateWikilink('${target.replace(/'/g, "\\'")}');return false">${display}</a>`;
|
|
});
|
|
|
|
html += marked.parse(body);
|
|
document.getElementById('content').innerHTML = html;
|
|
document.getElementById('content').scrollTop = 0;
|
|
}
|
|
|
|
// Wikilink navigation
|
|
async function navigateWikilink(target) {
|
|
// Search for the target file
|
|
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(target));
|
|
const data = await resp.json();
|
|
if (data.results && data.results.length > 0) {
|
|
// Find exact name match first
|
|
const exact = data.results.find(r => r.name.toLowerCase() === target.toLowerCase());
|
|
loadFile((exact || data.results[0]).path);
|
|
}
|
|
}
|
|
|
|
// Search
|
|
let searchTimeout = null;
|
|
function onSearch(query) {
|
|
clearTimeout(searchTimeout);
|
|
if (!query.trim()) { hideSearch(); return; }
|
|
searchTimeout = setTimeout(async () => {
|
|
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(query));
|
|
const data = await resp.json();
|
|
const el = document.getElementById('search-results');
|
|
if (!data.results || data.results.length === 0) {
|
|
el.innerHTML = '<div class="search-item"><span style="color:var(--text-muted)">No results</span></div>';
|
|
} else {
|
|
el.innerHTML = data.results.map(r =>
|
|
`<div class="search-item" onmousedown="loadFile('${r.path.replace(/'/g, "\\'")}');hideSearch()">
|
|
<div><span class="name">${r.name}</span><span class="folder">${r.folder}</span></div>
|
|
${r.snippet ? '<div class="snippet">' + r.snippet + '</div>' : ''}
|
|
</div>`
|
|
).join('');
|
|
}
|
|
el.style.display = 'block';
|
|
}, 200);
|
|
}
|
|
function showSearch() { if (document.getElementById('search-input').value) onSearch(document.getElementById('search-input').value); }
|
|
function hideSearch() { document.getElementById('search-results').style.display = 'none'; }
|
|
|
|
// Graph toggle
|
|
function toggleGraph() {
|
|
graphVisible = !graphVisible;
|
|
document.getElementById('app').classList.toggle('graph-hidden', !graphVisible);
|
|
if (graphVisible && !graphData) loadGraph();
|
|
}
|
|
|
|
// 3D Graph
|
|
let graphScene, graphCamera, graphRenderer;
|
|
async function loadGraph() {
|
|
const resp = await fetch('/api/vault/graph');
|
|
graphData = await resp.json();
|
|
|
|
const container = document.getElementById('graph-panel');
|
|
const canvas = document.getElementById('graph-canvas');
|
|
const W = container.clientWidth;
|
|
const H = container.clientHeight;
|
|
|
|
graphScene = new THREE.Scene();
|
|
graphScene.background = new THREE.Color(0x0e0e1a);
|
|
|
|
graphCamera = new THREE.PerspectiveCamera(60, W / H, 1, 2000);
|
|
graphCamera.position.set(0, 0, 300);
|
|
|
|
graphRenderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
graphRenderer.setSize(W, H);
|
|
|
|
// Position nodes in a 3D force layout (simplified)
|
|
const nodePositions = {};
|
|
const nodes = graphData.nodes || [];
|
|
nodes.forEach((n, i) => {
|
|
const angle = (i / nodes.length) * Math.PI * 2;
|
|
const radius = 80 + Math.random() * 120;
|
|
nodePositions[n.id] = {
|
|
x: Math.cos(angle) * radius + (Math.random() - 0.5) * 40,
|
|
y: (Math.random() - 0.5) * 100,
|
|
z: Math.sin(angle) * radius + (Math.random() - 0.5) * 40,
|
|
};
|
|
});
|
|
|
|
// Node dots
|
|
const colors = { wiki: 0x89b4fa, daily: 0xf9e2af, journal: 0xa6e3a1, projects: 0xf5c2e7, other: 0x6c7086 };
|
|
nodes.forEach(n => {
|
|
const pos = nodePositions[n.id];
|
|
const color = colors[n.type] || colors.other;
|
|
const geo = new THREE.SphereGeometry(1.5, 8, 8);
|
|
const mat = new THREE.MeshBasicMaterial({ color });
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.position.set(pos.x, pos.y, pos.z);
|
|
graphScene.add(mesh);
|
|
});
|
|
|
|
// Edges
|
|
(graphData.edges || []).forEach(e => {
|
|
const s = nodePositions[e.source];
|
|
const t = nodePositions[e.target];
|
|
if (!s || !t) return;
|
|
const geo = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(s.x, s.y, s.z),
|
|
new THREE.Vector3(t.x, t.y, t.z),
|
|
]);
|
|
const mat = new THREE.LineBasicMaterial({ color: 0x313244, transparent: true, opacity: 0.3 });
|
|
graphScene.add(new THREE.Line(geo, mat));
|
|
});
|
|
|
|
// Orbit (mouse + touch support)
|
|
let isDrag = false, lastX = 0, lastY = 0, rotX = 0, rotY = 0;
|
|
function getPos(e) {
|
|
if (e.touches && e.touches.length) {
|
|
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
}
|
|
return { x: e.clientX, y: e.clientY };
|
|
}
|
|
function onDragStart(e) {
|
|
e.preventDefault();
|
|
isDrag = true;
|
|
const p = getPos(e);
|
|
lastX = p.x; lastY = p.y;
|
|
}
|
|
function onDragMove(e) {
|
|
if (!isDrag) return;
|
|
e.preventDefault();
|
|
const p = getPos(e);
|
|
rotY += (p.x - lastX) * 0.005;
|
|
rotX += (p.y - lastY) * 0.005;
|
|
lastX = p.x; lastY = p.y;
|
|
}
|
|
function onDragEnd(e) {
|
|
if (e && e.preventDefault) e.preventDefault();
|
|
isDrag = false;
|
|
}
|
|
function onWheel(e) {
|
|
graphCamera.position.z += e.deltaY * 0.5;
|
|
graphCamera.position.z = Math.max(50, Math.min(800, graphCamera.position.z));
|
|
}
|
|
canvas.addEventListener('mousedown', onDragStart);
|
|
canvas.addEventListener('mousemove', onDragMove);
|
|
window.addEventListener('mouseup', onDragEnd);
|
|
canvas.addEventListener('touchstart', onDragStart, { passive: false });
|
|
canvas.addEventListener('touchmove', onDragMove, { passive: false });
|
|
canvas.addEventListener('touchend', onDragEnd, { passive: false });
|
|
canvas.addEventListener('touchcancel', onDragEnd, { passive: false });
|
|
canvas.addEventListener('wheel', onWheel, { passive: true });
|
|
|
|
// Auto-rotate: when user is not interacting, slow rotation.
|
|
// Pauses when tab is hidden, when user is dragging, and after page visibility loss.
|
|
let autoRotate = true;
|
|
let lastInteractionTime = 0;
|
|
const AUTO_ROTATE_RESUME_MS = 3000; // resume auto-rotation 3s after user stops dragging
|
|
|
|
function animateGraph() {
|
|
requestAnimationFrame(animateGraph);
|
|
if (document.hidden) return; // skip rendering when tab is in background
|
|
const now = Date.now();
|
|
const userJustInteracted = (now - lastInteractionTime) < AUTO_ROTATE_RESUME_MS;
|
|
if (isDrag) {
|
|
lastInteractionTime = now;
|
|
autoRotate = false;
|
|
} else if (!userJustInteracted) {
|
|
autoRotate = true;
|
|
}
|
|
if (autoRotate && !isDrag) {
|
|
rotY += 0.002;
|
|
}
|
|
graphScene.rotation.y = rotY;
|
|
graphScene.rotation.x = rotX;
|
|
graphRenderer.render(graphScene, graphCamera);
|
|
}
|
|
animateGraph();
|
|
}
|
|
|
|
// Init
|
|
loadTree();
|
|
loadGraph();
|
|
</script>
|
|
</body>
|
|
</html>
|