Diagnose: Mobile Graph sichtbar aber nicht bedienbar. Root cause: iOS Safari interpretiert Single-Finger-Touch als Scroll-Geste statt als Drag, weil touch-action: none fehlt. Fixes: 1. CSS: touch-action: none auf .graph-modal-canvas, canvas und .graph-modal (verhindert iOS-Safari Scroll-Hijacking + Rubber-Band-Effect) 2. JS: Touch-Event-Fallbacks (touchstart/move/end/cancel) parallel zu pointer-events (für ältere iOS-Safari-Versionen die pointer-events noch nicht voll unterstützen) 3. JS: Pinch-to-Zoom via Zwei-Finger-Gestenerkennung 4. JS: Initial-Render mit Resize-Retry bis Canvas echte Dimensionen hat (Modal-Container kann initial 0x0 sein bevor Flex-Layout settled) 5. JS: ResizeObserver auf Canvas re-rendert bei Größenänderung 6. CSS: overscroll-behavior: contain auf Modal (kein Bounce) 7. CSS: -webkit-user-select: none + tap-highlight: transparent (verhindert iOS-Safari Selektions-Overlay beim Drag) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
754 lines
25 KiB
JavaScript
754 lines
25 KiB
JavaScript
/* Hermes Wiki — Touch-First JS for Phase 2
|
|
*
|
|
* Features:
|
|
* - Tree render with collapsible folders
|
|
* - Search with live results dropdown (title + tags)
|
|
* - 3D Graph in modal (Three.js, NO auto-rotation, click-to-navigate)
|
|
* - Mobile bottom-nav (Files, Search, Graph)
|
|
* - Hamburger tree + FAB graph on mobile
|
|
* - Backdrop click closes overlays
|
|
* - Backlinks rendering
|
|
* - Hash-based navigation (forward/back via pushState)
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
const data = window.HERMES_DATA;
|
|
if (!data) {
|
|
console.error('[Hermes] HERMES_DATA missing');
|
|
return;
|
|
}
|
|
|
|
const currentSlug = window.location.pathname
|
|
.replace(/^\//, '')
|
|
.replace(/\.html$/, '');
|
|
|
|
// ============================================================
|
|
// Tree render
|
|
// ============================================================
|
|
|
|
function renderTree(node, container, basePath) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'tree-root';
|
|
|
|
if (node.folders && node.folders.length > 0) {
|
|
for (const folder of node.folders) {
|
|
const fEl = document.createElement('div');
|
|
fEl.className = 'tree-folder';
|
|
if (folder.name === 'concepts' || folder.name === 'entities') {
|
|
fEl.classList.add('open'); // auto-open most-used folders
|
|
}
|
|
const header = document.createElement('div');
|
|
header.className = 'tree-folder-header';
|
|
header.innerHTML = `<span class="arrow">▶</span><span>${escapeHtml(folder.name)}</span>`;
|
|
header.onclick = () => fEl.classList.toggle('open');
|
|
fEl.appendChild(header);
|
|
const children = document.createElement('div');
|
|
children.className = 'tree-children';
|
|
fEl.appendChild(children);
|
|
renderTree(folder, children, basePath + '/' + folder.name);
|
|
wrap.appendChild(fEl);
|
|
}
|
|
}
|
|
|
|
if (node.files && node.files.length > 0) {
|
|
for (const file of node.files) {
|
|
const a = document.createElement('a');
|
|
a.href = '/' + file.slug + '.html';
|
|
a.className = 'tree-file';
|
|
if (file.slug === currentSlug) a.classList.add('current');
|
|
a.innerHTML = `<span class="tree-file-icon">📄</span><span>${escapeHtml(file.title)}</span>`;
|
|
wrap.appendChild(a);
|
|
}
|
|
}
|
|
|
|
container.appendChild(wrap);
|
|
}
|
|
|
|
// ============================================================
|
|
// Search
|
|
// ============================================================
|
|
|
|
function setupSearch() {
|
|
const input = document.getElementById('search');
|
|
if (!input) return;
|
|
|
|
// Wrap with relative parent for absolute dropdown
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'search-wrap';
|
|
input.parentNode.insertBefore(wrapper, input);
|
|
wrapper.appendChild(input);
|
|
input.style.paddingLeft = '36px';
|
|
|
|
const dropdown = document.createElement('div');
|
|
dropdown.className = 'search-results';
|
|
wrapper.appendChild(dropdown);
|
|
|
|
let activeIdx = -1;
|
|
|
|
input.addEventListener('input', (e) => {
|
|
const q = e.target.value.toLowerCase().trim();
|
|
if (!q) {
|
|
dropdown.classList.remove('open');
|
|
return;
|
|
}
|
|
const results = data.pages.filter(p =>
|
|
(p.title || '').toLowerCase().includes(q) ||
|
|
(p.slug || '').toLowerCase().includes(q) ||
|
|
(p.tags || []).some(t => t.toLowerCase().includes(q))
|
|
).slice(0, 20);
|
|
|
|
dropdown.innerHTML = '';
|
|
if (results.length === 0) {
|
|
dropdown.innerHTML = '<div class="search-empty">Keine Treffer</div>';
|
|
} else {
|
|
for (let i = 0; i < results.length; i++) {
|
|
const r = results[i];
|
|
const a = document.createElement('a');
|
|
a.href = '/' + r.slug + '.html';
|
|
a.className = 'search-result' + (i === activeIdx ? ' active' : '');
|
|
const tagsHtml = (r.tags || []).slice(0, 3).map(t => `<span class="search-result-tag">${escapeHtml(t)}</span>`).join('');
|
|
a.innerHTML = `<span class="search-result-title">${escapeHtml(r.title)}</span><span class="search-result-path">${tagsHtml}${escapeHtml(r.slug)}</span>`;
|
|
a.onmouseenter = () => {
|
|
activeIdx = i;
|
|
updateActive();
|
|
};
|
|
dropdown.appendChild(a);
|
|
}
|
|
}
|
|
dropdown.classList.add('open');
|
|
|
|
function updateActive() {
|
|
[...dropdown.querySelectorAll('.search-result')].forEach((el, idx) => {
|
|
el.classList.toggle('active', idx === activeIdx);
|
|
});
|
|
}
|
|
});
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
const items = [...dropdown.querySelectorAll('.search-result')];
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
activeIdx = Math.min(activeIdx + 1, items.length - 1);
|
|
[...dropdown.querySelectorAll('.search-result')].forEach((el, idx) =>
|
|
el.classList.toggle('active', idx === activeIdx)
|
|
);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
activeIdx = Math.max(activeIdx - 1, 0);
|
|
[...dropdown.querySelectorAll('.search-result')].forEach((el, idx) =>
|
|
el.classList.toggle('active', idx === activeIdx)
|
|
);
|
|
} else if (e.key === 'Enter' && activeIdx >= 0 && items[activeIdx]) {
|
|
e.preventDefault();
|
|
items[activeIdx].click();
|
|
} else if (e.key === 'Escape') {
|
|
input.value = '';
|
|
dropdown.classList.remove('open');
|
|
}
|
|
});
|
|
|
|
// Close dropdown on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!wrapper.contains(e.target)) {
|
|
dropdown.classList.remove('open');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Graph (Three.js) — modal, NO auto-rotation, click-to-navigate
|
|
// ============================================================
|
|
|
|
let graphState = null;
|
|
|
|
function buildGraph(canvas, container) {
|
|
if (!window.THREE) {
|
|
console.error('[Hermes] THREE.js not loaded');
|
|
return;
|
|
}
|
|
const ctx = {
|
|
scene: new THREE.Scene(),
|
|
camera: null,
|
|
renderer: new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }),
|
|
nodes: new Map(),
|
|
edges: [],
|
|
currentSlug: currentSlug,
|
|
isDragging: false,
|
|
dragStart: { x: 0, y: 0 },
|
|
cameraPos: { x: 0, y: 0, z: 1200 },
|
|
cameraTarget: { x: 0, y: 0, z: 0 },
|
|
needsRender: true,
|
|
hoveredNode: null,
|
|
};
|
|
|
|
const w = canvas.clientWidth || canvas.width || 600;
|
|
const h = canvas.clientHeight || canvas.height || 400;
|
|
|
|
ctx.renderer.setSize(w, h, false);
|
|
ctx.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
ctx.renderer.setClearColor(0x0e0e1a, 1);
|
|
|
|
ctx.camera = new THREE.PerspectiveCamera(60, w / h, 1, 10000);
|
|
ctx.camera.position.set(0, 0, 1200);
|
|
ctx.camera.lookAt(0, 0, 0);
|
|
|
|
// Build nodes via deterministic layout (Fruchterman-Reingold style)
|
|
const graph = data.graph;
|
|
const slugs = graph.nodes.map(n => n.id);
|
|
const positions = computeLayout(slugs, graph.edges, 600);
|
|
|
|
// Group nodes by folder for visual variety
|
|
const folderColors = {};
|
|
let colorIdx = 0;
|
|
const palette = [0x89b4fa, 0xa6e3a1, 0xf9e2af, 0xf5c2e7, 0x94e2d5, 0xfab387, 0xcba6f7];
|
|
for (const slug of slugs) {
|
|
const folder = slug.split('/')[0] || 'root';
|
|
if (!(folder in folderColors)) {
|
|
folderColors[folder] = palette[colorIdx++ % palette.length];
|
|
}
|
|
}
|
|
|
|
// Current page edges for highlight
|
|
const currentEdges = new Set();
|
|
const currentReverse = new Set();
|
|
if (graph.edges) {
|
|
for (const e of graph.edges) {
|
|
if (e.source === currentSlug) {
|
|
currentEdges.add(e.target);
|
|
currentReverse.add(e.source);
|
|
}
|
|
if (e.target === currentSlug) {
|
|
currentEdges.add(e.source);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build meshes
|
|
const sphereGeo = new THREE.SphereGeometry(5, 12, 12);
|
|
for (const node of graph.nodes) {
|
|
const isCurrent = node.id === currentSlug;
|
|
const isConnected = currentEdges.has(node.id);
|
|
const folder = node.id.split('/')[0] || 'root';
|
|
const baseColor = folderColors[folder];
|
|
|
|
let color = baseColor;
|
|
let opacity = 0.5;
|
|
let size = 1;
|
|
if (isCurrent) {
|
|
color = 0xFFD700;
|
|
opacity = 1;
|
|
size = 2.2;
|
|
} else if (isConnected) {
|
|
color = 0xFFD700;
|
|
opacity = 0.95;
|
|
size = 1.5;
|
|
} else if (currentSlug) {
|
|
opacity = 0.25;
|
|
} else {
|
|
opacity = 0.6;
|
|
}
|
|
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity,
|
|
depthWrite: isCurrent || isConnected,
|
|
});
|
|
const mesh = new THREE.Mesh(sphereGeo, mat);
|
|
mesh.position.set(positions[node.id].x, positions[node.id].y, positions[node.id].z);
|
|
mesh.scale.setScalar(size);
|
|
mesh.userData = { nodeId: node.id, baseScale: size, baseOpacity: opacity, isCurrent, isConnected };
|
|
ctx.scene.add(mesh);
|
|
ctx.nodes.set(node.id, mesh);
|
|
}
|
|
|
|
// Build edges
|
|
const edgeMat = new THREE.LineBasicMaterial({
|
|
color: 0x6c7086,
|
|
transparent: true,
|
|
opacity: 0.35,
|
|
});
|
|
const currentEdgeMat = new THREE.LineBasicMaterial({
|
|
color: 0xFFD700,
|
|
transparent: true,
|
|
opacity: 0.85,
|
|
});
|
|
for (const e of graph.edges) {
|
|
const src = ctx.nodes.get(e.source);
|
|
const tgt = ctx.nodes.get(e.target);
|
|
if (!src || !tgt) continue;
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([src.position, tgt.position]);
|
|
const isCurrentEdge = (e.source === currentSlug || e.target === currentSlug);
|
|
const line = new THREE.Line(lineGeo, isCurrentEdge ? currentEdgeMat : edgeMat);
|
|
ctx.scene.add(line);
|
|
}
|
|
|
|
// Render only on demand (NO animation loop!)
|
|
function render() {
|
|
ctx.renderer.render(ctx.scene, ctx.camera);
|
|
}
|
|
|
|
// Manual rotation: drag to rotate (pointer + touch fallback for older iOS)
|
|
let isDragging = false;
|
|
let lastX = 0, lastY = 0;
|
|
let dragStartTime = 0;
|
|
let dragStartXY = { x: 0, y: 0 };
|
|
let moved = false;
|
|
|
|
function getXY(e, canvas) {
|
|
let clientX, clientY;
|
|
if (e.touches && e.touches.length > 0) {
|
|
clientX = e.touches[0].clientX;
|
|
clientY = e.touches[0].clientY;
|
|
} else if (e.changedTouches && e.changedTouches.length > 0) {
|
|
clientX = e.changedTouches[0].clientX;
|
|
clientY = e.changedTouches[0].clientY;
|
|
} else {
|
|
clientX = e.clientX;
|
|
clientY = e.clientY;
|
|
}
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
const y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
return { x, y, clientX, clientY };
|
|
}
|
|
|
|
function rotateCamera(dx, dy) {
|
|
const rotSpeed = 0.005;
|
|
const offset = new THREE.Vector3().subVectors(ctx.camera.position, ctx.cameraTarget);
|
|
const spherical = new THREE.Spherical().setFromVector3(offset);
|
|
spherical.theta -= dx * rotSpeed;
|
|
spherical.phi -= dy * rotSpeed;
|
|
spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
|
|
offset.setFromSpherical(spherical);
|
|
ctx.camera.position.copy(ctx.cameraTarget).add(offset);
|
|
ctx.camera.lookAt(ctx.cameraTarget);
|
|
}
|
|
|
|
function onDown(e) {
|
|
e.preventDefault();
|
|
const p = getXY(e, canvas);
|
|
isDragging = true;
|
|
moved = false;
|
|
dragStartTime = Date.now();
|
|
dragStartXY = { x: p.clientX, y: p.clientY };
|
|
lastX = p.clientX;
|
|
lastY = p.clientY;
|
|
if (canvas.setPointerCapture && e.pointerId !== undefined) {
|
|
try { canvas.setPointerCapture(e.pointerId); } catch {}
|
|
}
|
|
}
|
|
|
|
function onMove(e) {
|
|
if (!isDragging) return;
|
|
e.preventDefault();
|
|
const p = getXY(e, canvas);
|
|
const dx = p.clientX - lastX;
|
|
const dy = p.clientY - lastY;
|
|
if (Math.abs(p.clientX - dragStartXY.x) + Math.abs(p.clientY - dragStartXY.y) > 8) {
|
|
moved = true;
|
|
}
|
|
rotateCamera(dx, dy);
|
|
lastX = p.clientX;
|
|
lastY = p.clientY;
|
|
render();
|
|
}
|
|
|
|
function onUp(e) {
|
|
if (!isDragging) return;
|
|
isDragging = false;
|
|
const elapsed = Date.now() - dragStartTime;
|
|
// Tap = quick release without much movement
|
|
if (!moved && elapsed < 500) {
|
|
const p = getXY(e.changedTouches ? { changedTouches: e.changedTouches } : e, canvas);
|
|
const raycaster = new THREE.Raycaster();
|
|
const mouse = new THREE.Vector2(p.x, p.y);
|
|
raycaster.setFromCamera(mouse, ctx.camera);
|
|
const meshes = Array.from(ctx.nodes.values());
|
|
const intersects = raycaster.intersectObjects(meshes);
|
|
if (intersects.length > 0) {
|
|
const nodeId = intersects[0].object.userData.nodeId;
|
|
if (nodeId) {
|
|
const modal = document.querySelector('.graph-modal');
|
|
if (modal) modal.classList.remove('open');
|
|
window.location.href = '/' + nodeId + '.html';
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pointer events (modern: iOS 13+, all modern browsers)
|
|
canvas.addEventListener('pointerdown', onDown);
|
|
canvas.addEventListener('pointermove', onMove);
|
|
canvas.addEventListener('pointerup', onUp);
|
|
canvas.addEventListener('pointercancel', onUp);
|
|
canvas.addEventListener('pointerleave', onUp);
|
|
|
|
// Touch events fallback (older iOS Safari 12-)
|
|
canvas.addEventListener('touchstart', onDown, { passive: false });
|
|
canvas.addEventListener('touchmove', onMove, { passive: false });
|
|
canvas.addEventListener('touchend', onUp, { passive: false });
|
|
canvas.addEventListener('touchcancel', onUp, { passive: false });
|
|
|
|
// Mouse events fallback (older desktop browsers)
|
|
canvas.addEventListener('mousedown', onDown);
|
|
canvas.addEventListener('mousemove', onMove);
|
|
canvas.addEventListener('mouseup', onUp);
|
|
canvas.addEventListener('mouseleave', onUp);
|
|
|
|
// Zoom with wheel (desktop) + pinch (mobile)
|
|
let pinchStartDist = null;
|
|
function getPinchDist(e) {
|
|
if (e.touches && e.touches.length >= 2) {
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
return Math.sqrt(dx*dx + dy*dy);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function onWheel(e) {
|
|
e.preventDefault();
|
|
const dir = e.deltaY > 0 ? 1.1 : 0.9;
|
|
ctx.camera.position.multiplyScalar(dir);
|
|
ctx.camera.lookAt(ctx.cameraTarget);
|
|
render();
|
|
}
|
|
canvas.addEventListener('wheel', onWheel, { passive: false });
|
|
|
|
function onTouchMoveForPinch(e) {
|
|
const dist = getPinchDist(e);
|
|
if (dist !== null) {
|
|
e.preventDefault();
|
|
if (pinchStartDist === null) {
|
|
pinchStartDist = dist;
|
|
} else {
|
|
const ratio = pinchStartDist / dist;
|
|
if (Math.abs(ratio - 1) > 0.01) {
|
|
ctx.camera.position.multiplyScalar(1 / ratio);
|
|
ctx.camera.lookAt(ctx.cameraTarget);
|
|
pinchStartDist = dist;
|
|
render();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function onTouchEndForPinch(e) {
|
|
if (e.touches && e.touches.length < 2) {
|
|
pinchStartDist = null;
|
|
}
|
|
}
|
|
canvas.addEventListener('touchmove', onTouchMoveForPinch, { passive: false });
|
|
canvas.addEventListener('touchend', onTouchEndForPinch, { passive: false });
|
|
|
|
// Resize handling — wait for actual visible dimensions
|
|
function safeResize() {
|
|
const w2 = canvas.clientWidth || canvas.parentElement.clientWidth;
|
|
const h2 = canvas.clientHeight || canvas.parentElement.clientHeight;
|
|
if (w2 === 0 || h2 === 0) return;
|
|
ctx.camera.aspect = w2 / h2;
|
|
ctx.camera.updateProjectionMatrix();
|
|
ctx.renderer.setSize(w2, h2, false);
|
|
render();
|
|
}
|
|
|
|
function onResize() {
|
|
safeResize();
|
|
}
|
|
window.addEventListener('resize', onResize);
|
|
|
|
// Force-initial render with retry until canvas has dimensions
|
|
let resizeRetries = 0;
|
|
function tryInitialRender() {
|
|
safeResize();
|
|
if ((canvas.clientWidth === 0 || canvas.clientHeight === 0) && resizeRetries < 10) {
|
|
resizeRetries++;
|
|
requestAnimationFrame(tryInitialRender);
|
|
}
|
|
}
|
|
requestAnimationFrame(tryInitialRender);
|
|
|
|
// Listen for modal-open to re-check sizing
|
|
if (container.classList.contains('graph-modal')) {
|
|
const observer = new ResizeObserver(() => safeResize());
|
|
observer.observe(canvas);
|
|
ctx.cleanupObserver = () => observer.disconnect();
|
|
}
|
|
|
|
// Cleanup function
|
|
ctx.render = render;
|
|
ctx.cleanup = () => {
|
|
canvas.removeEventListener('pointerdown', onDown);
|
|
canvas.removeEventListener('pointermove', onMove);
|
|
canvas.removeEventListener('pointerup', onUp);
|
|
canvas.removeEventListener('pointercancel', onUp);
|
|
canvas.removeEventListener('pointerleave', onUp);
|
|
canvas.removeEventListener('touchstart', onDown);
|
|
canvas.removeEventListener('touchmove', onMove);
|
|
canvas.removeEventListener('touchend', onUp);
|
|
canvas.removeEventListener('touchcancel', onUp);
|
|
canvas.removeEventListener('mousedown', onDown);
|
|
canvas.removeEventListener('mousemove', onMove);
|
|
canvas.removeEventListener('mouseup', onUp);
|
|
canvas.removeEventListener('mouseleave', onUp);
|
|
canvas.removeEventListener('wheel', onWheel);
|
|
canvas.removeEventListener('touchmove', onTouchMoveForPinch);
|
|
canvas.removeEventListener('touchend', onTouchEndForPinch);
|
|
window.removeEventListener('resize', onResize);
|
|
if (ctx.cleanupObserver) ctx.cleanupObserver();
|
|
};
|
|
return ctx;
|
|
}
|
|
|
|
function computeLayout(nodeIds, edges, radius) {
|
|
// Simple force-directed-ish: angle-distributed with edge-based clustering
|
|
const positions = {};
|
|
const N = nodeIds.length;
|
|
if (N === 0) return positions;
|
|
|
|
// Initialize: distribute on sphere
|
|
for (let i = 0; i < N; i++) {
|
|
const phi = Math.acos(-1 + (2 * i) / N);
|
|
const theta = Math.sqrt(N * Math.PI) * phi;
|
|
positions[nodeIds[i]] = {
|
|
x: radius * Math.cos(theta) * Math.sin(phi),
|
|
y: radius * Math.sin(theta) * Math.sin(phi),
|
|
z: radius * Math.cos(phi),
|
|
};
|
|
}
|
|
|
|
// 1-2 iterations of relaxation
|
|
for (let iter = 0; iter < 2; iter++) {
|
|
// Repulsion
|
|
for (let i = 0; i < N; i++) {
|
|
const a = nodeIds[i];
|
|
let fx = 0, fy = 0, fz = 0;
|
|
for (let j = 0; j < N; j++) {
|
|
if (i === j) continue;
|
|
const b = nodeIds[j];
|
|
const dx = positions[a].x - positions[b].x;
|
|
const dy = positions[a].y - positions[b].y;
|
|
const dz = positions[a].z - positions[b].z;
|
|
const d2 = dx*dx + dy*dy + dz*dz + 0.01;
|
|
const f = 50 / d2;
|
|
fx += dx * f;
|
|
fy += dy * f;
|
|
fz += dz * f;
|
|
}
|
|
positions[a].x += fx * 0.01;
|
|
positions[a].y += fy * 0.01;
|
|
positions[a].z += fz * 0.01;
|
|
}
|
|
}
|
|
return positions;
|
|
}
|
|
|
|
// ============================================================
|
|
// Backlinks render
|
|
// ============================================================
|
|
|
|
function renderBacklinks() {
|
|
const container = document.getElementById('backlinks');
|
|
if (!container) return;
|
|
const links = (data.backlinks && data.backlinks[currentSlug]) || [];
|
|
if (links.length === 0) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
const html = [
|
|
'<div class="note-backlinks-title">Backlinks (' + links.length + ')</div>',
|
|
'<ul>' + links.map(slug => {
|
|
const page = data.pages.find(p => p.slug === slug);
|
|
const title = page ? page.title : slug;
|
|
return `<li><a href="/${slug}.html">${escapeHtml(title)}</a></li>`;
|
|
}).join('') + '</ul>'
|
|
].join('');
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// ============================================================
|
|
// TOC (extract from rendered headings)
|
|
// ============================================================
|
|
|
|
function renderTOC() {
|
|
const container = document.getElementById('toc');
|
|
if (!container) return;
|
|
const headings = [...document.querySelectorAll('.note-body h2, .note-body h3')];
|
|
if (headings.length === 0) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
const items = headings.map(h => {
|
|
const level = h.tagName === 'H2' ? 2 : 3;
|
|
const text = h.textContent.replace(/\s*¶\s*$/, '').trim();
|
|
const id = h.id || text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
h.id = id;
|
|
return `<li style="margin-left: ${(level - 2) * 16}px"><a href="#${id}">${escapeHtml(text)}</a></li>`;
|
|
}).join('');
|
|
container.innerHTML = `<div class="note-toc-title">Inhalt</div><ul>${items}</ul>`;
|
|
}
|
|
|
|
// ============================================================
|
|
// Mobile bottom-nav handlers
|
|
// ============================================================
|
|
|
|
function setupBottomNav() {
|
|
document.querySelectorAll('.app-bottom-nav button').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const action = btn.dataset.action;
|
|
if (action === 'toggle-tree') {
|
|
toggleTree();
|
|
} else if (action === 'toggle-graph') {
|
|
openGraphModal();
|
|
} else if (action === 'focus-search') {
|
|
const s = document.getElementById('search');
|
|
if (s) {
|
|
s.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
s.focus();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function toggleTree() {
|
|
const tree = document.querySelector('.app-tree');
|
|
const backdrop = document.querySelector('.backdrop');
|
|
if (!tree) return;
|
|
const isOpen = tree.classList.contains('open');
|
|
tree.classList.toggle('open');
|
|
if (backdrop) backdrop.classList.toggle('open', !isOpen);
|
|
}
|
|
|
|
function openGraphModal() {
|
|
const modal = document.querySelector('.graph-modal');
|
|
if (!modal) return;
|
|
modal.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
// Build graph in modal canvas (once)
|
|
const canvas = modal.querySelector('canvas');
|
|
if (canvas && !canvas.dataset.built) {
|
|
canvas.dataset.built = '1';
|
|
// Wait for layout
|
|
requestAnimationFrame(() => {
|
|
graphState = buildGraph(canvas, modal);
|
|
});
|
|
} else if (canvas && graphState) {
|
|
graphState.render();
|
|
}
|
|
}
|
|
|
|
function closeGraphModal() {
|
|
const modal = document.querySelector('.graph-modal');
|
|
if (!modal) return;
|
|
modal.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
// ============================================================
|
|
// Sidebar/Desktop graph
|
|
// ============================================================
|
|
|
|
function setupDesktopGraph() {
|
|
const graphEl = document.querySelector('.app-graph');
|
|
if (!graphEl) return;
|
|
const canvas = document.createElement('canvas');
|
|
graphEl.appendChild(canvas);
|
|
requestAnimationFrame(() => {
|
|
graphState = buildGraph(canvas, graphEl);
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Top-bar buttons (toolbar actions)
|
|
// ============================================================
|
|
|
|
function setupToolbar() {
|
|
document.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('[data-action]');
|
|
if (!btn) return;
|
|
const action = btn.dataset.action;
|
|
if (action === 'toggle-tree') {
|
|
toggleTree();
|
|
} else if (action === 'toggle-graph') {
|
|
const isMobile = window.innerWidth < 768;
|
|
if (isMobile) openGraphModal();
|
|
else if (graphState) graphState.render();
|
|
} else if (action === 'focus-search') {
|
|
const s = document.getElementById('search');
|
|
if (s) s.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Modal close handlers
|
|
// ============================================================
|
|
|
|
function setupModalClose() {
|
|
const closeBtn = document.querySelector('.graph-modal-close');
|
|
if (closeBtn) closeBtn.onclick = closeGraphModal;
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
const modal = document.querySelector('.graph-modal');
|
|
if (modal && modal.classList.contains('open')) closeGraphModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Backdrop
|
|
// ============================================================
|
|
|
|
function setupBackdrop() {
|
|
let backdrop = document.querySelector('.backdrop');
|
|
if (!backdrop) {
|
|
backdrop = document.createElement('div');
|
|
backdrop.className = 'backdrop';
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
backdrop.onclick = () => {
|
|
const tree = document.querySelector('.app-tree');
|
|
if (tree) tree.classList.remove('open');
|
|
backdrop.classList.remove('open');
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Utility
|
|
// ============================================================
|
|
|
|
function escapeHtml(str) {
|
|
return String(str).replace(/[&<>"']/g, (c) => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
})[c]);
|
|
}
|
|
|
|
// ============================================================
|
|
// Init
|
|
// ============================================================
|
|
|
|
function init() {
|
|
const treeEl = document.getElementById('tree');
|
|
if (treeEl && data.tree) renderTree(data.tree, treeEl);
|
|
|
|
setupSearch();
|
|
setupToolbar();
|
|
setupModalClose();
|
|
setupBackdrop();
|
|
setupBottomNav();
|
|
setupDesktopGraph();
|
|
|
|
renderTOC();
|
|
renderBacklinks();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})(); |