mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: add rich editor
This commit is contained in:
@@ -5,7 +5,7 @@ import "./globals.css";
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "魔方简历",
|
||||
title: "魔法简历",
|
||||
description: "极度自由的在线简历编辑器"
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
Highlighter
|
||||
} from "lucide-react";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
@@ -56,23 +58,7 @@ const COLORS = [
|
||||
{ label: "粉色", value: "#FF00FF" }
|
||||
];
|
||||
|
||||
const BG_COLORS = [
|
||||
{ label: "黑色", value: "#000000" },
|
||||
{ label: "深灰", value: "#333333" },
|
||||
{ label: "灰色", value: "#666666" },
|
||||
{ label: "红色", value: "#FF0000" },
|
||||
{ label: "橙色", value: "#FF4D00" },
|
||||
{ label: "橙黄", value: "#FF9900" },
|
||||
{ label: "黄色", value: "#FFCC00" },
|
||||
{ label: "黄绿", value: "#33CC00" },
|
||||
{ label: "绿色", value: "#00CC00" },
|
||||
{ label: "青色", value: "#00CCCC" },
|
||||
{ label: "浅蓝", value: "#0066FF" },
|
||||
{ label: "蓝色", value: "#0000FF" },
|
||||
{ label: "紫色", value: "#6600FF" },
|
||||
{ label: "紫红", value: "#CC00FF" },
|
||||
{ label: "粉色", value: "#FF00FF" }
|
||||
];
|
||||
const BG_COLORS = COLORS;
|
||||
|
||||
interface MenuButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -83,6 +69,19 @@ interface MenuButtonProps {
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
interface MenuButtonProps {
|
||||
onClick: () => void;
|
||||
isActive?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const handleToolbarClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const MenuButton = ({
|
||||
onClick,
|
||||
isActive = false,
|
||||
@@ -90,30 +89,51 @@ const MenuButton = ({
|
||||
children,
|
||||
className = "",
|
||||
tooltip
|
||||
}: MenuButtonProps) => (
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={`h-9 w-9 rounded-md transition-all duration-200 hover:scale-105 p-0
|
||||
${isActive ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/5"}
|
||||
${disabled ? "opacity-50" : ""}
|
||||
${className}
|
||||
relative group`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
{tooltip && (
|
||||
<span
|
||||
className="absolute -bottom-8 left-1/2 transform -translate-x-1/2
|
||||
px-2 py-1 bg-secondary text-secondary-foreground text-xs rounded-md
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap z-50"
|
||||
}: MenuButtonProps) => {
|
||||
const theme = useResumeStore((state) => state.theme);
|
||||
const [showTooltip, setShowTooltip] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-md transition-all duration-200 hover:scale-105 p-0",
|
||||
isActive
|
||||
? theme === "dark"
|
||||
? "bg-neutral-800 text-neutral-200 hover:bg-neutral-700"
|
||||
: "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: theme === "dark"
|
||||
? "hover:bg-neutral-800"
|
||||
: "hover:bg-primary/5",
|
||||
disabled ? "opacity-50" : "",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
{tooltip}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
{children}
|
||||
</Button>
|
||||
{tooltip && showTooltip && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -bottom-8 left-1/2 transform -translate-x-1/2",
|
||||
"px-2 py-1 text-xs rounded-md whitespace-nowrap z-50",
|
||||
"transition-opacity duration-200",
|
||||
theme === "dark"
|
||||
? "bg-neutral-800 text-neutral-200"
|
||||
: "bg-secondary text-secondary-foreground"
|
||||
)}
|
||||
>
|
||||
{tooltip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TextColorButton = ({ editor, tooltip }) => {
|
||||
const [activeColor, setActiveColor] = React.useState<string | null>(null);
|
||||
@@ -268,74 +288,91 @@ const BackgroundColorButton = ({ editor }) => {
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
const HeadingSelect = ({ editor }) => (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-2.5 rounded-md hover:bg-primary/5 flex items-center gap-1.5 min-w-[100px]"
|
||||
|
||||
const HeadingSelect = ({ editor }) => {
|
||||
const theme = useResumeStore((state) => state.theme);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 px-2.5 rounded-md flex items-center gap-1.5 min-w-[100px]",
|
||||
theme === "dark" ? "hover:bg-neutral-800" : "hover:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<Type className="h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
{editor.isActive("heading", { level: 1 })
|
||||
? "标题 1"
|
||||
: editor.isActive("heading", { level: 2 })
|
||||
? "标题 2"
|
||||
: editor.isActive("heading", { level: 3 })
|
||||
? "标题 3"
|
||||
: "正文"}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn(
|
||||
"w-40 p-1 rounded-lg",
|
||||
theme === "dark" ? "bg-neutral-900" : "bg-white"
|
||||
)}
|
||||
>
|
||||
<Type className="h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
{editor.isActive("heading", { level: 1 })
|
||||
? "标题 1"
|
||||
: editor.isActive("heading", { level: 2 })
|
||||
? "标题 2"
|
||||
: editor.isActive("heading", { level: 3 })
|
||||
? "标题 3"
|
||||
: "正文"}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1 rounded-lg">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{[
|
||||
{ label: "正文", value: "p" },
|
||||
{ label: "标题 1", value: "1" },
|
||||
{ label: "标题 2", value: "2" },
|
||||
{ label: "标题 3", value: "3" }
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
variant="ghost"
|
||||
className={`w-full justify-start px-2 py-1.5 text-sm rounded-md
|
||||
${
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{[
|
||||
{ label: "正文", value: "p" },
|
||||
{ label: "标题 1", value: "1" },
|
||||
{ label: "标题 2", value: "2" },
|
||||
{ label: "标题 3", value: "3" }
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start px-2 py-1.5 text-sm rounded-md",
|
||||
editor.isActive(
|
||||
item.value === "p" ? "paragraph" : "heading",
|
||||
item.value === "p"
|
||||
? undefined
|
||||
: { level: parseInt(item.value) }
|
||||
)
|
||||
? "bg-primary/10 text-primary"
|
||||
? theme === "dark"
|
||||
? "bg-neutral-800 text-neutral-200"
|
||||
: "bg-primary/10 text-primary"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (item.value === "p") {
|
||||
editor.chain().focus().setParagraph().run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleHeading({ level: parseInt(item.value) })
|
||||
.run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
)}
|
||||
onClick={() => {
|
||||
if (item.value === "p") {
|
||||
editor.chain().focus().setParagraph().run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleHeading({ level: parseInt(item.value) })
|
||||
.run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const RichTextEditor = ({
|
||||
content,
|
||||
onChange,
|
||||
placeholder
|
||||
}: RichTextEditorProps) => {
|
||||
const theme = useResumeStore((state) => state.theme);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
@@ -358,8 +395,21 @@ const RichTextEditor = ({
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm sm:prose lg:prose-lg max-w-none focus:outline-none min-h-[150px] px-4 py-3"
|
||||
class: cn(
|
||||
"prose prose-sm sm:prose lg:prose-lg max-w-none focus:outline-none min-h-[150px] px-4 py-3",
|
||||
theme === "dark" && [
|
||||
"prose-invert",
|
||||
"prose-headings:text-neutral-200",
|
||||
"prose-p:text-neutral-300",
|
||||
"prose-strong:text-neutral-200",
|
||||
"prose-em:text-neutral-200",
|
||||
"prose-blockquote:text-neutral-300",
|
||||
"prose-blockquote:border-neutral-700",
|
||||
"prose-ul:text-neutral-300",
|
||||
"prose-ol:text-neutral-300",
|
||||
"[&_*::selection]:bg-neutral-700/50"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -369,14 +419,34 @@ const RichTextEditor = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-card overflow-hidden border shadow-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg overflow-hidden border shadow-sm",
|
||||
theme === "dark"
|
||||
? "bg-neutral-900/30 border-neutral-800"
|
||||
: "bg-card border-gray-100"
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="border-b px-2 py-1.5 bg-background flex flex-wrap items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"border-b px-2 py-1.5 flex flex-wrap items-center gap-3",
|
||||
theme === "dark"
|
||||
? "bg-neutral-900/50 border-neutral-800"
|
||||
: "bg-background"
|
||||
)}
|
||||
onClick={handleToolbarClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<HeadingSelect editor={editor} />
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-border/60" />
|
||||
<div
|
||||
className={cn(
|
||||
"h-5 w-px",
|
||||
theme === "dark" ? "bg-neutral-800" : "bg-border/60"
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<MenuButton
|
||||
@@ -411,7 +481,12 @@ const RichTextEditor = ({
|
||||
<BackgroundColorButton editor={editor} />
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-border/60" />
|
||||
<div
|
||||
className={cn(
|
||||
"h-5 w-px",
|
||||
theme === "dark" ? "bg-neutral-800" : "bg-border/60"
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<MenuButton
|
||||
@@ -444,7 +519,12 @@ const RichTextEditor = ({
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-border/60" />
|
||||
<div
|
||||
className={cn(
|
||||
"h-5 w-px",
|
||||
theme === "dark" ? "bg-neutral-800" : "bg-border/60"
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<MenuButton
|
||||
@@ -470,7 +550,12 @@ const RichTextEditor = ({
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-border/60" />
|
||||
<div
|
||||
className={cn(
|
||||
"h-5 w-px",
|
||||
theme === "dark" ? "bg-neutral-800" : "bg-border/60"
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<MenuButton
|
||||
@@ -496,7 +581,12 @@ const RichTextEditor = ({
|
||||
{/* Bubble Menu */}
|
||||
{editor && (
|
||||
<BubbleMenu
|
||||
className="flex items-center gap-0.5 p-1 rounded-md bg-background/80 backdrop-blur border shadow-lg"
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 p-1 rounded-md backdrop-blur border shadow-lg",
|
||||
theme === "dark"
|
||||
? "bg-neutral-900/80 border-neutral-800"
|
||||
: "bg-background/80 border-gray-100"
|
||||
)}
|
||||
tippyOptions={{ duration: 100 }}
|
||||
editor={editor}
|
||||
>
|
||||
|
||||
@@ -5,13 +5,14 @@ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Calendar } from "../ui/calendar";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import RichTextEditor from "@/app/workbench/compoents/Editor/RichText";
|
||||
|
||||
// 扩展 FieldProps 类型定义
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
type?: "text" | "textarea" | "date";
|
||||
type?: "text" | "textarea" | "date" | "editor";
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
@@ -27,6 +28,18 @@ const Field: React.FC<FieldProps> = ({
|
||||
}) => {
|
||||
const theme = useResumeStore((state) => state.theme);
|
||||
|
||||
const renderLabel = () => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium flex gap-1 items-center",
|
||||
theme === "dark" ? "text-neutral-300" : "text-gray-600"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
const inputStyles = cn(
|
||||
"mt-1.5 block w-full transition-all duration-200",
|
||||
"rounded-lg px-4 py-2.5",
|
||||
@@ -144,6 +157,20 @@ const Field: React.FC<FieldProps> = ({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (type === "editor") {
|
||||
return (
|
||||
<motion.div className="block">
|
||||
{renderLabel()}
|
||||
<div className="mt-1.5">
|
||||
<RichTextEditor
|
||||
content={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
|
||||
@@ -174,7 +174,7 @@ export function PreviewPanel() {
|
||||
>
|
||||
{project.name || "未命名项目"}
|
||||
</motion.h4>
|
||||
<motion.p
|
||||
<motion.div
|
||||
layout
|
||||
className={cn("text-gray-600")}
|
||||
style={{
|
||||
@@ -182,7 +182,7 @@ export function PreviewPanel() {
|
||||
}}
|
||||
>
|
||||
{project.role}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.span
|
||||
layout
|
||||
@@ -197,7 +197,7 @@ export function PreviewPanel() {
|
||||
|
||||
{/* 项目详情 */}
|
||||
{project.description && (
|
||||
<motion.p
|
||||
<motion.div
|
||||
layout
|
||||
className={cn("whitespace-pre-wrap", "text-gray-600")}
|
||||
style={{
|
||||
@@ -205,14 +205,16 @@ export function PreviewPanel() {
|
||||
lineHeight: globalSettings?.lineHeight || 1.6
|
||||
}}
|
||||
>
|
||||
{project.description}
|
||||
</motion.p>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: project.description }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 技术栈 */}
|
||||
{project.technologies && (
|
||||
<motion.div layout>
|
||||
<motion.p
|
||||
<motion.div
|
||||
layout
|
||||
className={cn("font-medium", "text-gray-800")}
|
||||
style={{
|
||||
@@ -220,8 +222,8 @@ export function PreviewPanel() {
|
||||
}}
|
||||
>
|
||||
技术栈:
|
||||
</motion.p>
|
||||
<motion.p
|
||||
</motion.div>
|
||||
<motion.div
|
||||
layout
|
||||
className={cn("whitespace-pre-wrap", "text-gray-600")}
|
||||
style={{
|
||||
@@ -230,14 +232,14 @@ export function PreviewPanel() {
|
||||
}}
|
||||
>
|
||||
{project.technologies}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 主要职责 */}
|
||||
{project.responsibilities && (
|
||||
<motion.div layout>
|
||||
<motion.p
|
||||
<motion.div
|
||||
layout
|
||||
className={cn("font-medium", "text-gray-800")}
|
||||
style={{
|
||||
@@ -245,8 +247,8 @@ export function PreviewPanel() {
|
||||
}}
|
||||
>
|
||||
主要职责:
|
||||
</motion.p>
|
||||
<motion.p
|
||||
</motion.div>
|
||||
<motion.div
|
||||
layout
|
||||
className={cn("whitespace-pre-wrap", "text-gray-600")}
|
||||
style={{
|
||||
@@ -255,14 +257,14 @@ export function PreviewPanel() {
|
||||
}}
|
||||
>
|
||||
{project.responsibilities}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 项目成就 */}
|
||||
{project.achievements && (
|
||||
<motion.div layout>
|
||||
<motion.p
|
||||
<motion.div
|
||||
layout
|
||||
className={cn("font-medium", "text-gray-800")}
|
||||
style={{
|
||||
@@ -270,8 +272,8 @@ export function PreviewPanel() {
|
||||
}}
|
||||
>
|
||||
项目成就:
|
||||
</motion.p>
|
||||
<motion.p
|
||||
</motion.div>
|
||||
<motion.div
|
||||
layout
|
||||
className={cn("whitespace-pre-wrap", "text-gray-600")}
|
||||
style={{
|
||||
@@ -280,7 +282,7 @@ export function PreviewPanel() {
|
||||
}}
|
||||
>
|
||||
{project.achievements}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -65,36 +65,9 @@ const ProjectEditor: React.FC<ProjectEditorProps> = ({
|
||||
onChange={(value) =>
|
||||
setData((prev) => ({ ...prev, description: value }))
|
||||
}
|
||||
type="textarea"
|
||||
type="editor"
|
||||
placeholder="简要描述项目的背景和目标..."
|
||||
/>
|
||||
<Field
|
||||
label="技术栈"
|
||||
value={data.technologies}
|
||||
onChange={(value) =>
|
||||
setData((prev) => ({ ...prev, technologies: value }))
|
||||
}
|
||||
type="textarea"
|
||||
placeholder="使用的主要技术、框架和工具..."
|
||||
/>
|
||||
<Field
|
||||
label="主要职责"
|
||||
value={data.responsibilities}
|
||||
onChange={(value) =>
|
||||
setData((prev) => ({ ...prev, responsibilities: value }))
|
||||
}
|
||||
type="textarea"
|
||||
placeholder="描述你在项目中的具体职责和工作内容..."
|
||||
/>
|
||||
<Field
|
||||
label="项目成就"
|
||||
value={data.achievements}
|
||||
onChange={(value) =>
|
||||
setData((prev) => ({ ...prev, achievements: value }))
|
||||
}
|
||||
type="textarea"
|
||||
placeholder="项目取得的成果、解决的问题或改进的指标..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user