优化了移动端的多个UI

This commit is contained in:
2026-03-04 11:27:41 +08:00
parent 087323e58a
commit 4bbfbede01
10 changed files with 221 additions and 136 deletions
+1
View File
@@ -2,6 +2,7 @@
# dependencies
/node_modules
/release
/.pnp
.pnp.*
.yarn/*
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -14
View File
@@ -179,10 +179,10 @@
padding-bottom: 120px;
}
/* 移动端侧边栏文档树:增大字号触摸目标 */
/* 移动端侧边栏文档树:适中字号触摸目标 */
.sidebar-tree-item {
font-size: 0.9375rem !important;
min-height: 2.5rem !important;
min-height: 2.3rem !important;
line-height: 1.2;
}
@@ -292,7 +292,7 @@
/* Table Styles - Ensure content is compact */
.ProseMirror table p {
margin: 0;
line-height: var(--table-line-height, 1.2);
line-height: var(--table-line-height, 1.5);
}
/* 利用 TipTap 表格扩展的 .tableWrapper 包裹层实现横向滚动 */
@@ -315,7 +315,7 @@
border-color: hsl(var(--border));
box-sizing: border-box;
min-width: 1em;
padding: 2px 4px;
padding: 6px 8px;
position: relative;
vertical-align: top;
}
@@ -393,13 +393,3 @@ ul[data-type="taskList"],
white-space: nowrap;
line-height: 1.2;
}
@media (min-width: 768px) {
.sidebar-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
max-height: calc(1.25em * 2);
}
}
+69 -7
View File
@@ -6,8 +6,7 @@ import { useEditorStore } from "@/lib/store";
import { Hash, X, Plus, ChevronLeft, ChevronRight, ChevronDown, Sparkles, Lock, Unlock, FolderOpen, History, RotateCcw } from "lucide-react";
import { useSettingsStore } from "@/lib/settings-store";
import { exportPageAsMarkdown } from "@/lib/export";
import { useMemo, useState } from "react";
import { useEffect } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { cn, getTagColor } from "@/lib/utils";
import { useSearchStore } from "@/lib/search-store";
import { type Editor as TiptapEditor } from "@tiptap/react";
@@ -39,6 +38,9 @@ export default function Home() {
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(() => !activePageId);
const edgeSwipeStartRef = useRef<{ x: number; y: number } | null>(null);
const drawerSwipeStartRef = useRef<{ x: number; y: number } | null>(null);
const breadcrumbs = useMemo(() => {
if (!activePage) return [];
@@ -123,25 +125,85 @@ export default function Home() {
};
}, [flushPendingSyncs]);
const handleEdgeSwipeStart = (e: React.TouchEvent<HTMLDivElement>) => {
if (isMobileSidebarOpen) return;
const touch = e.touches[0];
if (!touch || touch.clientX > 24) return;
edgeSwipeStartRef.current = { x: touch.clientX, y: touch.clientY };
};
const handleEdgeSwipeEnd = (e: React.TouchEvent<HTMLDivElement>) => {
const start = edgeSwipeStartRef.current;
edgeSwipeStartRef.current = null;
if (!start || isMobileSidebarOpen) return;
const touch = e.changedTouches[0];
if (!touch) return;
const deltaX = touch.clientX - start.x;
const deltaY = Math.abs(touch.clientY - start.y);
if (deltaX > 56 && deltaY < 42) {
setIsMobileSidebarOpen(true);
}
};
const handleDrawerSwipeStart = (e: React.TouchEvent<HTMLDivElement>) => {
const touch = e.touches[0];
if (!touch) return;
drawerSwipeStartRef.current = { x: touch.clientX, y: touch.clientY };
};
const handleDrawerSwipeEnd = (e: React.TouchEvent<HTMLDivElement>) => {
const start = drawerSwipeStartRef.current;
drawerSwipeStartRef.current = null;
if (!start) return;
const touch = e.changedTouches[0];
if (!touch) return;
const deltaX = touch.clientX - start.x;
const deltaY = Math.abs(touch.clientY - start.y);
if (deltaX < -56 && deltaY < 42) {
setIsMobileSidebarOpen(false);
}
};
return (
<ImportProvider>
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
<div
className="relative flex h-[100dvh] w-full overflow-hidden bg-background"
onTouchStart={handleEdgeSwipeStart}
onTouchEnd={handleEdgeSwipeEnd}
>
<SearchCommand />
<div className={cn(activePageId ? "hidden" : "block h-full flex-1 md:hidden")}>
<SidebarContent />
<div
className={cn(
"fixed inset-0 z-40 md:hidden transition-opacity duration-200",
isMobileSidebarOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
)}
aria-hidden={!isMobileSidebarOpen}
>
<div
className={cn(
"absolute inset-0 w-full border-r border-border bg-secondary/95 backdrop-blur-sm transition-transform duration-200 ease-out",
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
onTouchStart={handleDrawerSwipeStart}
onTouchEnd={handleDrawerSwipeEnd}
>
<SidebarContent onCloseMobile={() => setIsMobileSidebarOpen(false)} />
</div>
</div>
<ResizableSidebar />
<main className={cn("relative z-0 flex h-full flex-1 flex-col overflow-hidden", !activePageId && "hidden md:flex")}>
<main className={cn("relative z-0 flex h-full flex-1 flex-col overflow-hidden", isMobileSidebarOpen && "hidden md:flex")}>
{activePage ? (
<div className="flex-1 overflow-y-auto scroll-smooth">
<div className="mx-auto min-h-screen max-w-7xl px-3 py-4 md:px-16 md:py-6">
<div className="ui-enter group relative mb-6 pt-10 md:mb-8 md:pt-0">
<button
className="absolute left-0 top-0 flex h-9 items-center gap-1 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground md:hidden"
onClick={() => useEditorStore.getState().setActivePageId(null)}
onClick={() => setIsMobileSidebarOpen(true)}
>
<ChevronLeft size={18} />
+144 -112
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useRef, useEffect, useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useEditorStore, Page } from "@/lib/store";
import {
Search,
@@ -8,7 +9,6 @@ import {
Sun,
Moon,
Upload,
Loader2,
FolderPlus,
FilePlus,
Settings,
@@ -22,10 +22,10 @@ import {
Layers,
PanelLeftOpen,
SlidersHorizontal,
MoreHorizontal,
} from "lucide-react";
import { useTheme } from "next-themes";
import { TreeView } from "./sidebar/tree-view";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { cn, getTagColor } from "@/lib/utils";
import { useSearchStore } from "@/lib/search-store";
@@ -101,41 +101,48 @@ export function ResizableSidebar() {
}, [resize, stopResizing]);
return (
<aside
ref={sidebarRef}
className={cn(
"group/sidebar relative z-20 hidden h-screen flex-col border-r bg-secondary/80 backdrop-blur-sm md:flex",
isResizing ? "transition-none" : "transition-[width] duration-300 ease-in-out",
isCollapsed && "w-[0px] border-none"
<>
{isCollapsed && (
<button
onClick={() => setIsCollapsed(false)}
className="fixed left-3 top-3 z-40 hidden items-center gap-1 rounded-md border border-border/70 bg-card/95 px-2 py-1.5 text-xs text-foreground shadow-sm backdrop-blur md:inline-flex"
title="展开侧边栏"
>
<PanelLeftOpen size={14} />
</button>
)}
style={{ width: isCollapsed ? 0 : width }}
>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
<aside
ref={sidebarRef}
className={cn(
"absolute -right-3 top-6 z-50 rounded-full border bg-card p-1 text-foreground shadow-sm transition-all opacity-100 lg:opacity-0 lg:group-hover/sidebar:opacity-100 hover:bg-primary hover:text-primary-foreground",
isCollapsed && "-right-8 opacity-100"
"group/sidebar relative z-20 hidden h-screen flex-col border-r bg-secondary/80 backdrop-blur-sm md:flex",
isResizing ? "transition-none" : "transition-[width] duration-300 ease-in-out",
isCollapsed && "w-[0px] border-none"
)}
title={isCollapsed ? "展开侧边栏" : "收起侧边栏"}
style={{ width: isCollapsed ? 0 : width }}
>
{isCollapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />}
</button>
<div className={cn("flex flex-1 flex-col overflow-hidden", isCollapsed ? "hidden" : "visible")}>
<SidebarContent onToggleSidebarCollapse={() => setIsCollapsed((v) => !v)} />
</div>
<div className={cn("flex flex-1 flex-col overflow-hidden", isCollapsed ? "hidden" : "visible")}>
<SidebarContent />
</div>
{!isCollapsed && (
<div
className="absolute right-0 top-0 z-40 h-full w-1 cursor-col-resize transition-colors hover:bg-primary/50"
onMouseDown={startResizing}
/>
)}
</aside>
{!isCollapsed && (
<div
className="absolute right-0 top-0 z-40 h-full w-1 cursor-col-resize transition-colors hover:bg-primary/50"
onMouseDown={startResizing}
/>
)}
</aside>
</>
);
}
export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }) {
export function SidebarContent({
onCloseMobile,
onToggleSidebarCollapse,
}: {
onCloseMobile?: () => void;
onToggleSidebarCollapse?: () => void;
}) {
const { pages, activePageId, fetchPages, addPage, isLoading, movePage, reorderPages } = useEditorStore();
const { setOpen, openSearchWithTag } = useSearchStore();
const { triggerImport, isImporting } = useImport();
@@ -197,6 +204,12 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
}, []);
const allTags = Array.from(new Set(pages.flatMap((p) => p.tags || []))).sort((a, b) => a.localeCompare(b));
const activePage = pages.find((p) => p.id === activePageId) || null;
const contextualImportParentId = activePage
? activePage.type === "folder"
? activePage.id
: activePage.parentId
: null;
const handleLogout = async () => {
try {
@@ -273,6 +286,9 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
if (onCloseMobile) onCloseMobile();
};
const canCollapseSidebar = Boolean(onToggleSidebarCollapse);
const canCloseMobile = Boolean(onCloseMobile);
return (
<div className="flex h-full flex-col">
<div className="space-y-3 border-b border-border/60 bg-muted/[0.18] p-3">
@@ -285,46 +301,105 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => addPage(null, "file")}
className="ui-btn-secondary h-10 px-2 text-xs md:h-8 md:w-8 md:px-0"
title="新建文件"
>
<span className="sr-only"></span>
<FilePlus size={15} />
<span className="md:hidden"></span>
</button>
<button
onClick={() => addPage(null, "folder")}
className="ui-btn-secondary h-10 px-2 text-xs md:h-8 md:w-8 md:px-0"
title="新建文件夹"
>
<span className="sr-only"></span>
<FolderPlus size={15} />
<span className="md:hidden"></span>
</button>
<button
onClick={() => {
setOpen(true);
handleItemClick();
}}
className="ui-btn-secondary h-10 px-2 text-xs md:h-8 md:w-8 md:px-0"
title="搜索文档 (Ctrl+K)"
aria-label="搜索文档"
>
<Search size={15} />
<span className="md:hidden"></span>
</button>
{onCloseMobile && (
{(canCollapseSidebar || canCloseMobile) && (
<button
onClick={onCloseMobile}
className="ui-icon-btn h-10 w-10 hover:text-destructive md:h-8 md:w-8"
title="关闭菜单"
onClick={() => (canCloseMobile ? onCloseMobile?.() : onToggleSidebarCollapse?.())}
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
title={canCloseMobile ? "关闭列表" : "收起侧边栏"}
>
<span className="sr-only"></span>
<PanelLeftClose size={16} />
<span className="sr-only">{canCloseMobile ? "关闭列表" : "收起侧边栏"}</span>
<PanelLeftClose size={15} />
</button>
)}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" title="文档操作菜单" aria-label="文档操作菜单">
<MoreHorizontal size={15} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="ui-card z-50 min-w-[170px] p-1.5 shadow-xl animate-in fade-in zoom-in-95" align="end" sideOffset={6}>
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={() => {
addPage(null, "file");
handleItemClick();
}}
>
<FilePlus size={14} />
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={() => {
addPage(null, "folder");
handleItemClick();
}}
>
<FolderPlus size={14} />
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={() => {
setOpen(true);
handleItemClick();
}}
>
<Search size={14} />
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-muted" />
<DropdownMenu.Item
disabled={isImporting}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
onClick={() => {
triggerImport(contextualImportParentId);
handleItemClick();
}}
>
<Upload size={14} />
{isImporting ? "导入中..." : "导入到当前层级"}
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={isImporting}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
onClick={() => {
triggerImport(null);
handleItemClick();
}}
>
<Upload size={14} />
{isImporting ? "导入中..." : "导入到根目录"}
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-muted" />
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={() => {
router.push("/settings");
handleItemClick();
}}
>
<Settings size={14} />
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
{mounted ? (theme === "dark" ? <Sun size={14} /> : <Moon size={14} />) : <Moon size={14} />}
{mounted ? (theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式") : "切换主题"}
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive outline-none transition-colors hover:bg-destructive/10"
onClick={handleLogout}
>
<LogOut size={14} />
退
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
@@ -378,13 +453,13 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
)}
</div>
<div className="flex min-h-0 flex-1 flex-col p-2">
<div className="flex min-h-0 flex-1 flex-col p-1.5">
<button
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
className="mb-1 flex h-11 w-full items-center justify-between rounded-lg px-2 text-sm font-semibold uppercase tracking-wide text-foreground/85 transition-colors hover:bg-muted/60 hover:text-foreground md:h-9 md:text-xs"
className="mb-1 flex h-9 w-full items-center justify-between rounded-md px-1.5 text-xs font-semibold uppercase tracking-wide text-foreground/85 transition-colors hover:bg-muted/60 hover:text-foreground"
>
<span className="flex items-center gap-1.5">
<Layers size={13} className="opacity-80" />
<Layers size={12} className="opacity-80" />
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{pages.length}</span>
</span>
@@ -419,7 +494,7 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
>
<div
onClick={handleItemClick}
className="min-h-0 flex-1 overflow-y-auto rounded-lg bg-background/15 p-0.5 pr-0.5"
className="min-h-0 flex-1 overflow-y-auto rounded-md bg-background/10 p-0"
>
<TreeView
pages={pages}
@@ -449,49 +524,6 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
</>
)}
</div>
<div className="border-t border-border/60 bg-muted/[0.22] p-3 backdrop-blur-sm">
<div className="flex items-center justify-between rounded-xl border border-border/60 bg-background/45 px-2 py-1">
<span className="px-1 text-[11px] text-muted-foreground"></span>
<div className="flex items-center gap-1">
<button
onClick={() => triggerImport(null)}
disabled={isImporting}
aria-busy={isImporting}
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
title={isImporting ? "导入中..." : "导入"}
>
<span className="sr-only">{isImporting ? "导入中" : "导入"}</span>
{isImporting ? <Loader2 size={15} className="animate-spin" /> : <Upload size={15} />}
</button>
<Link
href="/settings"
onClick={handleItemClick}
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
title="设置"
aria-label="设置"
>
<Settings size={15} />
</Link>
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
title={mounted ? (theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式") : "切换主题"}
>
<span className="sr-only">{mounted ? (theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式") : "切换主题"}</span>
{mounted ? (theme === "dark" ? <Sun size={15} /> : <Moon size={15} />) : <Moon size={15} />}
</button>
<button
onClick={handleLogout}
className="ui-icon-btn h-10 w-10 hover:text-destructive md:h-8 md:w-8"
title="退出登录"
>
<span className="sr-only">退</span>
<LogOut size={15} />
</button>
</div>
</div>
</div>
</div>
);
}
+3 -3
View File
@@ -97,7 +97,7 @@ function TreeNode({
});
const style = {
paddingLeft: `${level * 12 + 6}px`,
paddingLeft: `${level * 6}px`,
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
@@ -107,7 +107,7 @@ function TreeNode({
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
<div
className={cn(
"sidebar-tree-item group relative flex h-10 items-center justify-between rounded-lg border border-transparent px-1.5 text-[14px] transition-colors cursor-pointer select-none md:h-9 md:text-[13px]",
"sidebar-tree-item group relative flex h-9 items-center justify-between rounded-md border border-transparent px-1 text-[14px] transition-colors cursor-pointer select-none md:h-8 md:text-[13px]",
isActive
? "border-border/60 bg-accent/75 text-accent-foreground"
: "text-foreground/85 hover:border-border/45 hover:bg-muted/55 hover:text-foreground"
@@ -119,7 +119,7 @@ function TreeNode({
>
{isActive && <span className="absolute left-0 top-[8px] h-5 w-0.5 rounded-r bg-primary/90" />}
<div className="grid min-w-0 flex-1 grid-cols-[1.75rem_1.25rem_minmax(0,1fr)] items-center gap-1.5">
<div className="grid min-w-0 flex-1 grid-cols-[1.25rem_1.25rem_minmax(0,1fr)] items-center gap-1">
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {