refactor: preserve URL search params during navigation

Introduce `useNavigateWithParams` to ensure that existing URL search
parameters are maintained when navigating between pages. This is
applied to the Article component, FileTree, and journal links to
prevent losing state (like session or player info) during client-side
navigation.

Also intercepts markdown anchor links in the Article component to use
client-side navigation.
This commit is contained in:
hypercross 2026-07-07 23:26:44 +08:00
parent 48776424a7
commit 779722fe85
4 changed files with 54 additions and 9 deletions

View File

@ -15,7 +15,7 @@ import { extractSection } from "../data-loader";
import mermaid from "mermaid"; import mermaid from "mermaid";
import { getIndexedData } from "../data-loader/file-index"; import { getIndexedData } from "../data-loader/file-index";
import { resolvePath } from "./utils/path"; import { resolvePath } from "./utils/path";
import { useLocation } from "@solidjs/router"; import { useNavigateWithParams } from "./useNavigateWithParams";
export interface ArticleProps { export interface ArticleProps {
src: string; src: string;
@ -76,7 +76,7 @@ function scrollToHash(hash: string) {
* src md markdown * src md markdown
*/ */
export const Article: Component<ArticleProps & ParentProps> = (props) => { export const Article: Component<ArticleProps & ParentProps> = (props) => {
const location = useLocation(); const navigate = useNavigateWithParams();
const [content, { refetch }] = createResource( const [content, { refetch }] = createResource(
() => ({ src: props.src, section: props.section }), () => ({ src: props.src, section: props.section }),
fetchArticleContent, fetchArticleContent,
@ -97,10 +97,33 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
void mermaid.run(); void mermaid.run();
// 内容渲染后检查 hash 并滚动 // 内容渲染后检查 hash 并滚动
scrollToHash(location.hash); scrollToHash(window.location.hash);
} }
}); });
// Intercept markdown <a> links to use client-side navigation with
// preserved URL search params.
createEffect(() => {
const dom = contentDom();
if (!dom) return;
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest("a[href]");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href) return;
// Only intercept same-origin navigation links (not external, not anchors)
if (href.startsWith("http") || href.startsWith("//")) return;
if (href.startsWith("#")) return;
e.preventDefault();
navigate(href);
};
dom.addEventListener("click", onClick);
onCleanup(() => dom.removeEventListener("click", onClick));
});
onCleanup(() => { onCleanup(() => {
// 清理时清空内容,触发内部组件的销毁 // 清理时清空内容,触发内部组件的销毁
}); });

View File

@ -1,6 +1,6 @@
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 { useNavigate } from "@solidjs/router"; import { useNavigateWithParams } from "./useNavigateWithParams";
/** /**
* *
@ -22,7 +22,7 @@ export const FileTreeNode: Component<{
onClose: () => void; onClose: () => void;
isHidden?: (node: FileNode) => boolean; isHidden?: (node: FileNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigate(); const navigate = useNavigateWithParams();
const isDir = !!props.node.children; const isDir = !!props.node.children;
const isActive = createMemo(() => props.currentPath === props.node.path); const isActive = createMemo(() => props.currentPath === props.node.path);
// 默认收起,除非当前文件在该文件夹内 // 默认收起,除非当前文件在该文件夹内
@ -85,7 +85,7 @@ export const HeadingNode: Component<{
depth: number; depth: number;
isHidden?: (node: TocNode) => boolean; isHidden?: (node: TocNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigate(); const navigate = useNavigateWithParams();
const anchor = props.node.id || ""; const anchor = props.node.id || "";
const href = `${props.basePath}#${anchor}`; const href = `${props.basePath}#${anchor}`;
const hasChildren = !!props.node.children; const hasChildren = !!props.node.children;
@ -99,6 +99,7 @@ export const HeadingNode: Component<{
} }
}; };
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
e.preventDefault();
navigate(href); navigate(href);
// 滚动到目标元素,考虑导航栏高度偏移 // 滚动到目标元素,考虑导航栏高度偏移
requestAnimationFrame(() => { requestAnimationFrame(() => {

View File

@ -6,7 +6,7 @@
*/ */
import { Component } from "solid-js"; import { Component } from "solid-js";
import { useNavigate } from "@solidjs/router"; import { useNavigateWithParams } from "../../useNavigateWithParams";
import { z } from "zod"; import { z } from "zod";
import { registerMessageType } from "../registry"; import { registerMessageType } from "../registry";
import { journalSetState } from "../../stores/journalStream"; import { journalSetState } from "../../stores/journalStream";
@ -22,7 +22,7 @@ export type LinkPayload = z.infer<typeof schema>;
function fileNameFromPath(path: string): string { function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean); const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path; return decodeURIComponent(parts[parts.length - 1] || path);
} }
/** Encode a path, preserving / separators but encoding each segment */ /** Encode a path, preserving / separators but encoding each segment */
@ -42,7 +42,7 @@ function slugToTitle(slug: string): string {
} }
const RevealLink: Component<LinkPayload> = (p) => { const RevealLink: Component<LinkPayload> = (p) => {
const navigate = useNavigate(); const navigate = useNavigateWithParams();
const target = () => { const target = () => {
let t = encodePath(p.path); let t = encodePath(p.path);

View File

@ -0,0 +1,21 @@
/**
* useNavigateWithParams wraps navigate() to preserve the current URL
* search params (e.g. ?session=abc&player=alice) when navigating to a
* new path.
*/
import { useNavigate, useLocation } from "@solidjs/router";
export function useNavigateWithParams() {
const navigate = useNavigate();
const location = useLocation();
return (to: string) => {
// Split hash from the path so we can re-attach it after search params
const hashIdx = to.indexOf("#");
const path = hashIdx >= 0 ? to.slice(0, hashIdx) : to;
const hash = hashIdx >= 0 ? to.slice(hashIdx) : "";
navigate(path + location.search + hash);
};
}