Touch-First Design (style.css, 17 KB): - Mobile-First CSS mit Safe-Area-Insets für iOS Dynamic Island - Bottom-Nav (Files / Search / Graph) auf <768px - Hamburger-Tree slidet von links rein mit Backdrop - Graph-Modal als Full-Screen-Overlay mit Title-Bar + Close - Tap-Targets ≥ 44px (--tap-min) - Tablet-Mode (768-1023px): 2-Panel + Bottom-Graph 240px - Desktop (≥1024px): 3-Panel mit Tree 280px / Graph 320px - Touch-Action-Manipulation verhindert 300ms-Tap-Delay - Word-wrap + overflow-wrap für Mobile-Content - Frontmatter-Card als Grid-Layout - TOC + Backlinks Styling Touch-First JS (app.js, 21 KB): - Tree-Render mit auf-/zuklappbaren Ordnern (auto-open concepts/entities) - Search mit Live-Dropdown, Keyboard-Navigation (↑↓ Enter Esc) - 3D Graph in Three.js, KEIN Auto-Rotation, drag-to-rotate + wheel-zoom - Click-zentriert: Click auf Node → navigiert zur Page - Modal-Close via X-Button oder Escape - Touch/Click-Handler: pointerdown/move/up + Raycaster - Backlinks-Section aus data.backlinks - TOC aus h2/h3-Headings der aktuellen Page - Hash-anchor highlighting via :target CSS Template-Patch (generator.py): - Search-Wrap-Container für absolute Dropdown-Position - Bottom-Nav mit inner-Flex - Graph-Modal-Container - Three.js-CDN-Script im HEAD Assets werden jetzt aus dem Repo kopiert (style.css, app.js, manifest.json) statt eingebettete Placeholder-Strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
639 lines
21 KiB
JavaScript
639 lines
21 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
|
|
let isPointerDown = false;
|
|
let lastX = 0, lastY = 0;
|
|
function onPointerDown(e) {
|
|
isPointerDown = true;
|
|
ctx.isDragging = false;
|
|
const p = pointerXY(e, canvas);
|
|
lastX = p.x; lastY = p.y;
|
|
}
|
|
function onPointerMove(e) {
|
|
if (!isPointerDown) return;
|
|
const p = pointerXY(e, canvas);
|
|
const dx = p.x - lastX;
|
|
const dy = p.y - lastY;
|
|
if (Math.abs(dx) + Math.abs(dy) > 4) ctx.isDragging = true;
|
|
// Rotate camera around origin
|
|
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);
|
|
lastX = p.x; lastY = p.y;
|
|
render();
|
|
}
|
|
function onPointerUp(e) {
|
|
if (!isPointerDown) return;
|
|
isPointerDown = false;
|
|
if (!ctx.isDragging) {
|
|
// Click — raycast for node
|
|
const p = pointerXY(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) {
|
|
// Navigate
|
|
const slug = nodeId;
|
|
const modal = document.querySelector('.graph-modal');
|
|
if (modal) modal.classList.remove('open');
|
|
window.location.href = '/' + slug + '.html';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function pointerXY(e, canvas) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
return { x, y };
|
|
}
|
|
|
|
canvas.addEventListener('pointerdown', onPointerDown);
|
|
canvas.addEventListener('pointermove', onPointerMove);
|
|
canvas.addEventListener('pointerup', onPointerUp);
|
|
canvas.addEventListener('pointercancel', onPointerUp);
|
|
|
|
// Zoom with wheel
|
|
canvas.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
const dir = e.deltaY > 0 ? 1.1 : 0.9;
|
|
ctx.camera.position.multiplyScalar(dir);
|
|
ctx.camera.lookAt(ctx.cameraTarget);
|
|
render();
|
|
}, { passive: false });
|
|
|
|
// Resize handling
|
|
function onResize() {
|
|
const w2 = canvas.clientWidth;
|
|
const h2 = canvas.clientHeight;
|
|
if (w2 === 0 || h2 === 0) return;
|
|
ctx.camera.aspect = w2 / h2;
|
|
ctx.camera.updateProjectionMatrix();
|
|
ctx.renderer.setSize(w2, h2, false);
|
|
render();
|
|
}
|
|
window.addEventListener('resize', onResize);
|
|
|
|
// First render
|
|
render();
|
|
|
|
ctx.render = render;
|
|
ctx.cleanup = () => {
|
|
canvas.removeEventListener('pointerdown', onPointerDown);
|
|
canvas.removeEventListener('pointermove', onPointerMove);
|
|
canvas.removeEventListener('pointerup', onPointerUp);
|
|
canvas.removeEventListener('pointercancel', onPointerUp);
|
|
window.removeEventListener('resize', onResize);
|
|
};
|
|
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();
|
|
}
|
|
})(); |