Skip to content

Commit dfc8cbe

Browse files
lwinmoepaingclaude
andcommitted
✨ feat(editor): add Lexical ContentEditor component and test page
Reusable Notion-like rich text editor with slash commands, floating toolbar, markdown shortcuts, image support, and Obsidian Prism theme. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7b4c305 commit dfc8cbe

22 files changed

Lines changed: 1891 additions & 0 deletions
Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
"use client";
2+
3+
import { useState, useMemo, useCallback } from "react";
4+
import { ContentEditor, ContentRenderer } from "@/components/ContentEditor";
5+
import type { SerializedEditorState } from "@/components/ContentEditor";
6+
import { cn } from "@/utils";
7+
import { motion, AnimatePresence } from "framer-motion";
8+
import {
9+
PenLine,
10+
Eye,
11+
Braces,
12+
ChevronDown,
13+
Keyboard,
14+
Type,
15+
Hash,
16+
Bold,
17+
Quote,
18+
Minus,
19+
Slash,
20+
} from "lucide-react";
21+
22+
/* ── Helpers ── */
23+
function countWords(state: SerializedEditorState | null): number {
24+
if (!state) return 0;
25+
const text = extractText(state.root);
26+
return text
27+
.split(/\s+/)
28+
.filter((w) => w.length > 0).length;
29+
}
30+
31+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32+
function extractText(node: any): string {
33+
if (!node) return "";
34+
if (node.text) return node.text;
35+
if (node.children) {
36+
return node.children.map(extractText).join(" ");
37+
}
38+
return "";
39+
}
40+
41+
/* ── Shortcut Pill ── */
42+
function ShortcutPill({
43+
keys,
44+
label,
45+
icon: Icon,
46+
}: {
47+
keys: string;
48+
label: string;
49+
icon: React.ComponentType<{ className?: string }>;
50+
}) {
51+
return (
52+
<div className="flex items-center gap-2 group">
53+
<div
54+
className={cn(
55+
"w-7 h-7 rounded-md flex items-center justify-center",
56+
"bg-white/[0.04] border border-white/[0.06]",
57+
"group-hover:border-prism-violet/30 group-hover:bg-prism-violet/[0.06]",
58+
"transition-all duration-300"
59+
)}
60+
>
61+
<Icon className="w-3.5 h-3.5 text-zinc-500 group-hover:text-prism-violet transition-colors duration-300" />
62+
</div>
63+
<div className="flex items-center gap-1.5">
64+
<kbd
65+
className={cn(
66+
"px-1.5 py-0.5 rounded text-[10px] font-mono leading-none",
67+
"bg-white/[0.04] border border-white/[0.08] text-zinc-500",
68+
"group-hover:text-zinc-300 transition-colors duration-300"
69+
)}
70+
>
71+
{keys}
72+
</kbd>
73+
<span className="text-[11px] text-zinc-600 group-hover:text-zinc-400 transition-colors duration-300">
74+
{label}
75+
</span>
76+
</div>
77+
</div>
78+
);
79+
}
80+
81+
/* ── Tab Button ── */
82+
function TabButton({
83+
active,
84+
onClick,
85+
disabled,
86+
icon: Icon,
87+
label,
88+
}: {
89+
active: boolean;
90+
onClick: () => void;
91+
disabled?: boolean;
92+
icon: React.ComponentType<{ className?: string }>;
93+
label: string;
94+
}) {
95+
return (
96+
<button
97+
type="button"
98+
onClick={onClick}
99+
disabled={disabled}
100+
className={cn(
101+
"relative flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-all duration-300",
102+
active
103+
? "text-white"
104+
: "text-zinc-500 hover:text-zinc-300",
105+
"disabled:opacity-30 disabled:cursor-not-allowed"
106+
)}
107+
>
108+
<Icon className={cn("w-4 h-4 transition-colors duration-300", active ? "text-prism-cyan" : "")} />
109+
{label}
110+
{active && (
111+
<motion.div
112+
layoutId="activeTab"
113+
className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-prism-cyan to-transparent"
114+
transition={{ type: "spring", stiffness: 400, damping: 30 }}
115+
/>
116+
)}
117+
</button>
118+
);
119+
}
120+
121+
/* ── Main ── */
122+
export default function TestEditorClient() {
123+
const [content, setContent] = useState<SerializedEditorState | null>(null);
124+
const [activeTab, setActiveTab] = useState<"editor" | "preview" | "json">("editor");
125+
const [jsonCollapsed, setJsonCollapsed] = useState(true);
126+
127+
const wordCount = useMemo(() => countWords(content), [content]);
128+
129+
const handleChange = useCallback((state: SerializedEditorState) => {
130+
setContent(state);
131+
}, []);
132+
133+
return (
134+
<div className="min-h-screen bg-obsidian pt-24 pb-20 px-5 relative overflow-hidden">
135+
{/* ── Atmospheric Background ── */}
136+
<div className="pointer-events-none absolute inset-0">
137+
{/* Grid */}
138+
<div
139+
className="absolute inset-0 bg-square"
140+
style={{
141+
maskImage: "linear-gradient(to bottom, transparent, rgba(255,255,255,0.03) 30%, rgba(255,255,255,0.03) 70%, transparent)",
142+
}}
143+
/>
144+
{/* Prism glow */}
145+
<div
146+
className="absolute top-20 left-1/2 -translate-x-1/2 w-[600px] h-[400px] rounded-full opacity-[0.07] blur-[120px]"
147+
style={{
148+
background: "radial-gradient(ellipse, #a78bfa 0%, #22d3ee 40%, transparent 70%)",
149+
}}
150+
/>
151+
</div>
152+
153+
<div className="max-w-3xl mx-auto relative z-10">
154+
{/* ── Header ── */}
155+
<motion.div
156+
initial={{ opacity: 0, y: 16 }}
157+
animate={{ opacity: 1, y: 0 }}
158+
transition={{ duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] }}
159+
className="mb-8"
160+
>
161+
<div className="flex items-center gap-3 mb-3">
162+
<div
163+
className={cn(
164+
"w-9 h-9 rounded-lg flex items-center justify-center",
165+
"bg-gradient-to-br from-prism-violet/20 to-prism-cyan/20",
166+
"border border-white/[0.08]"
167+
)}
168+
>
169+
<PenLine className="w-4.5 h-4.5 text-prism-cyan" />
170+
</div>
171+
<div>
172+
<h1 className="text-xl font-semibold font-display text-white tracking-tight">
173+
Content Editor
174+
</h1>
175+
<p className="text-xs text-zinc-600">
176+
Lexical-powered rich text editor
177+
</p>
178+
</div>
179+
</div>
180+
181+
{/* Shortcut hints */}
182+
<motion.div
183+
initial={{ opacity: 0 }}
184+
animate={{ opacity: 1 }}
185+
transition={{ delay: 0.3, duration: 0.5 }}
186+
className="flex flex-wrap gap-x-4 gap-y-2 mt-4"
187+
>
188+
<ShortcutPill icon={Slash} keys="/" label="Commands" />
189+
<ShortcutPill icon={Hash} keys="#" label="Heading" />
190+
<ShortcutPill icon={Bold} keys="**" label="Bold" />
191+
<ShortcutPill icon={Quote} keys=">" label="Quote" />
192+
<ShortcutPill icon={Minus} keys="---" label="Divider" />
193+
<ShortcutPill icon={Type} keys="Select" label="Toolbar" />
194+
</motion.div>
195+
</motion.div>
196+
197+
{/* ── Editor Chrome ── */}
198+
<motion.div
199+
initial={{ opacity: 0, y: 20 }}
200+
animate={{ opacity: 1, y: 0 }}
201+
transition={{ duration: 0.5, delay: 0.15, ease: [0.25, 0.46, 0.45, 0.94] }}
202+
>
203+
{/* Tab bar */}
204+
<div
205+
className={cn(
206+
"flex items-center border-b border-white/[0.06]",
207+
"bg-surface/60 backdrop-blur-sm rounded-t-xl"
208+
)}
209+
>
210+
<TabButton
211+
active={activeTab === "editor"}
212+
onClick={() => setActiveTab("editor")}
213+
icon={PenLine}
214+
label="Editor"
215+
/>
216+
<TabButton
217+
active={activeTab === "preview"}
218+
onClick={() => setActiveTab("preview")}
219+
disabled={!content}
220+
icon={Eye}
221+
label="Preview"
222+
/>
223+
<TabButton
224+
active={activeTab === "json"}
225+
onClick={() => setActiveTab("json")}
226+
disabled={!content}
227+
icon={Braces}
228+
label="JSON"
229+
/>
230+
231+
{/* Word count — right side */}
232+
<div className="ml-auto pr-4 flex items-center gap-3">
233+
{content && (
234+
<motion.span
235+
initial={{ opacity: 0 }}
236+
animate={{ opacity: 1 }}
237+
className="text-[11px] text-zinc-600 font-mono tabular-nums"
238+
>
239+
{wordCount} {wordCount === 1 ? "word" : "words"}
240+
</motion.span>
241+
)}
242+
</div>
243+
</div>
244+
245+
{/* Content area */}
246+
<div
247+
className={cn(
248+
"rounded-b-xl border border-t-0 border-white/[0.06]",
249+
"bg-[#0f0f14]/80 backdrop-blur-sm",
250+
"shadow-2xl shadow-black/30"
251+
)}
252+
>
253+
{/* Editor — always mounted, hidden when not active */}
254+
<div className={activeTab !== "editor" ? "hidden" : ""}>
255+
<ContentEditor
256+
value={null}
257+
onChange={handleChange}
258+
placeholder="Start writing... (type / for commands)"
259+
className="[&>div]:border-0 [&>div]:rounded-none [&>div]:bg-transparent [&>div]:rounded-b-xl"
260+
/>
261+
</div>
262+
263+
{/* Preview */}
264+
<AnimatePresence mode="wait">
265+
{activeTab === "preview" && content && (
266+
<motion.div
267+
key="preview"
268+
initial={{ opacity: 0 }}
269+
animate={{ opacity: 1 }}
270+
exit={{ opacity: 0 }}
271+
transition={{ duration: 0.2 }}
272+
className="px-5 py-4 min-h-[200px]"
273+
>
274+
<ContentRenderer value={content} />
275+
</motion.div>
276+
)}
277+
</AnimatePresence>
278+
279+
{/* JSON */}
280+
<AnimatePresence mode="wait">
281+
{activeTab === "json" && content && (
282+
<motion.div
283+
key="json"
284+
initial={{ opacity: 0 }}
285+
animate={{ opacity: 1 }}
286+
exit={{ opacity: 0 }}
287+
transition={{ duration: 0.2 }}
288+
className="relative"
289+
>
290+
<div className="flex items-center justify-between px-4 py-2 border-b border-white/[0.04]">
291+
<span className="text-[11px] font-mono text-zinc-600">
292+
SerializedEditorState
293+
</span>
294+
<button
295+
type="button"
296+
onClick={() => {
297+
navigator.clipboard.writeText(
298+
JSON.stringify(content, null, 2)
299+
);
300+
}}
301+
className="text-[11px] font-mono text-zinc-600 hover:text-prism-cyan transition-colors"
302+
>
303+
Copy
304+
</button>
305+
</div>
306+
<pre className="p-4 font-mono text-xs text-zinc-500 overflow-auto max-h-[400px] leading-relaxed">
307+
{JSON.stringify(content, null, 2)}
308+
</pre>
309+
</motion.div>
310+
)}
311+
</AnimatePresence>
312+
</div>
313+
</motion.div>
314+
315+
{/* ── Keyboard shortcuts reference ── */}
316+
<motion.div
317+
initial={{ opacity: 0 }}
318+
animate={{ opacity: 1 }}
319+
transition={{ delay: 0.5, duration: 0.6 }}
320+
className="mt-6"
321+
>
322+
<button
323+
type="button"
324+
onClick={() => setJsonCollapsed(!jsonCollapsed)}
325+
className="flex items-center gap-2 text-[11px] text-zinc-600 hover:text-zinc-400 transition-colors group"
326+
>
327+
<Keyboard className="w-3.5 h-3.5" />
328+
<span className="font-mono">Keyboard shortcuts</span>
329+
<ChevronDown
330+
className={cn(
331+
"w-3 h-3 transition-transform duration-300",
332+
!jsonCollapsed && "rotate-180"
333+
)}
334+
/>
335+
</button>
336+
337+
<AnimatePresence>
338+
{!jsonCollapsed && (
339+
<motion.div
340+
initial={{ height: 0, opacity: 0 }}
341+
animate={{ height: "auto", opacity: 1 }}
342+
exit={{ height: 0, opacity: 0 }}
343+
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
344+
className="overflow-hidden"
345+
>
346+
<div
347+
className={cn(
348+
"mt-3 p-4 rounded-xl",
349+
"bg-white/[0.02] border border-white/[0.04]"
350+
)}
351+
>
352+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-3">
353+
{[
354+
["# + Space", "Heading 1"],
355+
["## + Space", "Heading 2"],
356+
["### + Space", "Heading 3"],
357+
["**text**", "Bold"],
358+
["*text*", "Italic"],
359+
["`text`", "Inline code"],
360+
["- + Space", "Bullet list"],
361+
["1. + Space", "Numbered list"],
362+
["> + Space", "Blockquote"],
363+
["```", "Code block"],
364+
["---", "Divider"],
365+
["/ ", "Slash commands"],
366+
].map(([key, desc]) => (
367+
<div key={key} className="flex items-center gap-2">
368+
<kbd className="px-1.5 py-0.5 rounded text-[10px] font-mono bg-white/[0.04] border border-white/[0.06] text-zinc-500 shrink-0">
369+
{key}
370+
</kbd>
371+
<span className="text-[11px] text-zinc-600 truncate">
372+
{desc}
373+
</span>
374+
</div>
375+
))}
376+
</div>
377+
</div>
378+
</motion.div>
379+
)}
380+
</AnimatePresence>
381+
</motion.div>
382+
</div>
383+
</div>
384+
);
385+
}

0 commit comments

Comments
 (0)