mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: control layout
This commit is contained in:
@@ -78,10 +78,6 @@ interface MenuButtonProps {
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const handleToolbarClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const MenuButton = ({
|
||||
onClick,
|
||||
isActive = false,
|
||||
@@ -93,9 +89,16 @@ const MenuButton = ({
|
||||
const theme = useResumeStore((state) => state.theme);
|
||||
const [showTooltip, setShowTooltip] = React.useState(false);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
@@ -110,7 +113,7 @@ const MenuButton = ({
|
||||
disabled ? "opacity-50" : "",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
@@ -427,6 +430,7 @@ const RichTextEditor = ({
|
||||
? "bg-neutral-900/30 border-neutral-800"
|
||||
: "bg-card border-gray-100"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
@@ -436,7 +440,6 @@ const RichTextEditor = ({
|
||||
? "bg-neutral-900/50 border-neutral-800"
|
||||
: "bg-background"
|
||||
)}
|
||||
onClick={handleToolbarClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<HeadingSelect editor={editor} />
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Eye, Edit2, Menu } from "lucide-react";
|
||||
import {
|
||||
Eye,
|
||||
Edit2,
|
||||
Menu,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Maximize2,
|
||||
Minimize2
|
||||
} from "lucide-react";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { EditorHeader } from "@/components/editor/EditorHeader";
|
||||
import { SidePanel } from "@/components/editor/SidePanel";
|
||||
@@ -16,39 +24,228 @@ import {
|
||||
ResizablePanelGroup
|
||||
} from "@/components/ui/resizable";
|
||||
import { cn } from "@/lib/utils";
|
||||
import CustomHandle from "./compoents/CustomHandle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const LAYOUT_CONFIG = {
|
||||
DEFAULT: [20, 35, 45],
|
||||
SIDE_COLLAPSED: [0, 50, 50],
|
||||
EDIT_FOCUSED_WITH_SIDE: [0, 100, 0],
|
||||
PREVIEW_FOCUSED_WITH_SIDE: [0, 0, 100],
|
||||
EDIT_FOCUSED_NO_SIDE: [0, 100, 0],
|
||||
PREVIEW_FOCUSED_NO_SIDE: [0, 0, 100]
|
||||
};
|
||||
|
||||
// 创建自定义拖拽手柄组件
|
||||
const DragHandle = ({ show = true }) => {
|
||||
const theme = useResumeStore((state) => state.theme);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<ResizableHandle className="relative w-1.5 group">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-1/2 w-1 -translate-x-1/2",
|
||||
"group-hover:bg-primary/20 group-data-[dragging=true]:bg-primary",
|
||||
theme === "dark" ? "bg-neutral-700/50" : "bg-gray-200"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
"w-4 h-8 rounded-full opacity-0 group-hover:opacity-100",
|
||||
"flex items-center justify-center",
|
||||
theme === "dark" ? "bg-neutral-800" : "bg-gray-200"
|
||||
)}
|
||||
>
|
||||
<div className="w-0.5 h-4 bg-gray-400 rounded-full" />
|
||||
</div>
|
||||
</ResizableHandle>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const theme = useResumeStore((state) => state.theme);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const themeConfig = getThemeConfig(theme === "dark");
|
||||
|
||||
// 移动端状态管理
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [sidePanelCollapsed, setSidePanelCollapsed] = useState(false);
|
||||
const [focusedPanel, setFocusedPanel] = useState<null | "edit" | "preview">(
|
||||
null
|
||||
);
|
||||
const [showMobileSidebar, setShowMobileSidebar] = useState(false);
|
||||
const [isMobilePreview, setIsMobilePreview] = useState(false);
|
||||
const [panelSizes, setPanelSizes] = useState(LAYOUT_CONFIG.DEFAULT);
|
||||
const [layoutKey, setLayoutKey] = useState(0);
|
||||
|
||||
useScrollbarTheme();
|
||||
const updateLayout = (newSizes: number[]) => {
|
||||
setPanelSizes(newSizes);
|
||||
setLayoutKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const [defaultLayout] = useState([15, 40, 50]);
|
||||
useEffect(() => {
|
||||
let newSizes;
|
||||
if (sidePanelCollapsed) {
|
||||
if (focusedPanel === "edit") {
|
||||
newSizes = [...LAYOUT_CONFIG.EDIT_FOCUSED_NO_SIDE];
|
||||
} else if (focusedPanel === "preview") {
|
||||
newSizes = [...LAYOUT_CONFIG.PREVIEW_FOCUSED_NO_SIDE];
|
||||
} else {
|
||||
newSizes = [...LAYOUT_CONFIG.SIDE_COLLAPSED];
|
||||
}
|
||||
} else {
|
||||
if (focusedPanel === "edit") {
|
||||
newSizes = [...LAYOUT_CONFIG.EDIT_FOCUSED_WITH_SIDE];
|
||||
} else if (focusedPanel === "preview") {
|
||||
newSizes = [...LAYOUT_CONFIG.PREVIEW_FOCUSED_WITH_SIDE];
|
||||
} else {
|
||||
newSizes = [...LAYOUT_CONFIG.DEFAULT];
|
||||
}
|
||||
}
|
||||
updateLayout([...newSizes]);
|
||||
}, [sidePanelCollapsed, focusedPanel]);
|
||||
|
||||
// 移动端切换按钮
|
||||
const MobileNav = () => (
|
||||
<div className={`fixed bottom-6 right-6 flex gap-2 md:hidden z-50`}>
|
||||
const toggleSidePanel = () => {
|
||||
setSidePanelCollapsed(!sidePanelCollapsed);
|
||||
};
|
||||
|
||||
const togglePanelFocus = (panel: "edit" | "preview") => {
|
||||
setFocusedPanel(focusedPanel === panel ? null : panel);
|
||||
};
|
||||
|
||||
// 底部控制栏
|
||||
const LayoutControls = () => (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-6 left-1/2 -translate-x-1/2",
|
||||
"flex items-center gap-2 z-10 p-2 rounded-full",
|
||||
theme === "dark"
|
||||
? "bg-neutral-900/80 border border-neutral-800"
|
||||
: "bg-white/80 border border-gray-200",
|
||||
"backdrop-blur-sm shadow-lg"
|
||||
)}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={sidePanelCollapsed ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full"
|
||||
onClick={toggleSidePanel}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{sidePanelCollapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"h-5 w-px mx-1",
|
||||
theme === "dark" ? "bg-neutral-800" : "bg-gray-200"
|
||||
)}
|
||||
/>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={focusedPanel === "edit" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full"
|
||||
onClick={() => togglePanelFocus("edit")}
|
||||
>
|
||||
{focusedPanel === "edit" ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Edit2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{focusedPanel === "edit" ? "退出编辑模式" : "专注编辑"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={focusedPanel === "preview" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full"
|
||||
onClick={() => togglePanelFocus("preview")}
|
||||
>
|
||||
{focusedPanel === "preview" ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{focusedPanel === "preview" ? "退出预览模式" : "专注预览"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 移动端控制栏
|
||||
const MobileControls = () => (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-6 right-6 flex gap-2 md:hidden z-50",
|
||||
"p-2 rounded-full",
|
||||
theme === "dark"
|
||||
? "bg-neutral-900/80 border border-neutral-800"
|
||||
: "bg-white/80 border border-gray-200",
|
||||
"backdrop-blur-sm shadow-lg"
|
||||
)}
|
||||
>
|
||||
<motion.button
|
||||
className={`p-3 rounded-full ${themeConfig.buttonPrimary} text-white shadow-lg`}
|
||||
className={cn(
|
||||
"p-3 rounded-full",
|
||||
theme === "dark" ? "hover:bg-neutral-800" : "hover:bg-gray-100"
|
||||
)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setShowSidebar(true)}
|
||||
onClick={() => setShowMobileSidebar(true)}
|
||||
>
|
||||
<Menu size={24} />
|
||||
<Menu className="h-5 w-5" />
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className={`p-3 rounded-full ${themeConfig.buttonPrimary} text-white shadow-lg`}
|
||||
className={cn(
|
||||
"p-3 rounded-full",
|
||||
theme === "dark" ? "hover:bg-neutral-800" : "hover:bg-gray-100"
|
||||
)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||
onClick={() => setIsMobilePreview(!isMobilePreview)}
|
||||
>
|
||||
{isPreviewMode ? <Edit2 size={24} /> : <Eye size={24} />}
|
||||
{isMobilePreview ? (
|
||||
<Edit2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
@@ -56,23 +253,26 @@ export default function Home() {
|
||||
// 移动端侧边栏
|
||||
const MobileSidebar = () => (
|
||||
<AnimatePresence>
|
||||
{showSidebar && (
|
||||
{showMobileSidebar && (
|
||||
<>
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 z-50 md:hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowSidebar(false)}
|
||||
onClick={() => setShowMobileSidebar(false)}
|
||||
/>
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 h-full w-80 z-50 md:hidden"
|
||||
className={cn(
|
||||
"fixed top-0 left-0 h-full w-80 z-50 md:hidden",
|
||||
theme === "dark" ? "bg-neutral-900" : "bg-white"
|
||||
)}
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<SidePanel onClose={() => setShowSidebar(false)} />
|
||||
<SidePanel onClose={() => setShowMobileSidebar(false)} />
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
@@ -81,47 +281,77 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`w-full h-screen ${themeConfig.bg} ${themeConfig.text} overflow-hidden`}
|
||||
className={cn(
|
||||
"w-full h-screen overflow-hidden",
|
||||
theme === "dark"
|
||||
? "bg-neutral-900 text-neutral-200"
|
||||
: "bg-white text-gray-900"
|
||||
)}
|
||||
>
|
||||
<EditorHeader previewRef={previewRef} isMobile={true} />
|
||||
<EditorHeader previewRef={previewRef} />
|
||||
|
||||
<div className="hidden md:block h-[calc(100vh-64px)] overflow-hidden">
|
||||
{/* 桌面端布局 */}
|
||||
{/* 桌面端布局 */}
|
||||
<div className="hidden md:block h-[calc(100vh-64px)]">
|
||||
<ResizablePanelGroup
|
||||
key={layoutKey}
|
||||
direction="horizontal"
|
||||
className="h-full rounded-lg border"
|
||||
className={cn(
|
||||
"h-full rounded-lg",
|
||||
theme === "dark"
|
||||
? "border-neutral-800 bg-neutral-900/50"
|
||||
: "border border-gray-200 bg-white"
|
||||
)}
|
||||
>
|
||||
{/* 侧边栏面板 */}
|
||||
{!sidePanelCollapsed && (
|
||||
<>
|
||||
<ResizablePanel
|
||||
defaultSize={panelSizes[0]}
|
||||
minSize={20}
|
||||
className={cn(
|
||||
theme === "dark"
|
||||
? "bg-neutral-900 border-r border-neutral-800"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<SidePanel />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<DragHandle />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 编辑面板 */}
|
||||
<ResizablePanel
|
||||
defaultSize={defaultLayout[0]}
|
||||
minSize={15}
|
||||
maxSize={30}
|
||||
defaultSize={panelSizes[1]}
|
||||
className={cn(
|
||||
theme === "dark"
|
||||
? "bg-neutral-900 border-r border-neutral-800"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<SidePanel />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<CustomHandle />
|
||||
<ResizablePanel
|
||||
minSize={20}
|
||||
maxSize={30}
|
||||
defaultSize={defaultLayout[1]}
|
||||
>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="h-full">
|
||||
<EditPanel />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<CustomHandle />
|
||||
<ResizablePanel defaultSize={defaultLayout[2]} minSize={50}>
|
||||
<DragHandle />
|
||||
|
||||
{/* 预览面板 */}
|
||||
<ResizablePanel defaultSize={panelSizes[2]} className="bg-gray-100">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<PreviewPanel />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<LayoutControls />
|
||||
</div>
|
||||
|
||||
{/* 移动端布局 */}
|
||||
<div className="md:hidden h-[calc(100vh-64px)] overflow-hidden">
|
||||
<div className="md:hidden h-[calc(100vh-64px)]">
|
||||
<AnimatePresence mode="wait">
|
||||
{isPreviewMode ? (
|
||||
{isMobilePreview ? (
|
||||
<motion.div
|
||||
key="preview"
|
||||
initial={{ opacity: 0, x: 300 }}
|
||||
@@ -147,7 +377,7 @@ export default function Home() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<MobileNav />
|
||||
<MobileControls />
|
||||
<MobileSidebar />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -30,11 +30,11 @@ export function EditorHeader({ previewRef, isMobile }: EditorHeaderProps) {
|
||||
<div className="flex items-center justify-between px-6 h-full">
|
||||
<div className="flex items-center space-x-6 scrollbar-hide">
|
||||
<motion.div
|
||||
className="flex items-center space-x-2 shrink-0"
|
||||
className="flex items-center space-x-2 shrink-0 cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="text-lg font-semibold">Resume</span>
|
||||
<span className="text-lg font-semibold">Magic Resume</span>
|
||||
<div className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" />
|
||||
</motion.div>
|
||||
|
||||
@@ -76,15 +76,6 @@ export function EditorHeader({ previewRef, isMobile }: EditorHeaderProps) {
|
||||
|
||||
{/* 在移动端隐藏这些按钮 */}
|
||||
<div className="hidden md:flex items-center space-x-3">
|
||||
<motion.button
|
||||
className={`px-4 py-2 ${themeConfig.button} rounded-lg text-sm font-medium flex items-center space-x-2`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>预览</span>
|
||||
</motion.button>
|
||||
|
||||
<PdfExport />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -467,7 +467,7 @@ export function PreviewPanel() {
|
||||
return (
|
||||
<motion.div
|
||||
ref={previewRef}
|
||||
className={cn("flex-1 overflow-y-auto", "bg-gray-100")}
|
||||
className={cn("flex-1 overflow-y-auto")}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
|
||||
@@ -296,6 +296,7 @@ export function SidePanel() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 基础字号选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
className={cn(
|
||||
@@ -304,30 +305,50 @@ export function SidePanel() {
|
||||
>
|
||||
基础字号
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Slider
|
||||
value={[globalSettings?.baseFontSize || 0]}
|
||||
min={12}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([value]) =>
|
||||
updateGlobalSettings?.({ baseFontSize: value })
|
||||
}
|
||||
<Select
|
||||
value={globalSettings?.baseFontSize?.toString()}
|
||||
onValueChange={(value) =>
|
||||
updateGlobalSettings?.({ baseFontSize: parseInt(value) })
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"border transition-colors",
|
||||
theme === "dark"
|
||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||
: "border-gray-200 bg-white text-gray-700"
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</motion.div>
|
||||
<SelectContent
|
||||
className={cn(
|
||||
theme === "dark"
|
||||
? "[&_[role=slider]]:bg-neutral-200"
|
||||
: "[&_[role=slider]]:bg-gray-900"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-[3ch] text-sm",
|
||||
theme === "dark" ? "text-neutral-300" : "text-gray-600"
|
||||
? "bg-neutral-900 border-neutral-800 text-[#fff] hover:text-white"
|
||||
: "bg-white border-gray-200"
|
||||
)}
|
||||
>
|
||||
{globalSettings?.baseFontSize}px
|
||||
</span>
|
||||
</div>
|
||||
{[12, 13, 14, 15, 16, 18, 20, 24].map((size) => (
|
||||
<SelectItem
|
||||
key={size}
|
||||
value={size.toString()}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
theme === "dark"
|
||||
? "focus:bg-neutral-800 hover:bg-neutral-800"
|
||||
: "focus:bg-gray-100 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
{size}px
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
Reference in New Issue
Block a user