refactor: implement scroll container context and flex layout
Switch from fixed positioning to a flexbox-based layout for the main application structure. This introduces a `ScrollContainerCtx` to provide access to the main scrollable element, allowing child components like `FileTree` and `RevealManager` to perform accurate scrolling and positioning relative to the container instead of the window. refactor: set JournalPanel height to full
This commit is contained in:
parent
b54f7ab1fa
commit
5097aba842
114
src/App.tsx
114
src/App.tsx
|
|
@ -1,4 +1,11 @@
|
||||||
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
import {
|
||||||
|
Component,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
} from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
import { useJournalStream } from "./components/stores/journalStream";
|
||||||
|
|
||||||
|
|
@ -15,6 +22,23 @@ import {
|
||||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
import { JournalPanel } from "./components/journal";
|
import { JournalPanel } from "./components/journal";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scroll container context – lets child components (FileTree, RevealManager)
|
||||||
|
// find the correct scrollable element instead of relying on window.scroll
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ScrollContainerCtx = createContext<() => HTMLElement | undefined>();
|
||||||
|
|
||||||
|
/** Access the main content scroll container from any descendant. */
|
||||||
|
export function useScrollContainer(): () => HTMLElement | undefined {
|
||||||
|
const ctx = useContext(ScrollContainerCtx);
|
||||||
|
return ctx ?? (() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// App
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
|
@ -30,6 +54,8 @@ const App: Component = () => {
|
||||||
>({});
|
>({});
|
||||||
const [tocKey, setTocKey] = createSignal(0);
|
const [tocKey, setTocKey] = createSignal(0);
|
||||||
|
|
||||||
|
let mainRef!: HTMLElement;
|
||||||
|
|
||||||
const loadToc = async () => {
|
const loadToc = async () => {
|
||||||
const toc = await generateToc();
|
const toc = await generateToc();
|
||||||
setFileTree(toc.fileTree);
|
setFileTree(toc.fileTree);
|
||||||
|
|
@ -58,23 +84,10 @@ const App: Component = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="h-screen flex flex-col overflow-hidden bg-gray-50">
|
||||||
{/* 桌面端固定侧边栏 */}
|
{/* Header */}
|
||||||
<DesktopSidebar
|
<header class="shrink-0 h-16 bg-white shadow z-30">
|
||||||
fileTree={fileTree()}
|
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
|
||||||
pathHeadings={pathHeadings()}
|
|
||||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
|
||||||
/>
|
|
||||||
{/* 移动端抽屉式侧边栏 */}
|
|
||||||
<MobileSidebar
|
|
||||||
isOpen={isSidebarOpen()}
|
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
|
||||||
fileTree={fileTree()}
|
|
||||||
pathHeadings={pathHeadings()}
|
|
||||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
|
||||||
/>
|
|
||||||
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
|
||||||
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
|
||||||
{/* 仅在移动端显示菜单按钮 */}
|
{/* 仅在移动端显示菜单按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSidebarOpen(true)}
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
|
@ -115,30 +128,61 @@ const App: Component = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{/* fill the rest of the space */}
|
|
||||||
<div
|
{/* 移动端抽屉式侧边栏 (overlay) */}
|
||||||
class="fixed top-16 left-0 right-0 bottom-0 overflow-auto transition-all duration-300"
|
<MobileSidebar
|
||||||
classList={{ "md:pr-[420px]": isJournalOpen() }}
|
isOpen={isSidebarOpen()}
|
||||||
>
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
|
fileTree={fileTree()}
|
||||||
<Article
|
pathHeadings={pathHeadings()}
|
||||||
class="prose text-black prose-sm max-w-full flex-1"
|
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||||
src={currentPath()}
|
/>
|
||||||
>
|
|
||||||
<RevealManager />
|
{/* Body: sidebar + main + journal */}
|
||||||
</Article>
|
<div class="flex flex-1 min-h-0">
|
||||||
</main>
|
{/* 桌面端固定侧边栏 (flex child) */}
|
||||||
|
<DesktopSidebar
|
||||||
|
fileTree={fileTree()}
|
||||||
|
pathHeadings={pathHeadings()}
|
||||||
|
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content — the scrollable region */}
|
||||||
|
<ScrollContainerCtx.Provider value={() => mainRef}>
|
||||||
|
<main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<Article
|
||||||
|
class="prose text-black prose-sm max-w-full"
|
||||||
|
src={currentPath()}
|
||||||
|
>
|
||||||
|
<RevealManager />
|
||||||
|
</Article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</ScrollContainerCtx.Provider>
|
||||||
|
|
||||||
|
{/* Journal panel wrapper — on desktop: flex child with width transition;
|
||||||
|
on mobile: passes through to JournalPanel's own overlay */}
|
||||||
|
<div
|
||||||
|
class="contents md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
|
||||||
|
classList={{
|
||||||
|
"md:w-105": isJournalOpen(),
|
||||||
|
"md:w-0": !isJournalOpen(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<JournalPanel
|
||||||
|
open={isJournalOpen()}
|
||||||
|
onClose={() => setIsJournalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
||||||
<DataSourceDialog
|
<DataSourceDialog
|
||||||
isOpen={isDataSourceOpen()}
|
isOpen={isDataSourceOpen()}
|
||||||
onClose={() => setIsDataSourceOpen(false)}
|
onClose={() => setIsDataSourceOpen(false)}
|
||||||
onSourceChanged={handleSourceChanged}
|
onSourceChanged={handleSourceChanged}
|
||||||
/>
|
/>
|
||||||
<JournalPanel
|
|
||||||
open={isJournalOpen()}
|
|
||||||
onClose={() => setIsJournalOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Component, createMemo, createSignal, Show } from "solid-js";
|
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||||
import { type FileNode, type TocNode } from "../data-loader";
|
import { type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||||
|
import { useScrollContainer } from "../App";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查当前文件路径是否在文件夹内
|
* 检查当前文件路径是否在文件夹内
|
||||||
|
|
@ -98,6 +99,8 @@ export const HeadingNode: Component<{
|
||||||
setIsExpanded(!isExpanded());
|
setIsExpanded(!isExpanded());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const scrollContainer = useScrollContainer();
|
||||||
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(href);
|
navigate(href);
|
||||||
|
|
@ -105,10 +108,18 @@ export const HeadingNode: Component<{
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const element = document.getElementById(anchor);
|
const element = document.getElementById(anchor);
|
||||||
if (element) {
|
if (element) {
|
||||||
|
const scroller = scrollContainer();
|
||||||
const navBarHeight = 80;
|
const navBarHeight = 80;
|
||||||
const elementPosition = element.getBoundingClientRect().top;
|
const elementPosition = element.getBoundingClientRect().top;
|
||||||
const offsetPosition = window.scrollY + elementPosition - navBarHeight;
|
const containerTop = scroller?.getBoundingClientRect().top ?? 0;
|
||||||
window.scrollTo({ top: offsetPosition, behavior: "instant" });
|
const relativePosition = elementPosition - containerTop;
|
||||||
|
const currentScroll = scroller?.scrollTop ?? window.scrollY;
|
||||||
|
const offsetPosition = currentScroll + relativePosition - navBarHeight;
|
||||||
|
if (scroller) {
|
||||||
|
scroller.scrollTo({ top: offsetPosition, behavior: "instant" });
|
||||||
|
} else {
|
||||||
|
window.scrollTo({ top: offsetPosition, behavior: "instant" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useArticleDom } from "./Article";
|
import { useArticleDom } from "./Article";
|
||||||
|
import { useScrollContainer } from "../App";
|
||||||
import { journalStreamState } from "./stores/journalStream";
|
import { journalStreamState } from "./stores/journalStream";
|
||||||
import { useJournalCompletions } from "./journal/completions";
|
import { useJournalCompletions } from "./journal/completions";
|
||||||
import {
|
import {
|
||||||
|
|
@ -75,6 +76,7 @@ function hide() {
|
||||||
|
|
||||||
export const RevealManager: Component = () => {
|
export const RevealManager: Component = () => {
|
||||||
const contentDom = useArticleDom();
|
const contentDom = useArticleDom();
|
||||||
|
const scrollContainer = useScrollContainer();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const comp = useJournalCompletions();
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
|
|
@ -179,8 +181,16 @@ export const RevealManager: Component = () => {
|
||||||
<button
|
<button
|
||||||
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
|
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
|
||||||
style={{
|
style={{
|
||||||
top: `${btn().rect.top + window.scrollY - 6}px`,
|
top: `${
|
||||||
left: `${btn().rect.left + window.scrollX - 20}px`,
|
btn().rect.top +
|
||||||
|
(scrollContainer()?.scrollTop ?? window.scrollY) -
|
||||||
|
6
|
||||||
|
}px`,
|
||||||
|
left: `${
|
||||||
|
btn().rect.left +
|
||||||
|
(scrollContainer()?.scrollLeft ?? window.scrollX) -
|
||||||
|
20
|
||||||
|
}px`,
|
||||||
}}
|
}}
|
||||||
title={btn().title}
|
title={btn().title}
|
||||||
innerHTML={btn().svg}
|
innerHTML={btn().svg}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 桌面端固定侧边栏
|
* 桌面端侧边栏 — flex child, no fixed positioning
|
||||||
*/
|
*/
|
||||||
export const DesktopSidebar: Component<{
|
export const DesktopSidebar: Component<{
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
|
|
@ -182,7 +182,7 @@ export const DesktopSidebar: Component<{
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside class="hidden md:block fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-30 overflow-hidden pt-16">
|
<aside class="hidden md:flex flex-col w-64 shrink-0 bg-white shadow-lg overflow-hidden">
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
fileTree={fileTree()}
|
fileTree={fileTree()}
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* JournalPanel — right panel container for the journal stream
|
* JournalPanel — right panel container for the journal stream
|
||||||
*
|
*
|
||||||
* Fixed overlay. Shows stream when connected, connect dialog when not.
|
* On mobile: fixed overlay with backdrop.
|
||||||
|
* On desktop: positioned by the parent flex container (width transition).
|
||||||
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
|
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -33,19 +34,19 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop — only on mobile, starts below header */}
|
{/* Mobile backdrop — overlay that appears below the header */}
|
||||||
<div
|
<div
|
||||||
class="fixed top-16 inset-x-0 bottom-0 bg-black/30 z-40 md:hidden"
|
class="fixed top-16 inset-x-0 bottom-0 bg-black/30 z-40 md:hidden"
|
||||||
classList={{ hidden: !props.open }}
|
classList={{ hidden: !props.open }}
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
/>
|
/>
|
||||||
{/* Panel — always mounted for exit animation, visibility toggled */}
|
{/* Panel — on mobile: fixed overlay; on desktop: fills the parent wrapper */}
|
||||||
<aside
|
<aside
|
||||||
class={`fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
|
class={`fixed top-16 right-0 bottom-0 z-50 bg-white
|
||||||
flex flex-col w-full max-w-md md:w-105 shadow-lg
|
flex flex-col w-full h-full max-w-md shadow-lg
|
||||||
transition-transform duration-300 ease-in-out ${
|
transition-transform duration-300 ease-in-out
|
||||||
props.open ? "translate-x-0" : "translate-x-full"
|
md:static md:inset-auto md:w-105 md:max-w-none md:shadow-none md:border-l md:border-gray-200
|
||||||
}`}
|
${props.open ? "translate-x-0" : "translate-x-full"}`}
|
||||||
inert={!props.open}
|
inert={!props.open}
|
||||||
>
|
>
|
||||||
<JournalContext.Provider value={{ onClose: props.onClose }}>
|
<JournalContext.Provider value={{ onClose: props.onClose }}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue