返回 presentation-ai
contributor.tsx
1 "use client";
2
3 import {
4 BlockSelectionPlugin,
5 useBlockSelected,
6 } from "@platejs/selection/react";
7 import { formatDistanceToNow } from "date-fns";
8 import { UserRound } from "lucide-react";
9 import { PlateElement, type PlateElementProps } from "platejs/react";
10 import type * as React from "react";
11
12 import { usePresentationOwner } from "@/hooks/presentation/usePresentationOwner";
13 import { cn } from "@/lib/utils";
14 import { usePresentationState } from "@/states/presentation-state";
15 import { type TContributorElement } from "../lib";
16
17 function getJustifyClass(alignment: TContributorElement["alignment"]) {
18 if (alignment === "left") return "justify-start";
19 if (alignment === "right") return "justify-end";
20 return "justify-center";
21 }
22
23 function getOwnerDisplayName(name: string | null | undefined) {
24 return name?.trim() ? name : "Unknown owner";
25 }
26
27 function getInitials(name: string) {
28 return name
29 .split(/[\s@._-]+/)
30 .filter(Boolean)
31 .slice(0, 2)
32 .map((part) => part[0]?.toUpperCase())
33 .join("");
34 }
35
36 function getEditedLabel(updatedAt: string | null | undefined) {
37 if (!updatedAt) return "Just now";
38
39 const updatedDate = new Date(updatedAt);
40 if (Number.isNaN(updatedDate.getTime())) {
41 return "Just now";
42 }
43
44 return `Last edited ${formatDistanceToNow(updatedDate, { addSuffix: true })}`;
45 }
46
47 export default function ContributorElement(
48 props: PlateElementProps<TContributorElement>,
49 ) {
50 const isBlockSelected = useBlockSelected();
51 const presentationId = usePresentationState((s) => s.currentPresentationId);
52 const updatedAt = usePresentationState((s) => s.currentPresentationUpdatedAt);
53 const { data: owner } = usePresentationOwner(presentationId);
54 const ownerName = getOwnerDisplayName(owner?.name);
55 const editedLabel =
56 props.element.editedLabel ??
57 getEditedLabel(updatedAt ?? props.element.updatedAt ?? null);
58 const avatarUrl = owner?.image;
59 const avatarStyle = avatarUrl
60 ? ({
61 backgroundImage: `url(${JSON.stringify(avatarUrl)})`,
62 } satisfies React.CSSProperties)
63 : undefined;
64 const initials = getInitials(ownerName);
65 const alignment = props.element.alignment ?? "left";
66 const accentColor = "var(--presentation-primary)";
67 const backgroundColor = props.element.backgroundColor ?? "transparent";
68 const textColor =
69 props.element.textColor ??
70 props.element.color ??
71 "var(--presentation-heading)";
72 const style = {
73 "--presentation-contributor-accent": accentColor,
74 "--presentation-contributor-background": backgroundColor,
75 "--presentation-contributor-text": textColor,
76 } as React.CSSProperties;
77 const selectContributorBlock = () => {
78 const elementId = props.element.id;
79 if (typeof elementId !== "string" || !elementId) return;
80
81 const blockSelectionApi = props.editor.getApi(BlockSelectionPlugin);
82 blockSelectionApi.blockSelection.set([elementId]);
83 blockSelectionApi.blockSelection.focus();
84 };
85
86 const handleMouseDownCapture = (event: React.MouseEvent<HTMLDivElement>) => {
87 if (event.button !== 0) return;
88
89 selectContributorBlock();
90 };
91
92 return (
93 <PlateElement
94 {...props}
95 className={cn(
96 "slate-editable-void slate-selectable relative my-2 flex w-full",
97 getJustifyClass(alignment),
98 isBlockSelected && "bg-brand/13",
99 )}
100 style={style}
101 >
102 <div
103 className="flex items-center gap-3 rounded-xl bg-(--presentation-contributor-background) px-4 py-3 text-(--presentation-contributor-text)"
104 contentEditable={false}
105 data-slate-void="true"
106 onMouseDownCapture={handleMouseDownCapture}
107 >
108 <div
109 aria-label={avatarUrl ? ownerName : undefined}
110 className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-(--presentation-contributor-accent)/50 bg-(--presentation-contributor-accent)/20 bg-cover bg-center text-(--presentation-contributor-accent)"
111 role={avatarUrl ? "img" : undefined}
112 style={avatarStyle}
113 >
114 {avatarUrl ? null : initials ? (
115 <span className="text-sm font-bold">{initials}</span>
116 ) : (
117 <UserRound className="size-6" />
118 )}
119 </div>
120 <div className="min-w-0">
121 <div className="text-2xl leading-tight font-bold">
122 <span>by </span>
123 <span>{ownerName}</span>
124 </div>
125 <div className="text-base leading-snug opacity-75">{editedLabel}</div>
126 </div>
127 </div>
128 <span className="sr-only">{props.children}</span>
129 </PlateElement>
130 );
131 }
132
132 lines Plain Text