|
|
@@ -15,6 +15,7 @@
|
|
|
--header-color: #666;
|
|
|
--middle-col-bg: #161616;
|
|
|
--tree-line-color: #333;
|
|
|
+ --accent-color: #ff9f43;
|
|
|
}
|
|
|
|
|
|
body {
|
|
|
@@ -33,7 +34,7 @@
|
|
|
.sidebar-left {
|
|
|
width: 260px;
|
|
|
background: var(--sidebar-bg);
|
|
|
- border-right: 1px solid var(--border-color);
|
|
|
+
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
overflow-y: auto;
|
|
|
@@ -147,7 +148,7 @@
|
|
|
.sidebar-middle {
|
|
|
width: 320px;
|
|
|
background: var(--middle-col-bg);
|
|
|
- border-right: 1px solid var(--border-color);
|
|
|
+
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
overflow: hidden;
|
|
|
@@ -175,6 +176,32 @@
|
|
|
height: 100%;
|
|
|
}
|
|
|
|
|
|
+ /* Connection line between middle and right columns */
|
|
|
+ .column-connection-line {
|
|
|
+ position: fixed;
|
|
|
+ inset: 0;
|
|
|
+ width: 100vw;
|
|
|
+ height: 100vh;
|
|
|
+ pointer-events: none;
|
|
|
+ z-index: 20;
|
|
|
+ opacity: 0.85;
|
|
|
+ }
|
|
|
+
|
|
|
+ .column-connection-path {
|
|
|
+ stroke: var(--accent-color);
|
|
|
+ stroke-width: 2;
|
|
|
+ stroke-dasharray: 6 6;
|
|
|
+ stroke-linecap: round;
|
|
|
+ fill: none;
|
|
|
+ opacity: 0.35;
|
|
|
+ animation: dashFlow 1.1s linear infinite;
|
|
|
+ filter: drop-shadow(0 0 2px rgba(255, 159, 67, 0.25));
|
|
|
+ }
|
|
|
+
|
|
|
+ @keyframes dashFlow {
|
|
|
+ to { stroke-dashoffset: -12; }
|
|
|
+ }
|
|
|
+
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
@@ -259,6 +286,11 @@
|
|
|
<iframe id="content-frame" src="targets/test_css_selector.html"></iframe>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- SVG overlay for the middle→right connection line -->
|
|
|
+ <svg id="column-connection-line" class="column-connection-line" aria-hidden="true">
|
|
|
+ <path id="column-connection-path" class="column-connection-path" d=""></path>
|
|
|
+ </svg>
|
|
|
+
|
|
|
<script>
|
|
|
function toggleTree(header) {
|
|
|
const children = header.nextElementSibling;
|
|
|
@@ -295,6 +327,9 @@
|
|
|
try {
|
|
|
setMiddleActive(url);
|
|
|
} catch {}
|
|
|
+
|
|
|
+ // Update connection line after navigation
|
|
|
+ scheduleConnectionLineUpdate();
|
|
|
}
|
|
|
|
|
|
// Called by content iframe pages (and also internally after navigation)
|
|
|
@@ -305,6 +340,198 @@
|
|
|
if (win && typeof win.setActiveByContentUrl === 'function') {
|
|
|
win.setActiveByContentUrl(contentUrl);
|
|
|
}
|
|
|
+
|
|
|
+ // Update connection line when active item changes
|
|
|
+ scheduleConnectionLineUpdate();
|
|
|
+ }
|
|
|
+
|
|
|
+ // =============================
|
|
|
+ // Middle ↔ Right connection line
|
|
|
+ // =============================
|
|
|
+ const connectionSvg = document.getElementById('column-connection-line');
|
|
|
+ const connectionPath = document.getElementById('column-connection-path');
|
|
|
+ let _connRaf = 0;
|
|
|
+
|
|
|
+ function scheduleConnectionLineUpdate() {
|
|
|
+ if (_connRaf) return;
|
|
|
+ _connRaf = requestAnimationFrame(() => {
|
|
|
+ _connRaf = 0;
|
|
|
+ updateConnectionLine();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function getActiveElementInFrame(frameEl) {
|
|
|
+ try {
|
|
|
+ const doc = frameEl?.contentDocument;
|
|
|
+ if (!doc) return null;
|
|
|
+ // Middle list pages use .card.active / .header-card.active; fall back to .active
|
|
|
+ return (
|
|
|
+ doc.querySelector('.card.active') ||
|
|
|
+ doc.querySelector('.header-card.active') ||
|
|
|
+ doc.querySelector('.nav-item.active') ||
|
|
|
+ doc.querySelector('.active')
|
|
|
+ );
|
|
|
+ } catch {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function getAnchorElementInContentFrame(frameEl) {
|
|
|
+ try {
|
|
|
+ const doc = frameEl?.contentDocument;
|
|
|
+ if (!doc) return null;
|
|
|
+ // Prefer a *visible* code area anchor (in-viewport), then fall back
|
|
|
+ const candidates = [
|
|
|
+ '.box-container .box-header',
|
|
|
+ '.code-view.active, .html-view.active, .css-view.active',
|
|
|
+ '.box-container',
|
|
|
+ 'h1',
|
|
|
+ '.container',
|
|
|
+ 'body'
|
|
|
+ ];
|
|
|
+ for (const sel of candidates) {
|
|
|
+ const el = doc.querySelector(sel);
|
|
|
+ if (!el) continue;
|
|
|
+ // If it's visible in the iframe viewport, use it; else keep looking
|
|
|
+ const r = el.getBoundingClientRect();
|
|
|
+ const vh = frameEl?.clientHeight || 0;
|
|
|
+ if (!vh) return el;
|
|
|
+ const visibleTop = Math.max(0, r.top);
|
|
|
+ const visibleBottom = Math.min(vh, r.bottom);
|
|
|
+ if (visibleBottom - visibleTop > 8) return el;
|
|
|
+ }
|
|
|
+ return doc.body;
|
|
|
+ } catch {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function clamp(n, min, max) {
|
|
|
+ return Math.min(Math.max(n, min), max);
|
|
|
+ }
|
|
|
+
|
|
|
+ function getVisibleCenterInFrame(frameEl, elRect) {
|
|
|
+ const w = frameEl?.clientWidth || 0;
|
|
|
+ const h = frameEl?.clientHeight || 0;
|
|
|
+ if (!w || !h) return null;
|
|
|
+
|
|
|
+ const visibleLeft = Math.max(0, elRect.left);
|
|
|
+ const visibleRight = Math.min(w, elRect.right);
|
|
|
+ const visibleTop = Math.max(0, elRect.top);
|
|
|
+ const visibleBottom = Math.min(h, elRect.bottom);
|
|
|
+
|
|
|
+ if (visibleRight - visibleLeft <= 1 || visibleBottom - visibleTop <= 1) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ x: (visibleLeft + visibleRight) / 2,
|
|
|
+ y: (visibleTop + visibleBottom) / 2
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateConnectionLine() {
|
|
|
+ if (!connectionSvg || !connectionPath) return;
|
|
|
+
|
|
|
+ const middleFrame = document.getElementById('middle-frame');
|
|
|
+ const contentFrame = document.getElementById('content-frame');
|
|
|
+ if (!middleFrame || !contentFrame) return;
|
|
|
+
|
|
|
+ const midRect = middleFrame.getBoundingClientRect();
|
|
|
+ const rightRect = contentFrame.getBoundingClientRect();
|
|
|
+
|
|
|
+ const active = getActiveElementInFrame(middleFrame);
|
|
|
+ const anchor = getAnchorElementInContentFrame(contentFrame);
|
|
|
+ if (!active || !anchor) {
|
|
|
+ connectionPath.setAttribute('d', '');
|
|
|
+ connectionSvg.style.opacity = '0';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const activeRect = active.getBoundingClientRect();
|
|
|
+ const anchorRect = anchor.getBoundingClientRect();
|
|
|
+
|
|
|
+ // Use the *visible* part of the element within its iframe viewport
|
|
|
+ const activeCenter = getVisibleCenterInFrame(middleFrame, activeRect);
|
|
|
+ const anchorCenter = getVisibleCenterInFrame(contentFrame, anchorRect);
|
|
|
+ if (!activeCenter || !anchorCenter) {
|
|
|
+ // If either side isn't visible in its iframe, hide the line (prevents weird drifting)
|
|
|
+ connectionPath.setAttribute('d', '');
|
|
|
+ connectionSvg.style.opacity = '0';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Convert iframe-viewport coordinates to parent viewport coordinates
|
|
|
+ const startX = midRect.left + clamp(activeRect.right, 0, middleFrame.clientWidth);
|
|
|
+ const startY = midRect.top + activeCenter.y;
|
|
|
+
|
|
|
+ // Point into the code area a bit (not exactly at left edge)
|
|
|
+ const endX = rightRect.left + clamp(anchorRect.left + 18, 12, contentFrame.clientWidth - 12);
|
|
|
+ const endY = rightRect.top + anchorCenter.y;
|
|
|
+
|
|
|
+ // Draw an L-shaped polyline.
|
|
|
+ // Keep the vertical segment aligned with the column divider (middle ↔ right).
|
|
|
+ const dividerX = rightRect.left + 0.5; // half-pixel for crisper stroke alignment
|
|
|
+ const x1 = Math.max(startX + 12, dividerX);
|
|
|
+ const d = [
|
|
|
+ `M ${startX} ${startY}`,
|
|
|
+ `L ${x1} ${startY}`,
|
|
|
+ `L ${x1} ${endY}`,
|
|
|
+ `L ${endX} ${endY}`
|
|
|
+ ].join(' ');
|
|
|
+
|
|
|
+ connectionPath.setAttribute('d', d);
|
|
|
+ connectionSvg.style.opacity = '0.85';
|
|
|
+ }
|
|
|
+
|
|
|
+ function attachFrameScrollListeners(frameEl) {
|
|
|
+ if (!frameEl) return;
|
|
|
+ const onScroll = () => {
|
|
|
+ markFrameScrolling(frameEl);
|
|
|
+ scheduleConnectionLineUpdate();
|
|
|
+ };
|
|
|
+ try {
|
|
|
+ // Window scroll inside iframe
|
|
|
+ frameEl.contentWindow?.addEventListener('scroll', onScroll, { passive: true });
|
|
|
+ // Some pages scroll on document/body; capture to be safe
|
|
|
+ frameEl.contentDocument?.addEventListener('scroll', onScroll, true);
|
|
|
+ frameEl.contentDocument?.documentElement?.addEventListener?.('scroll', onScroll, { passive: true });
|
|
|
+ frameEl.contentDocument?.body?.addEventListener?.('scroll', onScroll, { passive: true });
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+
|
|
|
+ const _scrollHideTimers = new WeakMap();
|
|
|
+ function markFrameScrolling(frameEl) {
|
|
|
+ try {
|
|
|
+ const doc = frameEl?.contentDocument;
|
|
|
+ if (!doc) return;
|
|
|
+ doc.documentElement?.classList.add('scrolling');
|
|
|
+ doc.body?.classList.add('scrolling');
|
|
|
+ const prev = _scrollHideTimers.get(frameEl);
|
|
|
+ if (prev) clearTimeout(prev);
|
|
|
+ _scrollHideTimers.set(frameEl, setTimeout(() => {
|
|
|
+ try {
|
|
|
+ doc.documentElement?.classList.remove('scrolling');
|
|
|
+ doc.body?.classList.remove('scrolling');
|
|
|
+ } catch {}
|
|
|
+ }, 500));
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+
|
|
|
+ // Always-on lightweight loop to keep the line aligned even
|
|
|
+ // if iframe scroll events were missed due to timing.
|
|
|
+ let _connLoopStarted = false;
|
|
|
+ function startConnectionLineLoop() {
|
|
|
+ if (_connLoopStarted) return;
|
|
|
+ _connLoopStarted = true;
|
|
|
+ const loop = () => {
|
|
|
+ // Only bother when we have a path (avoids work when hidden)
|
|
|
+ if (connectionPath?.getAttribute('d')) {
|
|
|
+ scheduleConnectionLineUpdate();
|
|
|
+ }
|
|
|
+ requestAnimationFrame(loop);
|
|
|
+ };
|
|
|
+ requestAnimationFrame(loop);
|
|
|
}
|
|
|
|
|
|
// Make initial active state robust against iframe load ordering
|
|
|
@@ -318,6 +545,8 @@
|
|
|
middleFrame.addEventListener('load', () => {
|
|
|
const current = document.getElementById('content-frame')?.getAttribute('src');
|
|
|
if (current) setMiddleActive(current);
|
|
|
+ attachFrameScrollListeners(middleFrame);
|
|
|
+ scheduleConnectionLineUpdate();
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -325,8 +554,19 @@
|
|
|
contentFrame.addEventListener('load', () => {
|
|
|
const current = contentFrame.getAttribute('src');
|
|
|
if (current) setMiddleActive(current);
|
|
|
+ attachFrameScrollListeners(contentFrame);
|
|
|
+ scheduleConnectionLineUpdate();
|
|
|
});
|
|
|
}
|
|
|
+
|
|
|
+ // If the iframes were already loaded before we attached 'load' listeners,
|
|
|
+ // still attach scroll listeners immediately.
|
|
|
+ attachFrameScrollListeners(middleFrame);
|
|
|
+ attachFrameScrollListeners(contentFrame);
|
|
|
+
|
|
|
+ window.addEventListener('resize', scheduleConnectionLineUpdate, { passive: true });
|
|
|
+ scheduleConnectionLineUpdate();
|
|
|
+ startConnectionLineLoop();
|
|
|
});
|
|
|
</script>
|
|
|
</body>
|