Skip to content

Commit 1d2833b

Browse files
feat: site update, dragable sidebars (#85)
1 parent d3e72e4 commit 1d2833b

5 files changed

Lines changed: 274 additions & 0 deletions

File tree

site/.vitepress/config/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ export default withDrawio(defineConfig({
8989
{},
9090
`(function(){try{var s=localStorage.getItem('vp-font-size')||'normal';if(s!=='xxsmall'&&s!=='small'&&s!=='normal'&&s!=='large'&&s!=='xxlarge'){s='normal';}document.documentElement.dataset.fontSize=s;}catch(e){}})()`,
9191
],
92+
// 首屏立即应用侧栏宽度(左导航 + 右大纲),防刷新闪烁。key 与 ResizableSidebar.vue 一致。
93+
[
94+
'script',
95+
{},
96+
`(function(){try{var w=parseInt(localStorage.getItem('vp-sidebar-width'));if(!w||w<200||w>480){w=272;}document.documentElement.style.setProperty('--vp-sidebar-width',w+'px');var a=parseInt(localStorage.getItem('vp-aside-width'));if(!a||a<180||a>360){a=256;}document.documentElement.style.setProperty('--vp-aside-width',a+'px');}catch(e){}})()`,
97+
],
9298
],
9399

94100
markdown: sharedMarkdown,

site/.vitepress/config/shared.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ export const sharedBase = {
6262
{},
6363
`(function(){try{var s=localStorage.getItem('vp-font-size')||'normal';if(s!=='xxsmall'&&s!=='small'&&s!=='normal'&&s!=='large'&&s!=='xxlarge'){s='normal';}document.documentElement.dataset.fontSize=s;}catch(e){}})()`,
6464
],
65+
// 首屏立即应用侧栏宽度(左导航 + 右大纲),防刷新闪烁。key 与 ResizableSidebar.vue 一致。
66+
[
67+
'script',
68+
{},
69+
`(function(){try{var w=parseInt(localStorage.getItem('vp-sidebar-width'));if(!w||w<200||w>480){w=272;}document.documentElement.style.setProperty('--vp-sidebar-width',w+'px');var a=parseInt(localStorage.getItem('vp-aside-width'));if(!a||a<180||a>360){a=256;}document.documentElement.style.setProperty('--vp-aside-width',a+'px');}catch(e){}})()`,
70+
],
6571
],
6672

6773
markdown: sharedMarkdown,
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<script setup lang="ts">
2+
import { onMounted, onBeforeUnmount, ref } from 'vue'
3+
4+
// 可拖拽侧栏宽度:左导航树 + 右大纲栏(TOC)。
5+
// 左栏 --vp-sidebar-width 由 VitePress 全链路消费(sidebar 自身 / 正文 padding-left / 顶栏对齐),
6+
// 改这一个变量即全联动。但 handle 的定位与拖动计算必须读 .VPSidebar 的实际几何 —— 宽屏
7+
// (≥1440px)布局居中,sidebar 左缘非 0、右缘带 (vw-maxW)/2 偏移,且受滚动条宽度影响,
8+
// CSS 公式推算总有小偏差。故 left 一律用 JS 读 getBoundingClientRect 精确设定(与右 handle 同策略)。
9+
// 右栏 --vp-aside-width 自定义变量(在 custom.css 覆盖 aside max-width);右 handle absolute
10+
// 注入 aside 内,MutationObserver 在路由切换重建 aside 时重新注入。
11+
// 宽度持久化 localStorage;首屏防闪由 config head 内联脚本(hydration 前注入变量)负责。
12+
13+
type Side = 'left' | 'right'
14+
interface Dim { min: number; max: number; def: number; key: string; cssVar: string }
15+
16+
const CONF: Record<Side, Dim> = {
17+
left: { min: 200, max: 480, def: 272, key: 'vp-sidebar-width', cssVar: '--vp-sidebar-width' },
18+
right: { min: 180, max: 360, def: 256, key: 'vp-aside-width', cssVar: '--vp-aside-width' },
19+
}
20+
21+
const leftHandle = ref<HTMLElement | null>(null)
22+
const RIGHT_HANDLE_ID = 'rs-right-handle'
23+
24+
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
25+
const applyVar = (side: Side, px: number) =>
26+
document.documentElement.style.setProperty(CONF[side].cssVar, px + 'px')
27+
const persist = (side: Side, px: number) => {
28+
try { localStorage.setItem(CONF[side].key, String(px)) } catch { /* 隐私模式 / 配额 */ }
29+
}
30+
31+
interface DragCtx {
32+
side: Side
33+
dim: Dim
34+
lastV: number
35+
handle: HTMLElement
36+
onMove: (e: MouseEvent) => void
37+
onUp: () => void
38+
}
39+
let drag: DragCtx | null = null
40+
41+
function startDrag(side: Side, e: MouseEvent) {
42+
e.preventDefault()
43+
const dim = CONF[side]
44+
const handle = e.currentTarget as HTMLElement
45+
handle.classList.add('is-active')
46+
document.body.classList.add('rs-resizing')
47+
48+
// 左栏:sidebar 左缘在居中布局下非 0,新宽 = 鼠标x − 左缘
49+
const sidebarLeft =
50+
side === 'left'
51+
? (document.querySelector('.VPSidebar') as HTMLElement | null)?.getBoundingClientRect().left ?? 0
52+
: 0
53+
54+
const onMove = (ev: MouseEvent) => {
55+
let v: number
56+
if (side === 'left') {
57+
v = ev.clientX - sidebarLeft
58+
} else {
59+
// 右栏:aside 右边缘固定(贴容器右内边),新宽 = 右边缘 − 鼠标 x
60+
const aside = document.querySelector('.VPDoc.has-aside .aside') as HTMLElement | null
61+
v = aside ? aside.getBoundingClientRect().right - ev.clientX : dim.def
62+
}
63+
if (drag) drag.lastV = clamp(Math.round(v), dim.min, dim.max)
64+
applyVar(side, drag ? drag.lastV : dim.def)
65+
if (side === 'left' && drag?.handle) {
66+
// handle 跟随 sidebar 右缘(左缘 + 当前宽)
67+
drag.handle.style.left = (sidebarLeft + drag.lastV) + 'px'
68+
}
69+
}
70+
const onUp = () => {
71+
if (drag) persist(side, drag.lastV)
72+
handle.classList.remove('is-active')
73+
document.body.classList.remove('rs-resizing')
74+
document.removeEventListener('mousemove', onMove)
75+
document.removeEventListener('mouseup', onUp)
76+
drag = null
77+
}
78+
drag = { side, dim, lastV: dim.def, handle, onMove, onUp }
79+
document.addEventListener('mousemove', onMove)
80+
document.addEventListener('mouseup', onUp)
81+
}
82+
83+
function reset(side: Side) {
84+
applyVar(side, CONF[side].def)
85+
persist(side, CONF[side].def)
86+
if (side === 'left') updateLeftPosition()
87+
}
88+
89+
// 仅在有侧栏的页(文档页)显示左 handle;首页等无侧栏页隐藏
90+
function updateLeftVisibility() {
91+
if (!leftHandle.value) return
92+
leftHandle.value.style.display = document.querySelector('.VPSidebar') ? '' : 'none'
93+
}
94+
95+
// 左 handle 精确定位:用 offsetLeft + offsetWidth(不含 transform),避开 sidebar 入场过渡
96+
// (translateX(-100%)→0)对 getBoundingClientRect 的干扰 —— 首屏即读到最终右边缘,无需等动画结束。
97+
function updateLeftPosition() {
98+
if (!leftHandle.value) return
99+
const sb = document.querySelector('.VPSidebar') as HTMLElement | null
100+
if (sb) leftHandle.value.style.left = (sb.offsetLeft + sb.offsetWidth) + 'px'
101+
}
102+
103+
function injectRightHandle() {
104+
const aside = document.querySelector('.VPDoc.has-aside .aside') as HTMLElement | null
105+
if (!aside || aside.querySelector('#' + RIGHT_HANDLE_ID)) return
106+
const handle = document.createElement('div')
107+
handle.id = RIGHT_HANDLE_ID
108+
handle.className = 'rs-handle rs-handle--right'
109+
handle.setAttribute('role', 'separator')
110+
handle.setAttribute('aria-orientation', 'vertical')
111+
handle.setAttribute('aria-label', '拖动调整右侧大纲栏宽度(双击重置)')
112+
handle.addEventListener('mousedown', (ev) => startDrag('right', ev))
113+
handle.addEventListener('dblclick', () => reset('right'))
114+
aside.style.position = 'relative'
115+
aside.appendChild(handle)
116+
}
117+
118+
let observer: MutationObserver | null = null
119+
let leftTimer = 0
120+
const onMutate = () => {
121+
updateLeftVisibility()
122+
updateLeftPosition()
123+
injectRightHandle()
124+
}
125+
126+
onMounted(() => {
127+
// 恢复已存宽度(防闪脚本已在首屏注入,这里做内存兜底)
128+
;(['left', 'right'] as Side[]).forEach((side) => {
129+
const dim = CONF[side]
130+
try {
131+
const v = parseInt(localStorage.getItem(dim.key) || '')
132+
if (v >= dim.min && v <= dim.max) applyVar(side, v)
133+
} catch {}
134+
})
135+
onMutate()
136+
// sidebar 桌面端有 transform 入场过渡(translateX(-100%)→0,约 0.25s),过渡中
137+
// getBoundingClientRect 偏左,导致 handle 初始错位(拖动后才贴合)。
138+
// 多时机补校准,确保首屏即贴合:下一帧 / 过渡结束后(~350ms)/ 页面 load 后。
139+
requestAnimationFrame(updateLeftPosition)
140+
leftTimer = window.setTimeout(updateLeftPosition, 350)
141+
window.addEventListener('resize', updateLeftPosition, { passive: true })
142+
if (document.readyState !== 'complete') {
143+
window.addEventListener('load', updateLeftPosition)
144+
}
145+
const root = document.querySelector('.VPContent') || document.body
146+
if ('MutationObserver' in window) {
147+
observer = new MutationObserver(onMutate)
148+
observer.observe(root, { childList: true, subtree: true })
149+
}
150+
})
151+
152+
onBeforeUnmount(() => {
153+
observer?.disconnect()
154+
observer = null
155+
window.clearTimeout(leftTimer)
156+
window.removeEventListener('resize', updateLeftPosition)
157+
window.removeEventListener('load', updateLeftPosition)
158+
document.querySelectorAll('#' + RIGHT_HANDLE_ID).forEach((n) => n.remove())
159+
if (drag) {
160+
document.removeEventListener('mousemove', drag.onMove)
161+
document.removeEventListener('mouseup', drag.onUp)
162+
drag = null
163+
}
164+
})
165+
</script>
166+
167+
<template>
168+
<div
169+
ref="leftHandle"
170+
class="rs-handle rs-handle--left"
171+
role="separator"
172+
aria-orientation="vertical"
173+
aria-label="拖动调整左侧导航栏宽度(双击重置)"
174+
@mousedown="startDrag('left', $event)"
175+
@dblclick="reset('left')"
176+
></div>
177+
</template>

site/.vitepress/theme/custom.css

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,6 +1252,89 @@ html.dark .online-compiler-demo__source-highlight .shiki span {
12521252
font-size: 0.85em;
12531253
}
12541254

1255+
/* ================================================================
1256+
Resizable Sidebar(可拖拽侧栏宽度)
1257+
组件: theme/components/ResizableSidebar.vue
1258+
左 handle 在组件模板内(fixed),右 handle 运行时注入 .aside(absolute)。
1259+
宽度由 --vp-sidebar-width(VitePress 原生,全链路消费)与 --vp-aside-width(本站自定义)驱动。
1260+
================================================================ */
1261+
1262+
:root {
1263+
--vp-aside-width: 256px; /* 右大纲栏宽度变量,默认与 VitePress 原值一致 */
1264+
}
1265+
1266+
/* 右栏宽度由变量驱动,覆盖 VitePress 的固定 max-width / width */
1267+
.VPDoc.has-aside .aside {
1268+
max-width: var(--vp-aside-width);
1269+
position: relative; /* 给注入的 absolute handle 提供锚点 */
1270+
}
1271+
.VPDoc .aside-container {
1272+
width: calc(var(--vp-aside-width) - 32px); /* 保持原 32px 内边距逻辑(原 max-width 256 → container 224) */
1273+
}
1274+
1275+
/* 手柄公共态:常显一条 1px 淡灰细线(::before),悬浮 / 拖拽时变品牌色加粗。
1276+
命中热区 8px(透明本体),避免过窄扫不到。z-index 压在 sidebar 之上,防止被侧栏盖住。 */
1277+
.rs-handle {
1278+
z-index: calc(var(--vp-z-index-sidebar) + 1);
1279+
width: 8px;
1280+
background: transparent;
1281+
cursor: col-resize;
1282+
}
1283+
.rs-handle::before {
1284+
content: '';
1285+
position: absolute;
1286+
left: 50%;
1287+
top: 0;
1288+
bottom: 0;
1289+
width: 1px;
1290+
transform: translateX(-50%);
1291+
background: var(--vp-c-divider);
1292+
transition: width 0.15s ease, background-color 0.15s ease;
1293+
}
1294+
.rs-handle:hover::before,
1295+
.rs-handle.is-active::before {
1296+
width: 2px;
1297+
background: var(--vp-c-brand-1);
1298+
}
1299+
1300+
/* 左手柄:fixed。left 仅作 SSR 首屏 fallback,onMounted 后由 JS 读取 .VPSidebar 右边缘精确
1301+
覆盖 —— 宽屏(≥1440px)布局居中,右缘带 (vw-maxW)/2 偏移且受滚动条影响,CSS 公式推算
1302+
总有小偏差,改读 getBoundingClientRect 根治(与右 handle 同策略)。 */
1303+
.rs-handle--left {
1304+
position: fixed;
1305+
left: var(--vp-sidebar-width);
1306+
margin-left: -4px;
1307+
top: var(--vp-nav-height, 64px);
1308+
bottom: 0;
1309+
}
1310+
1311+
/* 右手柄:absolute 注入 .aside 内,居中压在其左边缘(默认隐藏,仅大屏显示见下方媒体查询) */
1312+
.rs-handle--right {
1313+
position: absolute;
1314+
left: -4px;
1315+
top: 0;
1316+
bottom: 0;
1317+
display: none;
1318+
}
1319+
1320+
/* 拖拽进行中:禁选中文,全屏 col-resize 光标 */
1321+
body.rs-resizing {
1322+
user-select: none;
1323+
cursor: col-resize;
1324+
}
1325+
1326+
/* 响应式:左栏 <960px 沉为抽屉;右栏 aside 仅 ≥1280px 显示 */
1327+
@media (max-width: 959px) {
1328+
.rs-handle--left {
1329+
display: none;
1330+
}
1331+
}
1332+
@media (min-width: 1280px) {
1333+
.rs-handle--right {
1334+
display: block;
1335+
}
1336+
}
1337+
12551338
/* ================================================================
12561339
Content Page Enhancements
12571340
================================================================ */

site/.vitepress/theme/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import HomeHeroVisual from './components/HomeHeroVisual.vue'
1414
import ProofStrip from './components/ProofStrip.vue'
1515
import HomeRoadmap from './components/HomeRoadmap.vue'
1616
import FontSizeSwitcher from './components/FontSizeSwitcher.vue'
17+
import ResizableSidebar from './components/ResizableSidebar.vue'
1718
import { setupMermaid } from './mermaid-client'
1819
import './custom.css'
1920

2021
export default {
2122
extends: DefaultTheme,
2223
Layout() {
2324
return h(DefaultTheme.Layout, null, {
25+
'layout-top': () => h(ResizableSidebar),
2426
'home-hero-image': () => h(HomeHeroVisual),
2527
'home-hero-actions-after': () => h('div', { class: 'proof-on-mobile' }, [h(ProofStrip)]),
2628
'home-hero-after': () => h('div', { class: 'proof-on-desktop' }, [h(ProofStrip)]),

0 commit comments

Comments
 (0)