188 lines
7.4 KiB
JavaScript
188 lines
7.4 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||
import NoteAltIcon from '@mui/icons-material/NoteAlt';
|
||
import KeyIcon from '@mui/icons-material/Key';
|
||
|
||
/* ============================================================
|
||
* useWorkbenchLayout:VSCode 风格工作台布局状态(Lane A 新增)
|
||
*
|
||
* - 视图注册表 VIEWS:左侧主控(面板)+ 右侧功能区(记事本/密码箱)
|
||
* - order:{ left: [viewId], right: [viewId] } —— 视图在左右活动栏的归属
|
||
* (dnd-kit 拖拽换边后持久化,key: workbench.viewOrder)
|
||
* - active:{ left: viewId, right: viewId } —— 每侧当前显示的视图
|
||
* - mode:{ [viewId]: 'sidebar' | 'tab' | 'float' } —— 展示模式
|
||
* (sidebar 默认;tab 进主区标签页;float 为 P2 占位,本期不实现)
|
||
* - terminalOpen:底部终端面板是否展开
|
||
* ============================================================ */
|
||
|
||
/** 视图注册表:id → 元信息(新增视图只需在此登记 + Workbench 提供渲染) */
|
||
export const VIEWS = {
|
||
panels: { id: 'panels', label: '面板', Icon: WidgetsIcon, hint: '面板工作台' },
|
||
notes: { id: 'notes', label: '记事本', Icon: NoteAltIcon, hint: '笔记与文件' },
|
||
vault: { id: 'vault', label: '密码箱', Icon: KeyIcon, hint: '密码管理' },
|
||
};
|
||
export const VIEW_IDS = Object.keys(VIEWS);
|
||
|
||
export const VIEW_MODE_SIDEBAR = 'sidebar';
|
||
export const VIEW_MODE_TAB = 'tab';
|
||
export const VIEW_MODE_FLOAT = 'float'; // P2:本期仅占位
|
||
|
||
const STORE = {
|
||
order: 'workbench.viewOrder', // { left: [], right: [] }
|
||
active: 'workbench.viewActive', // { left: id, right: id }
|
||
mode: 'workbench.viewMode', // { id: 'sidebar'|'tab' }
|
||
terminal: 'workbench.terminalOpen',
|
||
};
|
||
|
||
const DEFAULT_ORDER = { left: ['panels'], right: ['notes', 'vault'] };
|
||
const DEFAULT_ACTIVE = { left: 'panels', right: 'notes' };
|
||
const DEFAULT_MODE = { panels: VIEW_MODE_SIDEBAR, notes: VIEW_MODE_SIDEBAR, vault: VIEW_MODE_SIDEBAR };
|
||
|
||
function read(key, fallback) {
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
return raw === null ? fallback : JSON.parse(raw);
|
||
} catch { return fallback; }
|
||
}
|
||
|
||
function write(key, value) {
|
||
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
|
||
}
|
||
|
||
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧,且保证每侧至少一个视图 */
|
||
function sanitizeOrder(saved) {
|
||
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
|
||
const seen = new Set();
|
||
const clean = (arr) => {
|
||
const out = [];
|
||
arr.forEach((id) => {
|
||
if (!VIEW_IDS.includes(id) || seen.has(id)) return; // 先查重,后登记
|
||
seen.add(id);
|
||
out.push(id);
|
||
});
|
||
return out;
|
||
};
|
||
const left = clean(saved.left);
|
||
const right = clean(saved.right);
|
||
VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); });
|
||
// 兜底:持久化可能把视图全挪到一侧(或空数组),刷新后另一侧空白打不开。
|
||
// 保证每侧至少一个视图(panels 优先左侧,notes 次之)。
|
||
if (left.length === 0 && right.length > 0) {
|
||
const preferred = ['panels', 'notes'].find((id) => right.includes(id));
|
||
const move = preferred || right[0];
|
||
left.push(move);
|
||
right.splice(right.indexOf(move), 1);
|
||
}
|
||
if (right.length === 0 && left.length > 0) {
|
||
const preferred = ['notes', 'vault'].find((id) => left.includes(id));
|
||
const move = preferred || left[0];
|
||
right.push(move);
|
||
left.splice(left.indexOf(move), 1);
|
||
}
|
||
return { left, right };
|
||
}
|
||
|
||
/** 校验 active:指向的视图必须仍在本侧,否则回退到该侧第一个 */
|
||
function sanitizeActive(saved, order) {
|
||
if (!saved || typeof saved !== 'object') return { ...DEFAULT_ACTIVE };
|
||
const pick = (side, fallback) => (
|
||
order[side].includes(saved[side]) ? saved[side] : (order[side][0] || fallback)
|
||
);
|
||
return { left: pick('left', 'panels'), right: pick('right', 'notes') };
|
||
}
|
||
|
||
/** 校验 mode:只接受 sidebar/tab(float 为保留值,不持久化激活) */
|
||
function sanitizeMode(saved) {
|
||
const out = { ...DEFAULT_MODE };
|
||
if (saved && typeof saved === 'object') {
|
||
VIEW_IDS.forEach((id) => {
|
||
if (saved[id] === VIEW_MODE_SIDEBAR || saved[id] === VIEW_MODE_TAB) out[id] = saved[id];
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export default function useWorkbenchLayout() {
|
||
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
|
||
const [active, setActive] = useState(() => sanitizeActive(read(STORE.active, null), order));
|
||
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
|
||
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
|
||
|
||
useEffect(() => write(STORE.order, order), [order]);
|
||
useEffect(() => write(STORE.active, active), [active]);
|
||
useEffect(() => write(STORE.mode, mode), [mode]);
|
||
useEffect(() => write(STORE.terminal, terminalOpen), [terminalOpen]);
|
||
|
||
/** 视图 id → 所在侧 */
|
||
const sideOf = useMemo(() => {
|
||
const m = {};
|
||
order.left.forEach((id) => { m[id] = 'left'; });
|
||
order.right.forEach((id) => { m[id] = 'right'; });
|
||
return m;
|
||
}, [order]);
|
||
|
||
const viewsOf = useCallback((side) => [...order[side]], [order]);
|
||
|
||
/** 每侧有效激活视图(active 指向已移走的视图时回退到第一个) */
|
||
const activeViewOf = useCallback(
|
||
(side) => (order[side].includes(active[side]) ? active[side] : order[side][0] || null),
|
||
[order, active]
|
||
);
|
||
|
||
/** 激活某视图(无论在哪侧) */
|
||
const activateView = useCallback((id) => {
|
||
const side = sideOf[id];
|
||
if (!side) return;
|
||
setActive((prev) => ({ ...prev, [side]: id }));
|
||
}, [sideOf]);
|
||
|
||
const setViewMode = useCallback((id, m) => {
|
||
if (!VIEW_IDS.includes(id)) return;
|
||
setMode((prev) => ({ ...prev, [id]: m }));
|
||
}, []);
|
||
|
||
/** 拖拽换边:视图从一侧移到另一侧,并激活目标侧 */
|
||
const moveView = useCallback((id, toSide) => {
|
||
if (toSide !== 'left' && toSide !== 'right') return;
|
||
setOrder((prev) => {
|
||
const fromSide = (['left', 'right']).find((s) => prev[s].includes(id));
|
||
if (!fromSide || fromSide === toSide) return prev;
|
||
const next = {
|
||
left: [...prev.left.filter((x) => x !== id)],
|
||
right: [...prev.right.filter((x) => x !== id)],
|
||
};
|
||
next[toSide].push(id);
|
||
return next;
|
||
});
|
||
setActive((prev) => ({ ...prev, [toSide]: id }));
|
||
}, []);
|
||
|
||
/** 同侧排序(dnd-kit arrayMove 语义) */
|
||
const reorderView = useCallback((id, overId) => {
|
||
setOrder((prev) => {
|
||
const side = (['left', 'right']).find((s) => prev[s].includes(id));
|
||
const overSide = (['left', 'right']).find((s) => prev[s].includes(overId));
|
||
if (!side || side !== overSide) return prev;
|
||
const list = prev[side];
|
||
const from = list.indexOf(id);
|
||
const to = list.indexOf(overId);
|
||
if (from < 0 || to < 0 || from === to) return prev;
|
||
const next = [...list];
|
||
next.splice(from, 1);
|
||
next.splice(to, 0, id);
|
||
return { ...prev, [side]: next };
|
||
});
|
||
}, []);
|
||
|
||
const toggleTerminal = useCallback(() => setTerminalOpen((v) => !v), []);
|
||
const openTerminal = useCallback(() => setTerminalOpen(true), []);
|
||
|
||
return {
|
||
order, sideOf, viewsOf,
|
||
active, activeViewOf, activateView,
|
||
mode, setViewMode,
|
||
moveView, reorderView,
|
||
terminalOpen, toggleTerminal, openTerminal,
|
||
};
|
||
}
|