Skip to content

Commit 2b82b5e

Browse files
committed
feat(notes): 珊瑚礁视图与视口锚定——笔记深潜增强
- 新增 reef 组件族:AbyssView2D 深渊视图 / GrottoView3D 洞窟视图 / ReefCards 卡片场 / ReefDiverConsole 潜水控制台 - NotesPage 集成珊瑚礁沉浸视图、侧栏左右独立控制(互斥展开) - useEditorDepth history 访问类型加固(运行时可能 undefined) - AppLayout 面板视口锚定:Notes 无内边距贴边,grid 高度公式与面板实际内容区精确一致
1 parent 3a91f24 commit 2b82b5e

12 files changed

Lines changed: 1434 additions & 16 deletions

File tree

‎client/src/components/layout/AppLayout.tsx‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { PWAInstallPrompt } from '@/components/PWAInstallPrompt';
2727
import { FirstDiveGate } from '@/features/onboarding/firstDive/FirstDiveGate';
2828
import { usePerformanceModeStore } from '@/lib/performance/usePerformanceMode';
2929
import { PERFORMANCE_MODE_CONFIG } from '@/lib/performance/performanceMode';
30+
import { cn } from '@/lib/utils';
3031

3132
/**
3233
* 路由层级映射:子路由 -> 父路由
@@ -207,17 +208,34 @@ export default function AppLayout() {
207208
/* 萤火海沟自带全屏暗物质场:透明面板让深海背景透出,遮罩用深海底色实现整屏无缝(其他模块保持毛玻璃) */
208209
panelClassName={currentModule === 'inspiration'
209210
? '!bg-transparent !backdrop-blur-none !shadow-none !border-white/5'
210-
: isFullHeightRoute
211-
? '!h-full'
212-
: undefined}
211+
: currentModule === 'notes'
212+
? isFullHeightRoute
213+
? '!p-0 !h-full'
214+
: '!p-0'
215+
: isFullHeightRoute
216+
? '!h-full'
217+
: undefined}
213218
maskClassName={currentModule === 'inspiration' ? '!bg-[var(--kb-bg-primary)] !backdrop-blur-none' : undefined}
214219
>
215220
{/* 路由级过渡:grid 叠放 + 同期交叉淡入淡出。
216221
历史方案 mode="wait" 要求旧页退出回调交接后才挂载新页;在
217222
reduced-motion / StrictMode(dev) / HMR 等条件下交接会偶发丢失,
218223
新页永不挂载——即内测反馈"回到主页时主页不显示"的根因。
219224
交叉淡入淡出下新页立即挂载、旧页淡出后卸载,退出即使延迟也不阻塞内容展示 */}
220-
<div className="grid h-full">
225+
{/* grid 视口锚定:面板为 max-h + auto 容器,h-full 链会退化为内容自然高(页面随内容伸缩);
226+
显式锚定后所有 h-full 子页面继承此高度,与 module-content 面板内容区精确一致(无空隙无滚动)。
227+
公式 = 面板实际高度 - padding 垂直 - border*2:
228+
普通路由面板 sm+ max-h=85vh、小屏 100vh-5rem,padding md p-8=4rem / sm p-5=2.5rem / 小屏 p-3=1.5rem;
229+
Notes 面板 !p-0(去内边距、内容贴边):小屏 calc(100vh-5rem-2px)、sm+ calc(85vh-2px);
230+
full-height 路由面板 !h-full(h-full 链有效)保持 h-full 不动 */}
231+
<div className={cn(
232+
'grid h-full',
233+
!isFullHeightRoute && (
234+
currentModule === 'notes'
235+
? 'h-[calc(100vh-5rem-2px)] sm:h-[calc(85vh-2px)]'
236+
: 'h-[calc(100vh-6.5rem-2px)] sm:h-[calc(85vh-2.5rem-2px)] md:h-[calc(85vh-4rem-2px)]'
237+
),
238+
)}>
221239
<AnimatePresence>
222240
<motion.div
223241
key={pathname}
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/**
2+
* 沉降深渊 2D 视图(深色主题形态)
3+
* Sinking Abyss 2D view (dark theme morph)
4+
*
5+
* @ai-context: 深色模式沉降深渊以 2D 实现(零 WebGL)——纵向滚动容器承载
6+
* 非对称沉积的卡片流,滚轮下拉 = 原生滚动(触摸/键盘滚动条免费获得)。
7+
* 遗忘雾双层(宪法映射「遗忘=混沌雾 ≤40%」实际渲染):①世界雾带——绝对
8+
* 定位在深度坐标上(每 2.5 单位一条,透明度随深度 0.08→0.38 递增),
9+
* z-index 高于卡片,卡片滚动穿过时被真实遮挡(破雾感);②视口雾——顶部
10+
* 海面光 + 底部渐暗的氛围层。卡片按时间衰减对数连续分布(浅密深疏)、
11+
* 确定性非对称错落(浅层聚集、深层被洋流冲散)。
12+
* @ai-context: Dark morph renders the abyss in pure 2D (no WebGL): a
13+
* vertical scroll container holds asymmetrically deposited cards; wheel
14+
* down = native scrolling. Forgetting mist (constitution: chaos mist ≤40%)
15+
* is truly rendered via world fog bands that occlude cards when scrolled
16+
* through, plus viewport ambience.
17+
*/
18+
import { useCallback, useMemo, useRef, useState } from 'react';
19+
import { cn } from '@/lib/utils';
20+
import {
21+
ABYSS_LAYERS, TEMPLATE_COLORS, TEMPLATE_FALLBACK,
22+
hashId, seeded, nodeBrightness, nodeSize, type ReefNote,
23+
} from './reefTypes';
24+
import { ReefSelectedCard } from './ReefCards';
25+
import { useReefKeyboard } from './useReefKeyboard';
26+
27+
/** 深度 → 像素换算(px/单位) */
28+
const PX_PER_UNIT = 60;
29+
/** 最大深度(世界单位) */
30+
const MAX_DEPTH = 22;
31+
/** 海面留白(px) */
32+
const SURFACE_PAD = 160;
33+
/** 世界雾带间隔(世界单位) */
34+
const FOG_INTERVAL = 2.5;
35+
/** 卡片宽度基准(px,随 wordCount 微调) */
36+
const CARD_BASE_W = 200;
37+
38+
interface CardPos {
39+
top: number;
40+
leftPct: number;
41+
scale: number;
42+
width: number;
43+
}
44+
45+
/** 非对称沉积布局:时间衰减对数连续分布 + 确定性错落(浅层聚集、深层散开) */
46+
function computePositions(notes: ReefNote[]): Map<string, CardPos> {
47+
const result = new Map<string, CardPos>();
48+
const now = Date.now();
49+
for (const note of notes) {
50+
const ageDays = Math.max(0, (now - new Date(note.updatedAt).getTime()) / 86400000);
51+
const y = Math.min(0, -Math.min(MAX_DEPTH, Math.log1p(ageDays) * 3.2) + (seeded(hashId(note.id) * 3) - 0.5) * 2.4);
52+
const depthRatio = Math.min(1, -y / MAX_DEPTH);
53+
// 浅层聚集(±25%)、深层散开(±60%)——洋流冲刷沉积
54+
const spread = 0.25 + depthRatio * 0.35;
55+
const leftPct = 50 + (seeded(hashId(note.id) * 5) - 0.5) * 2 * spread * 100;
56+
result.set(note.id, {
57+
top: SURFACE_PAD + (-y) * PX_PER_UNIT,
58+
leftPct,
59+
scale: 1 - depthRatio * 0.35,
60+
width: CARD_BASE_W + nodeSize(note.wordCount) * 400,
61+
});
62+
}
63+
return result;
64+
}
65+
66+
interface AbyssView2DProps {
67+
notes: ReefNote[];
68+
selectedId: string | null;
69+
hoveredId: string | null;
70+
highlightIds?: ReadonlySet<string> | null;
71+
focusFolderId?: string | null;
72+
onHover: (id: string | null) => void;
73+
onSelect: (id: string) => void;
74+
onOpen: (id: string) => void;
75+
onExit: () => void;
76+
}
77+
78+
/** 沉降深渊(2D)/ Sinking Abyss (2D) */
79+
export function AbyssView2D({
80+
notes, selectedId, hoveredId, highlightIds, focusFolderId,
81+
onHover, onSelect, onOpen, onExit,
82+
}: AbyssView2DProps) {
83+
const scrollRef = useRef<HTMLDivElement>(null);
84+
const [scrollDepth, setScrollDepth] = useState(0);
85+
86+
const positions = useMemo(() => computePositions(notes), [notes]);
87+
const sortedCards = useMemo(
88+
() => [...notes].sort((a, b) => b.wordCount - a.wordCount),
89+
[notes],
90+
);
91+
const hoveredNote = useMemo(
92+
() => notes.find((n) => n.id === hoveredId) ?? null,
93+
[notes, hoveredId],
94+
);
95+
const selectedNote = useMemo(
96+
() => notes.find((n) => n.id === selectedId) ?? null,
97+
[notes, selectedId],
98+
);
99+
100+
// 键盘导航:方向键切换选中后滚动到卡片可见;Enter 打开
101+
const handleSelect = useCallback((id: string) => {
102+
onSelect(id);
103+
scrollRef.current?.querySelector(`[data-card-id="${id}"]`)?.scrollIntoView({ block: 'nearest' });
104+
}, [onSelect]);
105+
useReefKeyboard({
106+
cards: sortedCards,
107+
selectedId,
108+
onSelect: handleSelect,
109+
onOpen,
110+
enabled: notes.length > 0,
111+
});
112+
113+
// 滚动 → 深度(滚轮下拉 = 原生滚动)
114+
const handleScroll = useCallback(() => {
115+
const el = scrollRef.current;
116+
if (!el) return;
117+
setScrollDepth(Math.max(0, (el.scrollTop - SURFACE_PAD) / PX_PER_UNIT));
118+
}, []);
119+
120+
const currentLayer = Math.min(
121+
ABYSS_LAYERS.length - 1,
122+
Math.max(0, Math.round(scrollDepth / 5)),
123+
);
124+
125+
// 世界雾带:深度坐标固定,透明度随深度递增(宪法 ≤40%)
126+
const fogBands = useMemo(() => {
127+
const bands: Array<{ top: number; opacity: number }> = [];
128+
const count = Math.ceil(MAX_DEPTH / FOG_INTERVAL);
129+
for (let i = 0; i < count; i++) {
130+
bands.push({
131+
top: SURFACE_PAD + i * FOG_INTERVAL * PX_PER_UNIT,
132+
opacity: 0.08 + ((i + 0.5) * FOG_INTERVAL / MAX_DEPTH) * 0.3,
133+
});
134+
}
135+
return bands;
136+
}, []);
137+
138+
// 层名刻度(海面/浅海/深海/海沟参考线)
139+
const layerMarks = useMemo(
140+
() => ABYSS_LAYERS.map((l) => ({ name: l.name, top: SURFACE_PAD + (-l.y) * PX_PER_UNIT })),
141+
[],
142+
);
143+
144+
if (notes.length === 0) {
145+
return (
146+
<div className="h-full flex flex-col items-center justify-center gap-3">
147+
<p className="text-b2 text-text-tertiary">深海寂静——还没有笔记</p>
148+
<button
149+
onClick={onExit}
150+
className="px-3 py-1.5 rounded-kb-full text-c1 font-medium text-brand-600 hover:bg-brand-500/10 transition-colors"
151+
>
152+
返回列表创建
153+
</button>
154+
</div>
155+
);
156+
}
157+
158+
return (
159+
<div className="relative w-full h-full overflow-hidden">
160+
{/* 纵向滚动容器(滚轮下拉 = 原生滚动) */}
161+
<div
162+
ref={scrollRef}
163+
onScroll={handleScroll}
164+
className="h-full overflow-y-auto [scrollbar-width:thin] scroll-smooth"
165+
>
166+
<div className="relative mx-auto" style={{ height: SURFACE_PAD + MAX_DEPTH * PX_PER_UNIT, maxWidth: 920 }}>
167+
{/* 深度背景渐变(深处渐暗) */}
168+
<div
169+
className="absolute inset-0 pointer-events-none"
170+
style={{ background: 'linear-gradient(to bottom, rgba(74,155,217,0.14), rgba(10,22,32,0) 14%, rgba(4,10,16,0.45) 85%, rgba(2,6,10,0.9))' }}
171+
aria-hidden="true"
172+
/>
173+
174+
{/* 层名刻度 */}
175+
{layerMarks.map((m) => (
176+
<div key={m.name} className="absolute left-3 z-[5] flex items-center gap-2 pointer-events-none" style={{ top: m.top }}>
177+
<span className="text-[10px] tracking-[0.25em] uppercase text-text-tertiary/60">{m.name}</span>
178+
<span className="h-px w-14 bg-border/30" aria-hidden="true" />
179+
</div>
180+
))}
181+
182+
{/* 卡片层(非对称沉积) */}
183+
{sortedCards.map((note) => {
184+
const pos = positions.get(note.id);
185+
if (!pos) return null;
186+
const isSelected = note.id === selectedId;
187+
const isHovered = note.id === hoveredId;
188+
const color = TEMPLATE_COLORS.abyss[note.template] ?? TEMPLATE_FALLBACK.abyss;
189+
const dim = (focusFolderId != null && note.folderId !== focusFolderId)
190+
|| (highlightIds != null && !highlightIds.has(note.id));
191+
return (
192+
<button
193+
key={note.id}
194+
data-card-id={note.id}
195+
onClick={() => onSelect(note.id)}
196+
onDoubleClick={() => onOpen(note.id)}
197+
onMouseEnter={() => onHover(note.id)}
198+
onMouseLeave={() => onHover(null)}
199+
className={cn(
200+
'absolute z-10 -translate-x-1/2 text-left rounded-kb-lg overflow-hidden',
201+
'bg-[rgba(13,26,38,0.92)] border backdrop-blur-sm',
202+
'transition-all duration-300 cursor-pointer',
203+
isSelected
204+
? 'border-brand-400/80 shadow-[0_0_24px_rgba(64,171,146,0.35)]'
205+
: isHovered
206+
? 'border-cyber/50 shadow-[0_0_18px_rgba(74,155,217,0.28)]'
207+
: 'border-border/40 hover:border-cyber/40',
208+
)}
209+
style={{
210+
top: pos.top,
211+
left: `${pos.leftPct}%`,
212+
width: pos.width,
213+
transform: `translateX(-50%) scale(${isHovered || isSelected ? pos.scale * 1.06 : pos.scale})`,
214+
opacity: dim ? 0.35 : 1,
215+
boxShadow: undefined,
216+
}}
217+
aria-label={`${note.title || '无标题'},${note.wordCount} 字${isSelected ? ',已选中' : ''}`}
218+
>
219+
{/* 模板色条 */}
220+
<span className="absolute left-0 top-0 bottom-0 w-[4px]" style={{ background: color }} aria-hidden="true" />
221+
<span className="block px-3.5 py-2.5">
222+
<span className="block text-[13px] font-medium text-[#E2EAF2] truncate">
223+
{note.title || '无标题'}
224+
</span>
225+
<span className="block text-[11px] text-[#647B90] mt-1 truncate">
226+
{note.wordCount} 字{note.tags[0] ? ` · #${note.tags[0]}` : ''}
227+
</span>
228+
</span>
229+
</button>
230+
);
231+
})}
232+
233+
{/* 世界雾带(z-20:真实遮挡下方卡片,滚动穿过有破雾感) */}
234+
{fogBands.map((band, i) => (
235+
<div
236+
key={i}
237+
className="absolute left-0 right-0 z-20 pointer-events-none"
238+
style={{
239+
top: band.top - FOG_INTERVAL * PX_PER_UNIT / 2,
240+
height: FOG_INTERVAL * PX_PER_UNIT,
241+
opacity: band.opacity,
242+
background: 'linear-gradient(to bottom, rgba(10,22,32,0), rgba(10,22,32,0.85) 50%, rgba(10,22,32,0))',
243+
}}
244+
aria-hidden="true"
245+
/>
246+
))}
247+
</div>
248+
</div>
249+
250+
{/* 视口雾(氛围:顶部海面光 + 底部渐暗,跟随视口) */}
251+
<div
252+
className="absolute inset-x-0 top-0 h-16 z-30 pointer-events-none"
253+
style={{ background: 'radial-gradient(ellipse at 50% 0%, rgba(74,155,217,0.16), transparent 70%)' }}
254+
aria-hidden="true"
255+
/>
256+
<div
257+
className="absolute inset-x-0 bottom-0 h-10 z-30 pointer-events-none"
258+
style={{ background: 'linear-gradient(to top, rgba(2,6,10,0.55), transparent)' }}
259+
aria-hidden="true"
260+
/>
261+
262+
{/* 深度指示(左上) */}
263+
<div className="absolute left-4 top-4 z-40 pointer-events-none flex flex-col gap-1">
264+
<span className="text-[11px] tracking-[0.3em] text-text-tertiary/70 uppercase">Depth</span>
265+
<span className="text-b1 font-semibold text-text-primary">{ABYSS_LAYERS[currentLayer].name}</span>
266+
<span className="text-c1 text-text-tertiary tabular-nums">{Math.round(scrollDepth)}m / {MAX_DEPTH}m</span>
267+
</div>
268+
269+
{/* 操作提示(左下) */}
270+
<p className="absolute left-4 bottom-4 z-40 text-c1 text-text-tertiary/70 pointer-events-none">
271+
滚轮下潜 · 单击选中 · 双击/Enter 打开 · 方向键切换
272+
</p>
273+
274+
{/* 悬停小卡(右上) */}
275+
{hoveredNote && (
276+
<div className="absolute right-4 top-4 z-40 px-3 py-2 rounded-kb-md bg-bg-elevated/90 backdrop-blur border border-border/40 text-c1 text-text-primary shadow-kb-sm pointer-events-none max-w-[220px]">
277+
<span className="block truncate">{hoveredNote.title || '无标题'}</span>
278+
<span className="block text-c2 text-text-tertiary mt-0.5">
279+
亮度 {Math.round(nodeBrightness(hoveredNote) * 100)}% · {hoveredNote.tags[0] ? `#${hoveredNote.tags[0]}` : ''}
280+
</span>
281+
</div>
282+
)}
283+
284+
{/* 选中详情卡 */}
285+
{selectedNote && <ReefSelectedCard note={selectedNote} onOpen={onOpen} />}
286+
</div>
287+
);
288+
}
289+
290+
export default AbyssView2D;

0 commit comments

Comments
 (0)