feat(frontend): 「目录」列可折叠、记忆折叠状态(UX P2-2)
useChapterListCollapse 用 localStorage 记忆折叠态(SSR 首帧恒展开避免 hydration 不一致);收起降为带展开按钮的窄轨、让宽给正文,展开时标题旁加收起按钮;三栏栅格据折叠态切列宽,章节列表/高亮/切章导航全保留。
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
|
|
||||||
import { buildChapterEntries, type ChapterEntry } from "@/lib/workbench/chapter";
|
import { buildChapterEntries, type ChapterEntry } from "@/lib/workbench/chapter";
|
||||||
|
import { focusRing } from "@/lib/ui/variants";
|
||||||
|
|
||||||
interface ChapterListProps {
|
interface ChapterListProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -9,36 +11,83 @@ interface ChapterListProps {
|
|||||||
currentChapterNo: number;
|
currentChapterNo: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 左栏目录(UX §6.3):列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。
|
interface DesktopChapterListProps extends ChapterListProps {
|
||||||
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 ChapterListContent。
|
// 目录列收起态(P2-2):收起时只留一条带「展开」按钮的窄轨,让宽给正文。
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左栏目录(UX §6.3 / P2-2):列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。
|
||||||
|
// 桌面 aside 包裹,可收起为窄轨;移动端经 Workbench 抽屉复用 ChapterListContent(不带折叠)。
|
||||||
export function ChapterList({
|
export function ChapterList({
|
||||||
projectId,
|
projectId,
|
||||||
chapters,
|
chapters,
|
||||||
currentChapterNo,
|
currentChapterNo,
|
||||||
}: ChapterListProps) {
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
}: DesktopChapterListProps) {
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="hidden shrink-0 border-r border-line bg-panel py-4 xl:block"
|
||||||
|
aria-label="目录(已收起)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={false}
|
||||||
|
aria-label="展开目录"
|
||||||
|
title="展开目录"
|
||||||
|
className={`mx-auto flex h-8 w-8 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<aside className="hidden border-r border-line bg-panel py-4 xl:block">
|
<aside className="hidden border-r border-line bg-panel py-4 xl:block">
|
||||||
<ChapterListContent
|
<ChapterListContent
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
currentChapterNo={currentChapterNo}
|
currentChapterNo={currentChapterNo}
|
||||||
|
onCollapse={onToggle}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChapterListContentProps extends ChapterListProps {
|
||||||
|
// 桌面列传入 → 目录标题旁显示「收起」按钮;移动抽屉不传(抽屉自带关闭)。
|
||||||
|
onCollapse?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function ChapterListContent({
|
export function ChapterListContent({
|
||||||
projectId,
|
projectId,
|
||||||
chapters,
|
chapters,
|
||||||
currentChapterNo,
|
currentChapterNo,
|
||||||
}: ChapterListProps) {
|
onCollapse,
|
||||||
|
}: ChapterListContentProps) {
|
||||||
const entries = buildChapterEntries(chapters, currentChapterNo);
|
const entries = buildChapterEntries(chapters, currentChapterNo);
|
||||||
const hasOutline = chapters.length > 0;
|
const hasOutline = chapters.length > 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="px-4 text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
<div className="flex items-center justify-between px-4">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||||
目录
|
目录
|
||||||
</h2>
|
</h2>
|
||||||
|
{onCollapse ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCollapse}
|
||||||
|
aria-label="收起目录"
|
||||||
|
title="收起目录"
|
||||||
|
className={`flex h-6 w-6 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
|
||||||
|
>
|
||||||
|
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<ul className="mt-2">
|
<ul className="mt-2">
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const active = entry.no === currentChapterNo;
|
const active = entry.no === currentChapterNo;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { GenrePicker } from "./GenrePicker";
|
|||||||
import { GENRES } from "@/lib/wizard/wizard";
|
import { GENRES } from "@/lib/wizard/wizard";
|
||||||
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
|
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
|
||||||
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
||||||
|
import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse";
|
||||||
|
|
||||||
interface WorkbenchProps {
|
interface WorkbenchProps {
|
||||||
project: ProjectResponse;
|
project: ProjectResponse;
|
||||||
@@ -104,6 +105,8 @@ export function Workbench({
|
|||||||
// AC-3 AI 对话抽屉:Workbench 级单实例(append 即使抽屉关着也能触发);flag 灰度。
|
// AC-3 AI 对话抽屉:Workbench 级单实例(append 即使抽屉关着也能触发);flag 灰度。
|
||||||
const [conversationOpen, setConversationOpen] = useState(false);
|
const [conversationOpen, setConversationOpen] = useState(false);
|
||||||
const conversation = useAiConversation(project.id, chapterNo);
|
const conversation = useAiConversation(project.id, chapterNo);
|
||||||
|
// P2-2:桌面「目录」列可收起(记忆状态);收起时把宽让给正文,窄轨留一个展开入口。
|
||||||
|
const chapterList = useChapterListCollapse();
|
||||||
const conversationTriggerRef = useRef<HTMLButtonElement>(null);
|
const conversationTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||||
@@ -292,11 +295,19 @@ export function Workbench({
|
|||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
activeNav="write"
|
activeNav="write"
|
||||||
>
|
>
|
||||||
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] xl:grid-cols-[12rem_1fr_18rem]">
|
<div
|
||||||
|
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${
|
||||||
|
chapterList.collapsed
|
||||||
|
? "xl:grid-cols-[2.75rem_1fr_18rem]"
|
||||||
|
: "xl:grid-cols-[12rem_1fr_18rem]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<ChapterList
|
<ChapterList
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
currentChapterNo={chapterNo}
|
currentChapterNo={chapterNo}
|
||||||
|
collapsed={chapterList.collapsed}
|
||||||
|
onToggle={chapterList.toggle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="flex min-w-0 flex-col bg-bg">
|
<section className="flex min-w-0 flex-col bg-bg">
|
||||||
|
|||||||
64
apps/web/lib/workbench/useChapterListCollapse.test.ts
Normal file
64
apps/web/lib/workbench/useChapterListCollapse.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHAPTER_LIST_COLLAPSE_KEY,
|
||||||
|
useChapterListCollapse,
|
||||||
|
} from "./useChapterListCollapse";
|
||||||
|
|
||||||
|
// 本仓库 jsdom 未提供可用的 Storage(setItem/clear 缺失),装一个内存版 localStorage。
|
||||||
|
function installMemoryStorage(): Storage {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
const storage: Storage = {
|
||||||
|
get length() {
|
||||||
|
return map.size;
|
||||||
|
},
|
||||||
|
clear: () => map.clear(),
|
||||||
|
getItem: (key) => (map.has(key) ? (map.get(key) ?? null) : null),
|
||||||
|
key: (index) => Array.from(map.keys())[index] ?? null,
|
||||||
|
removeItem: (key) => map.delete(key),
|
||||||
|
setItem: (key, value) => map.set(key, String(value)),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: storage,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useChapterListCollapse", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
installMemoryStorage();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("默认展开(无存档时 collapsed=false)", () => {
|
||||||
|
const { result } = renderHook(() => useChapterListCollapse());
|
||||||
|
expect(result.current.collapsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle 翻转并把状态写入 localStorage", () => {
|
||||||
|
const { result } = renderHook(() => useChapterListCollapse());
|
||||||
|
|
||||||
|
act(() => result.current.toggle());
|
||||||
|
|
||||||
|
expect(result.current.collapsed).toBe(true);
|
||||||
|
expect(window.localStorage.getItem(CHAPTER_LIST_COLLAPSE_KEY)).toBe("1");
|
||||||
|
|
||||||
|
act(() => result.current.toggle());
|
||||||
|
|
||||||
|
expect(result.current.collapsed).toBe(false);
|
||||||
|
expect(window.localStorage.getItem(CHAPTER_LIST_COLLAPSE_KEY)).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("挂载时从 localStorage 恢复已收起状态", () => {
|
||||||
|
window.localStorage.setItem(CHAPTER_LIST_COLLAPSE_KEY, "1");
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChapterListCollapse());
|
||||||
|
|
||||||
|
expect(result.current.collapsed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
apps/web/lib/workbench/useChapterListCollapse.ts
Normal file
46
apps/web/lib/workbench/useChapterListCollapse.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// 「目录」列折叠偏好的持久化键(对齐既有 `ww.` 前缀约定)。
|
||||||
|
export const CHAPTER_LIST_COLLAPSE_KEY = "ww.workbench_chapter_list_collapsed";
|
||||||
|
|
||||||
|
export interface ChapterListCollapse {
|
||||||
|
// true=目录列收起(让宽给正文);false=展开。
|
||||||
|
collapsed: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(collapsed: boolean): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(CHAPTER_LIST_COLLAPSE_KEY, collapsed ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
// 隐私模式/配额异常忽略:折叠是纯 UI 偏好,写盘失败不影响导航功能。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记住「目录」列收起/展开状态(P2-2):SSR 首帧恒展开(避免 hydration 不一致),
|
||||||
|
// 挂载后再从 localStorage 恢复;toggle 即时写回。仅桌面(≥xl)用得上,窄屏走抽屉不受影响。
|
||||||
|
export function useChapterListCollapse(): ChapterListCollapse {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
setCollapsed(window.localStorage.getItem(CHAPTER_LIST_COLLAPSE_KEY) === "1");
|
||||||
|
} catch {
|
||||||
|
// 读盘失败则维持默认展开。
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
persist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { collapsed, toggle };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user