返回 presentation-ai
quote.tsx
1 "use client";
2
3 import { cva } from "class-variance-authority";
4 import {
5 PlateElement,
6 useReadOnly,
7 type PlateElementProps,
8 } from "platejs/react";
9
10 import { cn } from "@/lib/utils";
11 import { type TQuoteElement } from "../plugins/quote-plugin";
12
13 const quoteVariants = cva("relative my-3 w-full max-w-full overflow-hidden", {
14 variants: {
15 variant: {
16 large: "flex flex-col items-center py-4 text-center",
17 "sidequote-icon":
18 "rounded-r-lg border-l-4 border-(--presentation-primary) bg-(--presentation-primary)/5 py-3 pr-4 pl-12",
19 sidequote: "border-l-4 border-(--presentation-primary) py-2 pr-4 pl-4",
20 },
21 },
22 defaultVariants: {
23 variant: "large",
24 },
25 });
26
27 const QuoteMark = ({
28 className,
29 flip = false,
30 }: {
31 className?: string;
32 flip?: boolean;
33 }) => (
34 <svg
35 viewBox="0 0 24 24"
36 fill="currentColor"
37 className={cn(
38 "size-12 text-(--presentation-primary)/30",
39 flip && "rotate-180",
40 className,
41 )}
42 >
43 <path d="M6.5 10c-.223 0-.437.034-.65.065.069-.232.14-.468.254-.68.114-.308.292-.575.469-.844.148-.291.409-.488.601-.737.201-.242.475-.403.692-.604.213-.21.492-.315.714-.463.232-.133.434-.28.65-.35l.539-.222.474-.197-.485-1.938-.597.144c-.191.048-.424.104-.689.171-.271.05-.56.187-.882.312-.317.143-.686.238-1.028.467-.344.218-.741.4-1.091.692-.339.301-.748.562-1.05.944-.33.358-.656.734-.909 1.162-.293.408-.492.856-.702 1.299-.19.443-.343.896-.468 1.336-.237.882-.343 1.72-.384 2.437-.034.718-.014 1.315.028 1.747.015.204.043.402.063.539l.025.168.026-.006A4.5 4.5 0 1 0 6.5 10zm11 0c-.223 0-.437.034-.65.065.069-.232.14-.468.254-.68.114-.308.292-.575.469-.844.148-.291.409-.488.601-.737.201-.242.475-.403.692-.604.213-.21.492-.315.714-.463.232-.133.434-.28.65-.35l.539-.222.474-.197-.485-1.938-.597.144c-.191.048-.424.104-.689.171-.271.05-.56.187-.882.312-.317.143-.686.238-1.028.467-.344.218-.741.4-1.091.692-.339.301-.748.562-1.05.944-.33.358-.656.734-.909 1.162-.293.408-.492.856-.702 1.299-.19.443-.343.896-.468 1.336-.237.882-.343 1.72-.384 2.437-.034.718-.014 1.315.028 1.747.015.204.043.402.063.539l.025.168.026-.006A4.5 4.5 0 1 0 17.5 10z" />
44 </svg>
45 );
46
47 const QuoteIcon = ({ className }: { className?: string }) => (
48 <svg
49 viewBox="0 0 24 24"
50 fill="currentColor"
51 className={cn("size-6 text-(--presentation-primary)", className)}
52 >
53 <path d="M6.5 10c-.223 0-.437.034-.65.065.069-.232.14-.468.254-.68.114-.308.292-.575.469-.844.148-.291.409-.488.601-.737.201-.242.475-.403.692-.604.213-.21.492-.315.714-.463.232-.133.434-.28.65-.35l.539-.222.474-.197-.485-1.938-.597.144c-.191.048-.424.104-.689.171-.271.05-.56.187-.882.312-.317.143-.686.238-1.028.467-.344.218-.741.4-1.091.692-.339.301-.748.562-1.05.944-.33.358-.656.734-.909 1.162-.293.408-.492.856-.702 1.299-.19.443-.343.896-.468 1.336-.237.882-.343 1.72-.384 2.437-.034.718-.014 1.315.028 1.747.015.204.043.402.063.539l.025.168.026-.006A4.5 4.5 0 1 0 6.5 10zm11 0c-.223 0-.437.034-.65.065.069-.232.14-.468.254-.68.114-.308.292-.575.469-.844.148-.291.409-.488.601-.737.201-.242.475-.403.692-.604.213-.21.492-.315.714-.463.232-.133.434-.28.65-.35l.539-.222.474-.197-.485-1.938-.597.144c-.191.048-.424.104-.689.171-.271.05-.56.187-.882.312-.317.143-.686.238-1.028.467-.344.218-.741.4-1.091.692-.339.301-.748.562-1.05.944-.33.358-.656.734-.909 1.162-.293.408-.492.856-.702 1.299-.19.443-.343.896-.468 1.336-.237.882-.343 1.72-.384 2.437-.034.718-.014 1.315.028 1.747.015.204.043.402.063.539l.025.168.026-.006A4.5 4.5 0 1 0 17.5 10z" />
54 </svg>
55 );
56
57 interface QuoteAuthorProps {
58 author: string;
59 className: string;
60 isReadOnly: boolean;
61 prefix?: string;
62 onChange: (author: string) => void;
63 onFocus: () => void;
64 }
65
66 function QuoteAuthor({
67 author,
68 className,
69 isReadOnly,
70 prefix = "",
71 onChange,
72 onFocus,
73 }: QuoteAuthorProps) {
74 if (isReadOnly) {
75 if (!author) return null;
76
77 return <p className={className}>{`${prefix}${author}`}</p>;
78 }
79
80 return (
81 <input
82 type="text"
83 value={author}
84 placeholder="Author name"
85 onFocus={onFocus}
86 onKeyDown={(e) => {
87 e.stopPropagation();
88 }}
89 onChange={(e) => onChange(e.target.value)}
90 onBlur={(e) => onChange(e.target.value)}
91 className={cn(
92 "border-none bg-transparent outline-none placeholder:text-(--presentation-muted-foreground)/60",
93 className,
94 )}
95 aria-label="Quote author"
96 />
97 );
98 }
99
100 export function QuoteElement({
101 element,
102 children,
103 className,
104 ref,
105 ...props
106 }: PlateElementProps<TQuoteElement>) {
107 const readOnly = useReadOnly();
108 const { variant = "large", author = "" } = element;
109
110 const handleAuthorChange = (newAuthor: string) => {
111 if (readOnly) return;
112
113 const quotePath = props.editor.api.findPath(element);
114 if (!quotePath) return;
115
116 props.editor.tf.setNodes({ author: newAuthor }, { at: quotePath });
117 };
118
119 const blurEditor = () => {
120 props.editor.tf.blur();
121 };
122
123 if (variant === "large") {
124 return (
125 <PlateElement
126 ref={ref}
127 element={element}
128 className={cn(quoteVariants({ variant }), className)}
129 {...props}
130 >
131 <div className="relative w-full max-w-3xl px-10">
132 <QuoteMark className="absolute top-0 left-0 size-8" />
133
134 <div className="flex flex-1 flex-col items-center">
135 <blockquote className="font-serif text-xl text-(--presentation-foreground) italic md:text-2xl">
136 {children}
137 </blockquote>
138
139 <div
140 contentEditable={false}
141 data-decor="true"
142 data-slate-void="true"
143 >
144 <QuoteAuthor
145 author={author}
146 className="mt-4 w-full text-center text-sm font-medium text-(--presentation-muted-foreground)"
147 isReadOnly={readOnly}
148 prefix="- "
149 onChange={handleAuthorChange}
150 onFocus={blurEditor}
151 />
152 </div>
153 </div>
154
155 <QuoteMark className="absolute top-0 right-0 size-8" flip />
156 </div>
157 </PlateElement>
158 );
159 }
160
161 if (variant === "sidequote-icon") {
162 return (
163 <PlateElement
164 ref={ref}
165 element={element}
166 className={cn(quoteVariants({ variant }), className)}
167 {...props}
168 >
169 <QuoteIcon className="absolute top-3 left-4" />
170
171 <blockquote className="text-base text-(--presentation-foreground) italic md:text-lg">
172 {children}
173 </blockquote>
174
175 <div contentEditable={false} data-decor="true" data-slate-void="true">
176 <QuoteAuthor
177 author={author}
178 className="mt-3 w-full text-sm font-semibold text-(--presentation-foreground)"
179 isReadOnly={readOnly}
180 onChange={handleAuthorChange}
181 onFocus={blurEditor}
182 />
183 </div>
184 </PlateElement>
185 );
186 }
187
188 return (
189 <PlateElement
190 ref={ref}
191 element={element}
192 className={cn(quoteVariants({ variant: "sidequote" }), className)}
193 {...props}
194 >
195 <blockquote className="text-base text-(--presentation-foreground)">
196 {children}
197 </blockquote>
198
199 <div contentEditable={false} data-decor="true" data-slate-void="true">
200 <QuoteAuthor
201 author={author}
202 className="mt-2 w-full text-sm text-(--presentation-muted-foreground)"
203 isReadOnly={readOnly}
204 prefix="- "
205 onChange={handleAuthorChange}
206 onFocus={blurEditor}
207 />
208 </div>
209 </PlateElement>
210 );
211 }
212
212 lines Plain Text