@multicomma/arkcn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/bin/arkcn.mjs +78 -0
- package/cli/commands.mjs +251 -0
- package/cli/project.mjs +168 -0
- package/cli/registry.mjs +50 -0
- package/cli/ui.mjs +46 -0
- package/mcp/server.mjs +149 -0
- package/package.json +201 -0
- package/registry/base.css +680 -0
- package/registry/index.json +1833 -0
- package/registry/items/accordion.json +18 -0
- package/registry/items/activity-feed.json +18 -0
- package/registry/items/alert-dialog.json +18 -0
- package/registry/items/alert.json +17 -0
- package/registry/items/angle-slider.json +17 -0
- package/registry/items/app-shell.json +28 -0
- package/registry/items/aspect-ratio.json +13 -0
- package/registry/items/attachment.json +19 -0
- package/registry/items/avatar.json +17 -0
- package/registry/items/badge.json +18 -0
- package/registry/items/breadcrumb.json +18 -0
- package/registry/items/bubble.json +18 -0
- package/registry/items/button-group.json +19 -0
- package/registry/items/button.json +18 -0
- package/registry/items/calendar.json +19 -0
- package/registry/items/canvas.json +24 -0
- package/registry/items/card.json +15 -0
- package/registry/items/carousel.json +19 -0
- package/registry/items/cascader.json +23 -0
- package/registry/items/chart.json +17 -0
- package/registry/items/chat.json +23 -0
- package/registry/items/checkbox.json +18 -0
- package/registry/items/clipboard.json +19 -0
- package/registry/items/collapsible.json +15 -0
- package/registry/items/color-picker.json +19 -0
- package/registry/items/combobox.json +20 -0
- package/registry/items/command.json +21 -0
- package/registry/items/comment-thread.json +25 -0
- package/registry/items/context-menu.json +18 -0
- package/registry/items/controllable.json +14 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/data-table-adapter.json +14 -0
- package/registry/items/data-table.json +32 -0
- package/registry/items/date-input.json +17 -0
- package/registry/items/dialog.json +19 -0
- package/registry/items/direction.json +13 -0
- package/registry/items/drag-preview.json +16 -0
- package/registry/items/drawer.json +18 -0
- package/registry/items/dropdown-menu.json +18 -0
- package/registry/items/editable.json +17 -0
- package/registry/items/empty.json +17 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-upload.json +19 -0
- package/registry/items/floating-panel.json +19 -0
- package/registry/items/floating-toolbar.json +16 -0
- package/registry/items/form.json +14 -0
- package/registry/items/format.json +15 -0
- package/registry/items/gantt.json +22 -0
- package/registry/items/history.json +14 -0
- package/registry/items/hotkeys.json +18 -0
- package/registry/items/hover-card.json +17 -0
- package/registry/items/image-cropper.json +17 -0
- package/registry/items/input-group.json +20 -0
- package/registry/items/input-otp.json +18 -0
- package/registry/items/input.json +15 -0
- package/registry/items/item.json +19 -0
- package/registry/items/json-tree-view.json +17 -0
- package/registry/items/kanban.json +26 -0
- package/registry/items/kbd.json +15 -0
- package/registry/items/label.json +15 -0
- package/registry/items/live-region.json +16 -0
- package/registry/items/marker.json +18 -0
- package/registry/items/marquee.json +17 -0
- package/registry/items/menubar.json +19 -0
- package/registry/items/message.json +15 -0
- package/registry/items/navigation-menu.json +19 -0
- package/registry/items/node-graph.json +22 -0
- package/registry/items/number-input.json +18 -0
- package/registry/items/pagination.json +18 -0
- package/registry/items/password-input.json +18 -0
- package/registry/items/popconfirm.json +20 -0
- package/registry/items/popover.json +18 -0
- package/registry/items/progress.json +17 -0
- package/registry/items/qr-code.json +17 -0
- package/registry/items/query-builder.json +23 -0
- package/registry/items/radio-group.json +17 -0
- package/registry/items/rating-group.json +18 -0
- package/registry/items/resizable.json +17 -0
- package/registry/items/rich-text-editor.json +37 -0
- package/registry/items/row-layout.json +15 -0
- package/registry/items/scheduler.json +23 -0
- package/registry/items/scroll-area.json +17 -0
- package/registry/items/segment-group.json +17 -0
- package/registry/items/select.json +19 -0
- package/registry/items/separator.json +15 -0
- package/registry/items/sheet.json +19 -0
- package/registry/items/sidebar.json +27 -0
- package/registry/items/signature-pad.json +19 -0
- package/registry/items/skeleton.json +15 -0
- package/registry/items/slider.json +17 -0
- package/registry/items/sonner.json +17 -0
- package/registry/items/spinner.json +17 -0
- package/registry/items/steps.json +19 -0
- package/registry/items/swap.json +17 -0
- package/registry/items/switch.json +17 -0
- package/registry/items/table.json +16 -0
- package/registry/items/tabs.json +18 -0
- package/registry/items/tags-input.json +18 -0
- package/registry/items/textarea.json +15 -0
- package/registry/items/time.json +14 -0
- package/registry/items/timer.json +18 -0
- package/registry/items/toc.json +17 -0
- package/registry/items/toggle-group.json +19 -0
- package/registry/items/toggle.json +18 -0
- package/registry/items/tooltip.json +17 -0
- package/registry/items/tour.json +19 -0
- package/registry/items/transfer-list.json +24 -0
- package/registry/items/tree-select.json +24 -0
- package/registry/items/tree-view.json +18 -0
- package/registry/items/use-mobile.json +13 -0
- package/registry/items/utils.json +17 -0
- package/registry/items/virtual-list.json +16 -0
- package/src/components/ui/accordion.tsx +111 -0
- package/src/components/ui/activity-feed.tsx +228 -0
- package/src/components/ui/alert-dialog.tsx +204 -0
- package/src/components/ui/alert.tsx +55 -0
- package/src/components/ui/angle-slider.tsx +117 -0
- package/src/components/ui/app-shell.tsx +985 -0
- package/src/components/ui/aspect-ratio.tsx +35 -0
- package/src/components/ui/attachment.tsx +193 -0
- package/src/components/ui/avatar.tsx +94 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/breadcrumb.tsx +93 -0
- package/src/components/ui/bubble.tsx +119 -0
- package/src/components/ui/button-group.tsx +79 -0
- package/src/components/ui/button.tsx +65 -0
- package/src/components/ui/calendar.tsx +172 -0
- package/src/components/ui/canvas.tsx +723 -0
- package/src/components/ui/card.tsx +69 -0
- package/src/components/ui/carousel.tsx +228 -0
- package/src/components/ui/cascader.tsx +754 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/chat.tsx +544 -0
- package/src/components/ui/checkbox.tsx +94 -0
- package/src/components/ui/clipboard.tsx +94 -0
- package/src/components/ui/collapsible.tsx +32 -0
- package/src/components/ui/color-picker.tsx +441 -0
- package/src/components/ui/combobox.tsx +400 -0
- package/src/components/ui/command.tsx +292 -0
- package/src/components/ui/comment-thread.tsx +923 -0
- package/src/components/ui/context-menu.tsx +331 -0
- package/src/components/ui/data-grid.tsx +1227 -0
- package/src/components/ui/data-table.tsx +993 -0
- package/src/components/ui/date-input.tsx +103 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +32 -0
- package/src/components/ui/drawer.tsx +200 -0
- package/src/components/ui/dropdown-menu.tsx +329 -0
- package/src/components/ui/editable.tsx +90 -0
- package/src/components/ui/empty.tsx +86 -0
- package/src/components/ui/field.tsx +218 -0
- package/src/components/ui/file-upload.tsx +204 -0
- package/src/components/ui/floating-panel.tsx +199 -0
- package/src/components/ui/floating-toolbar.tsx +79 -0
- package/src/components/ui/format.tsx +22 -0
- package/src/components/ui/gantt.tsx +950 -0
- package/src/components/ui/hotkeys.tsx +340 -0
- package/src/components/ui/hover-card.tsx +83 -0
- package/src/components/ui/image-cropper.tsx +100 -0
- package/src/components/ui/input-group.tsx +133 -0
- package/src/components/ui/input-otp.tsx +82 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/item.tsx +174 -0
- package/src/components/ui/json-tree-view.tsx +37 -0
- package/src/components/ui/kanban.tsx +884 -0
- package/src/components/ui/kbd.tsx +20 -0
- package/src/components/ui/label.tsx +30 -0
- package/src/components/ui/live-region.tsx +54 -0
- package/src/components/ui/marker.tsx +66 -0
- package/src/components/ui/marquee.tsx +61 -0
- package/src/components/ui/menubar.tsx +456 -0
- package/src/components/ui/message.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +202 -0
- package/src/components/ui/node-graph.tsx +1723 -0
- package/src/components/ui/number-input.tsx +148 -0
- package/src/components/ui/pagination.tsx +88 -0
- package/src/components/ui/password-input.tsx +104 -0
- package/src/components/ui/popconfirm.tsx +149 -0
- package/src/components/ui/popover.tsx +126 -0
- package/src/components/ui/progress.tsx +114 -0
- package/src/components/ui/qr-code.tsx +51 -0
- package/src/components/ui/query-builder.tsx +938 -0
- package/src/components/ui/radio-group.tsx +108 -0
- package/src/components/ui/rating-group.tsx +91 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/rich-text-editor.tsx +845 -0
- package/src/components/ui/scheduler.tsx +1164 -0
- package/src/components/ui/scroll-area.tsx +82 -0
- package/src/components/ui/segment-group.tsx +95 -0
- package/src/components/ui/select.tsx +354 -0
- package/src/components/ui/separator.tsx +32 -0
- package/src/components/ui/sheet.tsx +130 -0
- package/src/components/ui/sidebar.tsx +665 -0
- package/src/components/ui/signature-pad.tsx +112 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +183 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/steps.tsx +152 -0
- package/src/components/ui/swap.tsx +33 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +100 -0
- package/src/components/ui/tags-input.tsx +153 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/timer.tsx +67 -0
- package/src/components/ui/toc.tsx +64 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +46 -0
- package/src/components/ui/tooltip.tsx +113 -0
- package/src/components/ui/tour.tsx +168 -0
- package/src/components/ui/transfer-list.tsx +700 -0
- package/src/components/ui/tree-select.tsx +638 -0
- package/src/components/ui/tree-view.tsx +217 -0
- package/src/components/ui/virtual-list.tsx +343 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/lib/controllable.ts +31 -0
- package/src/lib/data-table-adapter.ts +67 -0
- package/src/lib/drag-preview.ts +39 -0
- package/src/lib/form.ts +39 -0
- package/src/lib/history.ts +55 -0
- package/src/lib/row-layout.ts +81 -0
- package/src/lib/table.ts +483 -0
- package/src/lib/time.ts +37 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/arkcn.css +764 -0
- package/src/styles/index.css +3 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { ark } from "@ark-ui/react"
|
|
3
|
+
import { CornerDownRightIcon, PencilIcon, SendIcon } from "lucide-react"
|
|
4
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
5
|
+
import { Button } from "@/components/ui/button"
|
|
6
|
+
import { Kbd } from "@/components/ui/kbd"
|
|
7
|
+
import { Popover, PopoverContent } from "@/components/ui/popover"
|
|
8
|
+
import { formatRelativeTime } from "@/lib/time"
|
|
9
|
+
import { cn } from "@/lib/utils"
|
|
10
|
+
import { useControllable } from "@/lib/controllable"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CommentThread — compositional, data-agnostic comments with replies,
|
|
14
|
+
* reactions and a mentions composer. The consumer owns the comments and
|
|
15
|
+
* the people list; the thread owns only which comment is being replied to or
|
|
16
|
+
* edited. Mentions are plain text (`@Full Name`); `extractMentions` maps
|
|
17
|
+
* them back to ids and `CommentBody` highlights them.
|
|
18
|
+
*
|
|
19
|
+
* Anatomy:
|
|
20
|
+
* CommentThread { people, currentUserId }
|
|
21
|
+
* CommentList > Comment value= authorId=
|
|
22
|
+
* CommentAvatar + CommentContent
|
|
23
|
+
* CommentHeader > CommentAuthor, CommentTime date=, CommentMeta
|
|
24
|
+
* CommentBody highlights @mentions
|
|
25
|
+
* CommentReactions > CommentReaction emoji= count= active=
|
|
26
|
+
* CommentActions > CommentReplyTrigger, CommentEditTrigger, …
|
|
27
|
+
* CommentReplies > Comment … nested, with a guide line
|
|
28
|
+
* CommentComposer { people, onSubmit }
|
|
29
|
+
* CommentComposerReplyingTo "Replying to X" strip
|
|
30
|
+
* CommentComposerInput textarea; type @ to mention, ⌘↵ to send
|
|
31
|
+
* CommentComposerMentionList suggestions under the input
|
|
32
|
+
* CommentComposerFooter > CommentComposerHint, CommentComposerCancelTrigger, CommentComposerSubmitTrigger
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
type Person = { id: string; name: string; initials?: string; avatar?: string }
|
|
36
|
+
|
|
37
|
+
type ThreadContextValue = {
|
|
38
|
+
people: Person[]
|
|
39
|
+
currentUserId?: string
|
|
40
|
+
replyTo: string | null
|
|
41
|
+
setReplyTo: (id: string | null) => void
|
|
42
|
+
editing: string | null
|
|
43
|
+
setEditing: (id: string | null) => void
|
|
44
|
+
}
|
|
45
|
+
const ThreadContext = React.createContext<ThreadContextValue | null>(null)
|
|
46
|
+
const CommentContext = React.createContext<{ value: string; authorId?: string; depth: number } | null>(null)
|
|
47
|
+
|
|
48
|
+
function useCommentThread() {
|
|
49
|
+
const ctx = React.useContext(ThreadContext)
|
|
50
|
+
if (!ctx) throw new Error("Comment parts must be used within <CommentThread>")
|
|
51
|
+
return ctx
|
|
52
|
+
}
|
|
53
|
+
function useComment() {
|
|
54
|
+
const ctx = React.useContext(CommentContext)
|
|
55
|
+
if (!ctx) throw new Error("Comment parts must be used within <Comment>")
|
|
56
|
+
return ctx
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const initialsOf = (person?: Person) =>
|
|
60
|
+
person?.initials ??
|
|
61
|
+
(person?.name ?? "?")
|
|
62
|
+
.split(/\s+/)
|
|
63
|
+
.map((p) => p[0])
|
|
64
|
+
.join("")
|
|
65
|
+
.slice(0, 2)
|
|
66
|
+
.toUpperCase()
|
|
67
|
+
|
|
68
|
+
/* ---------------------------------------------------------------------------
|
|
69
|
+
* Thread
|
|
70
|
+
* ------------------------------------------------------------------------- */
|
|
71
|
+
|
|
72
|
+
function CommentThread({
|
|
73
|
+
people = [],
|
|
74
|
+
currentUserId,
|
|
75
|
+
replyTo: replyToProp,
|
|
76
|
+
onReplyToChange,
|
|
77
|
+
className,
|
|
78
|
+
...props
|
|
79
|
+
}: React.ComponentProps<"div"> & {
|
|
80
|
+
people?: Person[]
|
|
81
|
+
currentUserId?: string
|
|
82
|
+
replyTo?: string | null
|
|
83
|
+
onReplyToChange?: (id: string | null) => void
|
|
84
|
+
}) {
|
|
85
|
+
const [replyTo, setReplyTo] = useControllable<string | null>(replyToProp, null, onReplyToChange)
|
|
86
|
+
const [editing, setEditing] = React.useState<string | null>(null)
|
|
87
|
+
const ctx = React.useMemo(
|
|
88
|
+
() => ({ people, currentUserId, replyTo, setReplyTo, editing, setEditing }),
|
|
89
|
+
[people, currentUserId, replyTo, setReplyTo, editing]
|
|
90
|
+
)
|
|
91
|
+
return (
|
|
92
|
+
<ThreadContext.Provider value={ctx}>
|
|
93
|
+
<div data-slot="comment-thread" className={cn("flex flex-col gap-4", className)} {...props} />
|
|
94
|
+
</ThreadContext.Provider>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function CommentList({ className, ...props }: React.ComponentProps<"ol">) {
|
|
99
|
+
return <ol data-slot="comment-list" className={cn("flex flex-col gap-4", className)} {...props} />
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function Comment({
|
|
103
|
+
value,
|
|
104
|
+
authorId,
|
|
105
|
+
className,
|
|
106
|
+
...props
|
|
107
|
+
}: React.ComponentProps<"li"> & { value: string; authorId?: string }) {
|
|
108
|
+
const thread = useCommentThread()
|
|
109
|
+
const parent = React.useContext(CommentContext)
|
|
110
|
+
const depth = parent ? parent.depth + 1 : 0
|
|
111
|
+
const ctx = React.useMemo(() => ({ value, authorId, depth }), [value, authorId, depth])
|
|
112
|
+
return (
|
|
113
|
+
<CommentContext.Provider value={ctx}>
|
|
114
|
+
<li
|
|
115
|
+
data-slot="comment"
|
|
116
|
+
data-value={value}
|
|
117
|
+
data-depth={depth}
|
|
118
|
+
data-own={authorId && authorId === thread.currentUserId ? "" : undefined}
|
|
119
|
+
data-replying={thread.replyTo === value ? "" : undefined}
|
|
120
|
+
data-editing={thread.editing === value ? "" : undefined}
|
|
121
|
+
className={cn("group/comment flex gap-3", className)}
|
|
122
|
+
{...props}
|
|
123
|
+
/>
|
|
124
|
+
</CommentContext.Provider>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Avatar for the comment's author (from `people`), or pass your own children. */
|
|
129
|
+
function CommentAvatar({ className, children, ...props }: React.ComponentProps<typeof Avatar>) {
|
|
130
|
+
const { people } = useCommentThread()
|
|
131
|
+
const { authorId, depth } = useComment()
|
|
132
|
+
const person = people.find((p) => p.id === authorId)
|
|
133
|
+
return (
|
|
134
|
+
<Avatar
|
|
135
|
+
data-slot="comment-avatar"
|
|
136
|
+
size={depth > 0 ? "sm" : "default"}
|
|
137
|
+
className={cn("shrink-0", className)}
|
|
138
|
+
{...props}
|
|
139
|
+
>
|
|
140
|
+
{children ?? (
|
|
141
|
+
<>
|
|
142
|
+
{person?.avatar && <AvatarImage src={person.avatar} alt="" />}
|
|
143
|
+
<AvatarFallback>{initialsOf(person)}</AvatarFallback>
|
|
144
|
+
</>
|
|
145
|
+
)}
|
|
146
|
+
</Avatar>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function CommentContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
151
|
+
return <div data-slot="comment-content" className={cn("flex min-w-0 flex-1 flex-col gap-1", className)} {...props} />
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function CommentHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
155
|
+
return (
|
|
156
|
+
<div
|
|
157
|
+
data-slot="comment-header"
|
|
158
|
+
className={cn("flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm", className)}
|
|
159
|
+
{...props}
|
|
160
|
+
/>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Author name from `people` by default. */
|
|
165
|
+
function CommentAuthor({ className, children, ...props }: React.ComponentProps<"span">) {
|
|
166
|
+
const { people } = useCommentThread()
|
|
167
|
+
const { authorId } = useComment()
|
|
168
|
+
return (
|
|
169
|
+
<span data-slot="comment-author" className={cn("font-medium", className)} {...props}>
|
|
170
|
+
{children ?? people.find((p) => p.id === authorId)?.name ?? "Unknown"}
|
|
171
|
+
</span>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function CommentTime({
|
|
176
|
+
date,
|
|
177
|
+
now,
|
|
178
|
+
className,
|
|
179
|
+
children,
|
|
180
|
+
...props
|
|
181
|
+
}: Omit<React.ComponentProps<"time">, "dateTime"> & { date: Date; now?: Date }) {
|
|
182
|
+
return (
|
|
183
|
+
<time
|
|
184
|
+
data-slot="comment-time"
|
|
185
|
+
dateTime={date.toISOString()}
|
|
186
|
+
title={date.toLocaleString()}
|
|
187
|
+
className={cn("text-xs text-muted-foreground", className)}
|
|
188
|
+
{...props}
|
|
189
|
+
>
|
|
190
|
+
{children ?? formatRelativeTime(date, { now })}
|
|
191
|
+
</time>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Small trailing note, e.g. "edited" or a role badge. */
|
|
196
|
+
function CommentMeta({ className, ...props }: React.ComponentProps<"span">) {
|
|
197
|
+
return <span data-slot="comment-meta" className={cn("text-xs text-muted-foreground", className)} {...props} />
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Split text into runs, turning `@Full Name` for known people into mention nodes. */
|
|
201
|
+
function renderMentions(text: string, people: Person[]) {
|
|
202
|
+
if (!people.length || !text.includes("@")) return [text]
|
|
203
|
+
const names = [...people].sort((a, b) => b.name.length - a.name.length)
|
|
204
|
+
const pattern = new RegExp(
|
|
205
|
+
`@(${names.map((p) => p.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?![\\w])`,
|
|
206
|
+
"g"
|
|
207
|
+
)
|
|
208
|
+
const nodes: React.ReactNode[] = []
|
|
209
|
+
let last = 0
|
|
210
|
+
for (const match of text.matchAll(pattern)) {
|
|
211
|
+
const index = match.index ?? 0
|
|
212
|
+
if (index > last) nodes.push(text.slice(last, index))
|
|
213
|
+
const person = people.find((p) => p.name === match[1])
|
|
214
|
+
nodes.push(
|
|
215
|
+
<CommentMention key={`${index}-${match[1]}`} personId={person?.id}>
|
|
216
|
+
@{match[1]}
|
|
217
|
+
</CommentMention>
|
|
218
|
+
)
|
|
219
|
+
last = index + match[0].length
|
|
220
|
+
}
|
|
221
|
+
if (last < text.length) nodes.push(text.slice(last))
|
|
222
|
+
return nodes
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function CommentMention({ personId, className, ...props }: React.ComponentProps<"span"> & { personId?: string }) {
|
|
226
|
+
const { currentUserId } = useCommentThread()
|
|
227
|
+
return (
|
|
228
|
+
<span
|
|
229
|
+
data-slot="comment-mention"
|
|
230
|
+
data-person={personId}
|
|
231
|
+
data-self={personId && personId === currentUserId ? "" : undefined}
|
|
232
|
+
className={cn(
|
|
233
|
+
"rounded-sm bg-primary/10 px-1 font-medium text-primary data-self:bg-amber-500/20 data-self:text-amber-700 dark:data-self:text-amber-300",
|
|
234
|
+
className
|
|
235
|
+
)}
|
|
236
|
+
{...props}
|
|
237
|
+
/>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Plain text with mentions highlighted; pass `children` to render anything else. */
|
|
242
|
+
function CommentBody({ text, className, children, ...props }: React.ComponentProps<"div"> & { text?: string }) {
|
|
243
|
+
const { people } = useCommentThread()
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
data-slot="comment-body"
|
|
247
|
+
className={cn("text-sm/relaxed wrap-break-word whitespace-pre-wrap", className)}
|
|
248
|
+
{...props}
|
|
249
|
+
>
|
|
250
|
+
{children ?? (text !== undefined ? renderMentions(text, people) : null)}
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function CommentReactions({ className, ...props }: React.ComponentProps<"div">) {
|
|
256
|
+
return (
|
|
257
|
+
<div
|
|
258
|
+
data-slot="comment-reactions"
|
|
259
|
+
className={cn("flex flex-wrap items-center gap-1 pt-0.5", className)}
|
|
260
|
+
{...props}
|
|
261
|
+
/>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function CommentReaction({
|
|
266
|
+
emoji,
|
|
267
|
+
count = 0,
|
|
268
|
+
active = false,
|
|
269
|
+
className,
|
|
270
|
+
children,
|
|
271
|
+
...props
|
|
272
|
+
}: React.ComponentProps<typeof ark.button> & { emoji: string; count?: number; active?: boolean }) {
|
|
273
|
+
return (
|
|
274
|
+
<ark.button
|
|
275
|
+
type="button"
|
|
276
|
+
data-slot="comment-reaction"
|
|
277
|
+
data-active={active ? "" : undefined}
|
|
278
|
+
aria-pressed={active}
|
|
279
|
+
aria-label={`${emoji} ${count}`}
|
|
280
|
+
className={cn(
|
|
281
|
+
"inline-flex h-6 items-center gap-1 rounded-full border bg-background px-1.5 text-xs transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none data-active:border-primary/40 data-active:bg-primary/10",
|
|
282
|
+
className
|
|
283
|
+
)}
|
|
284
|
+
{...props}
|
|
285
|
+
>
|
|
286
|
+
<span aria-hidden>{emoji}</span>
|
|
287
|
+
{children ?? <span className="tabular-nums">{count}</span>}
|
|
288
|
+
</ark.button>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Row of small text buttons; revealed on hover/focus, always visible when the comment is active. */
|
|
293
|
+
function CommentActions({ className, ...props }: React.ComponentProps<"div">) {
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
data-slot="comment-actions"
|
|
297
|
+
className={cn(
|
|
298
|
+
"flex items-center gap-1 text-xs text-muted-foreground opacity-0 transition-opacity group-focus-within/comment:opacity-100 group-hover/comment:opacity-100 group-data-editing/comment:opacity-100 group-data-replying/comment:opacity-100",
|
|
299
|
+
className
|
|
300
|
+
)}
|
|
301
|
+
{...props}
|
|
302
|
+
/>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function CommentReplyTrigger({ asChild, children, onClick, className, ...props }: React.ComponentProps<typeof Button>) {
|
|
307
|
+
const { replyTo, setReplyTo } = useCommentThread()
|
|
308
|
+
const { value } = useComment()
|
|
309
|
+
return (
|
|
310
|
+
<Button
|
|
311
|
+
data-slot="comment-reply-trigger"
|
|
312
|
+
variant="ghost"
|
|
313
|
+
size="xs"
|
|
314
|
+
asChild={asChild}
|
|
315
|
+
aria-pressed={replyTo === value}
|
|
316
|
+
className={cn("h-6 px-1.5 text-xs", className)}
|
|
317
|
+
onClick={(event) => {
|
|
318
|
+
onClick?.(event)
|
|
319
|
+
if (!event.defaultPrevented) setReplyTo(replyTo === value ? null : value)
|
|
320
|
+
}}
|
|
321
|
+
{...props}
|
|
322
|
+
>
|
|
323
|
+
{asChild
|
|
324
|
+
? children
|
|
325
|
+
: (children ?? (
|
|
326
|
+
<>
|
|
327
|
+
<CornerDownRightIcon /> Reply
|
|
328
|
+
</>
|
|
329
|
+
))}
|
|
330
|
+
</Button>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function CommentEditTrigger({ asChild, children, onClick, className, ...props }: React.ComponentProps<typeof Button>) {
|
|
335
|
+
const { editing, setEditing } = useCommentThread()
|
|
336
|
+
const { value } = useComment()
|
|
337
|
+
return (
|
|
338
|
+
<Button
|
|
339
|
+
data-slot="comment-edit-trigger"
|
|
340
|
+
variant="ghost"
|
|
341
|
+
size="xs"
|
|
342
|
+
asChild={asChild}
|
|
343
|
+
aria-pressed={editing === value}
|
|
344
|
+
className={cn("h-6 px-1.5 text-xs", className)}
|
|
345
|
+
onClick={(event) => {
|
|
346
|
+
onClick?.(event)
|
|
347
|
+
if (!event.defaultPrevented) setEditing(editing === value ? null : value)
|
|
348
|
+
}}
|
|
349
|
+
{...props}
|
|
350
|
+
>
|
|
351
|
+
{asChild
|
|
352
|
+
? children
|
|
353
|
+
: (children ?? (
|
|
354
|
+
<>
|
|
355
|
+
<PencilIcon /> Edit
|
|
356
|
+
</>
|
|
357
|
+
))}
|
|
358
|
+
</Button>
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Nested replies with a guide line; put `Comment`s inside. */
|
|
363
|
+
function CommentReplies({ className, ...props }: React.ComponentProps<"ol">) {
|
|
364
|
+
return (
|
|
365
|
+
<ol data-slot="comment-replies" className={cn("mt-2 flex flex-col gap-3 border-s-2 ps-3", className)} {...props} />
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function CommentEmpty({ className, ...props }: React.ComponentProps<"div">) {
|
|
370
|
+
return (
|
|
371
|
+
<div
|
|
372
|
+
data-slot="comment-empty"
|
|
373
|
+
className={cn("rounded-lg border border-dashed px-3 py-6 text-center text-sm text-muted-foreground", className)}
|
|
374
|
+
{...props}
|
|
375
|
+
/>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ---------------------------------------------------------------------------
|
|
380
|
+
* Composer with mentions
|
|
381
|
+
* ------------------------------------------------------------------------- */
|
|
382
|
+
|
|
383
|
+
type MentionState = { query: string; start: number; index: number } | null
|
|
384
|
+
|
|
385
|
+
type ComposerContextValue = {
|
|
386
|
+
people: Person[]
|
|
387
|
+
value: string
|
|
388
|
+
setValue: (value: string) => void
|
|
389
|
+
mention: MentionState
|
|
390
|
+
setMention: (mention: MentionState) => void
|
|
391
|
+
suggestions: Person[]
|
|
392
|
+
pick: (person: Person) => void
|
|
393
|
+
/** The input registers how a pick is applied to its DOM. */
|
|
394
|
+
registerPicker: (fn: (person: Person, mention: NonNullable<MentionState>) => void) => () => void
|
|
395
|
+
submit: () => void
|
|
396
|
+
cancel: () => void
|
|
397
|
+
canSubmit: boolean
|
|
398
|
+
inputRef: React.RefObject<HTMLDivElement | null>
|
|
399
|
+
listId: string
|
|
400
|
+
}
|
|
401
|
+
const ComposerContext = React.createContext<ComposerContextValue | null>(null)
|
|
402
|
+
function useComposer() {
|
|
403
|
+
const ctx = React.useContext(ComposerContext)
|
|
404
|
+
if (!ctx) throw new Error("Composer parts must be used within <CommentComposer>")
|
|
405
|
+
return ctx
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Ids of people whose `@Full Name` appears in the text. */
|
|
409
|
+
function extractMentions(text: string, people: Person[]) {
|
|
410
|
+
return people
|
|
411
|
+
.filter((p) => new RegExp(`@${p.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w])`).test(text))
|
|
412
|
+
.map((p) => p.id)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function CommentComposer({
|
|
416
|
+
people = [],
|
|
417
|
+
value: valueProp,
|
|
418
|
+
defaultValue = "",
|
|
419
|
+
onValueChange,
|
|
420
|
+
onSubmit,
|
|
421
|
+
onCancel,
|
|
422
|
+
maxSuggestions = 6,
|
|
423
|
+
className,
|
|
424
|
+
children,
|
|
425
|
+
...props
|
|
426
|
+
}: Omit<React.ComponentProps<"div">, "onSubmit" | "defaultValue"> & {
|
|
427
|
+
people?: Person[]
|
|
428
|
+
value?: string
|
|
429
|
+
defaultValue?: string
|
|
430
|
+
onValueChange?: (value: string) => void
|
|
431
|
+
/** Called with the trimmed text and the ids of mentioned people. */
|
|
432
|
+
onSubmit?: (comment: { text: string; mentions: string[] }) => void
|
|
433
|
+
onCancel?: () => void
|
|
434
|
+
maxSuggestions?: number
|
|
435
|
+
}) {
|
|
436
|
+
const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange)
|
|
437
|
+
const [mention, setMention] = React.useState<MentionState>(null)
|
|
438
|
+
const inputRef = React.useRef<HTMLDivElement>(null)
|
|
439
|
+
const listId = React.useId()
|
|
440
|
+
const pickerRef = React.useRef<((person: Person, mention: NonNullable<MentionState>) => void) | null>(null)
|
|
441
|
+
const registerPicker = React.useCallback((fn: (person: Person, mention: NonNullable<MentionState>) => void) => {
|
|
442
|
+
pickerRef.current = fn
|
|
443
|
+
return () => {
|
|
444
|
+
if (pickerRef.current === fn) pickerRef.current = null
|
|
445
|
+
}
|
|
446
|
+
}, [])
|
|
447
|
+
|
|
448
|
+
const suggestions = React.useMemo(() => {
|
|
449
|
+
if (!mention) return []
|
|
450
|
+
const q = mention.query.toLowerCase()
|
|
451
|
+
return people.filter((p) => p.name.toLowerCase().includes(q)).slice(0, maxSuggestions)
|
|
452
|
+
}, [mention, people, maxSuggestions])
|
|
453
|
+
|
|
454
|
+
const pick = React.useCallback(
|
|
455
|
+
(person: Person) => {
|
|
456
|
+
if (!mention) return
|
|
457
|
+
pickerRef.current?.(person, mention)
|
|
458
|
+
setMention(null)
|
|
459
|
+
},
|
|
460
|
+
[mention]
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
const canSubmit = value.trim().length > 0
|
|
464
|
+
const submit = React.useCallback(() => {
|
|
465
|
+
if (!canSubmit) return
|
|
466
|
+
onSubmit?.({ text: value.trim(), mentions: extractMentions(value, people) })
|
|
467
|
+
setValue("")
|
|
468
|
+
setMention(null)
|
|
469
|
+
}, [canSubmit, onSubmit, value, people, setValue])
|
|
470
|
+
const cancel = React.useCallback(() => {
|
|
471
|
+
setValue("")
|
|
472
|
+
setMention(null)
|
|
473
|
+
onCancel?.()
|
|
474
|
+
}, [setValue, onCancel])
|
|
475
|
+
|
|
476
|
+
const ctx = React.useMemo<ComposerContextValue>(
|
|
477
|
+
() => ({
|
|
478
|
+
people,
|
|
479
|
+
value,
|
|
480
|
+
setValue,
|
|
481
|
+
mention,
|
|
482
|
+
setMention,
|
|
483
|
+
suggestions,
|
|
484
|
+
pick,
|
|
485
|
+
registerPicker,
|
|
486
|
+
submit,
|
|
487
|
+
cancel,
|
|
488
|
+
canSubmit,
|
|
489
|
+
inputRef,
|
|
490
|
+
listId,
|
|
491
|
+
}),
|
|
492
|
+
[people, value, setValue, mention, suggestions, pick, registerPicker, submit, cancel, canSubmit, listId]
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<ComposerContext.Provider value={ctx}>
|
|
497
|
+
<div
|
|
498
|
+
data-slot="comment-composer"
|
|
499
|
+
data-mentioning={mention ? "" : undefined}
|
|
500
|
+
className={cn(
|
|
501
|
+
"relative flex flex-col gap-2 rounded-xl border bg-background p-2 transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
|
|
502
|
+
className
|
|
503
|
+
)}
|
|
504
|
+
{...props}
|
|
505
|
+
>
|
|
506
|
+
{children}
|
|
507
|
+
</div>
|
|
508
|
+
</ComposerContext.Provider>
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** "Replying to …" strip; renders nothing when not replying. The render prop gets the comment id. */
|
|
513
|
+
function CommentComposerReplyingTo({
|
|
514
|
+
className,
|
|
515
|
+
children,
|
|
516
|
+
...props
|
|
517
|
+
}: Omit<React.ComponentProps<"div">, "children"> & { children?: (details: { id: string }) => React.ReactNode }) {
|
|
518
|
+
const { replyTo, setReplyTo } = useCommentThread()
|
|
519
|
+
if (!replyTo) return null
|
|
520
|
+
return (
|
|
521
|
+
<div
|
|
522
|
+
data-slot="comment-composer-replying-to"
|
|
523
|
+
className={cn("flex items-center gap-2 px-1 text-xs text-muted-foreground", className)}
|
|
524
|
+
{...props}
|
|
525
|
+
>
|
|
526
|
+
<CornerDownRightIcon className="size-3.5" />
|
|
527
|
+
{children ? children({ id: replyTo }) : <span>Replying to a comment</span>}
|
|
528
|
+
<Button variant="ghost" size="xs" className="ms-auto h-5 px-1.5" onClick={() => setReplyTo(null)}>
|
|
529
|
+
Cancel
|
|
530
|
+
</Button>
|
|
531
|
+
</div>
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* ----- contenteditable helpers ----- */
|
|
536
|
+
|
|
537
|
+
const CHIP = "comment-mention-chip"
|
|
538
|
+
const escapeRe = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
539
|
+
|
|
540
|
+
/** Serialise the editable DOM to plain text; chips become `@Full Name`, blocks and <br> become newlines. */
|
|
541
|
+
function serialize(root: HTMLElement): string {
|
|
542
|
+
let out = ""
|
|
543
|
+
const walk = (node: Node) => {
|
|
544
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
545
|
+
out += (node.textContent ?? "").replace(/ /g, " ")
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (!(node instanceof HTMLElement)) return
|
|
549
|
+
if (node.dataset.slot === CHIP) {
|
|
550
|
+
out += `@${node.dataset.name ?? ""}`
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
if (node.tagName === "BR") {
|
|
554
|
+
out += "\n"
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
const block = node.tagName === "DIV" || node.tagName === "P"
|
|
558
|
+
if (block && out.length && !out.endsWith("\n")) out += "\n"
|
|
559
|
+
node.childNodes.forEach(walk)
|
|
560
|
+
}
|
|
561
|
+
root.childNodes.forEach(walk)
|
|
562
|
+
return out.replace(/\n$/, "")
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function createChip(person: Person) {
|
|
566
|
+
const chip = document.createElement("span")
|
|
567
|
+
chip.dataset.slot = CHIP
|
|
568
|
+
chip.dataset.person = person.id
|
|
569
|
+
chip.dataset.name = person.name
|
|
570
|
+
chip.contentEditable = "false"
|
|
571
|
+
chip.className =
|
|
572
|
+
"mx-px inline-flex items-center rounded-full bg-muted px-1.5 py-px align-baseline text-[0.9em] font-medium text-foreground select-none"
|
|
573
|
+
chip.textContent = `@${person.name}`
|
|
574
|
+
return chip
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Build editable DOM from plain text, turning known `@Full Name`s into chips. */
|
|
578
|
+
function hydrate(root: HTMLElement, text: string, people: Person[]) {
|
|
579
|
+
root.replaceChildren()
|
|
580
|
+
const names = [...people].sort((a, b) => b.name.length - a.name.length)
|
|
581
|
+
const pattern = names.length ? new RegExp(`@(${names.map((p) => escapeRe(p.name)).join("|")})(?![\\w])`, "g") : null
|
|
582
|
+
text.split("\n").forEach((line, lineIndex) => {
|
|
583
|
+
if (lineIndex > 0) root.appendChild(document.createElement("br"))
|
|
584
|
+
let last = 0
|
|
585
|
+
if (pattern) {
|
|
586
|
+
for (const match of line.matchAll(pattern)) {
|
|
587
|
+
const index = match.index ?? 0
|
|
588
|
+
if (index > last) root.appendChild(document.createTextNode(line.slice(last, index)))
|
|
589
|
+
const person = people.find((p) => p.name === match[1])
|
|
590
|
+
if (person) root.appendChild(createChip(person))
|
|
591
|
+
last = index + match[0].length
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (last < line.length) root.appendChild(document.createTextNode(line.slice(last)))
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The `@query` immediately before the caret inside a text node, if any. */
|
|
599
|
+
function mentionAtCaret(root: HTMLElement): { node: Text; start: number; end: number; query: string } | null {
|
|
600
|
+
const selection = window.getSelection()
|
|
601
|
+
if (!selection || !selection.rangeCount || !selection.isCollapsed) return null
|
|
602
|
+
const range = selection.getRangeAt(0)
|
|
603
|
+
const node = range.startContainer
|
|
604
|
+
if (node.nodeType !== Node.TEXT_NODE || !root.contains(node)) return null
|
|
605
|
+
const text = node.textContent ?? ""
|
|
606
|
+
const caret = range.startOffset
|
|
607
|
+
const before = text.slice(0, caret)
|
|
608
|
+
const at = before.lastIndexOf("@")
|
|
609
|
+
if (at === -1) return null
|
|
610
|
+
if (at > 0 && /\S/.test(before[at - 1])) return null
|
|
611
|
+
const query = before.slice(at + 1)
|
|
612
|
+
if (query.length > 30) return null
|
|
613
|
+
return { node: node as Text, start: at, end: caret, query }
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function placeCaretAfter(node: Node) {
|
|
617
|
+
const selection = window.getSelection()
|
|
618
|
+
if (!selection) return
|
|
619
|
+
const range = document.createRange()
|
|
620
|
+
range.setStartAfter(node)
|
|
621
|
+
range.collapse(true)
|
|
622
|
+
selection.removeAllRanges()
|
|
623
|
+
selection.addRange(range)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Contenteditable input: picked mentions become atomic grey chips (one
|
|
628
|
+
* Backspace removes a whole chip); `value` stays plain `@Full Name` text.
|
|
629
|
+
* Arrows/Enter/Tab pick from the list, Escape closes it, ⌘/Ctrl+Enter submits.
|
|
630
|
+
*/
|
|
631
|
+
function CommentComposerInput({
|
|
632
|
+
className,
|
|
633
|
+
placeholder = "Write a comment…",
|
|
634
|
+
autoFocus,
|
|
635
|
+
onKeyDown,
|
|
636
|
+
onBlur,
|
|
637
|
+
onInput,
|
|
638
|
+
onPaste,
|
|
639
|
+
...props
|
|
640
|
+
}: Omit<React.ComponentProps<"div">, "onChange"> & { placeholder?: string; autoFocus?: boolean }) {
|
|
641
|
+
const c = useComposer()
|
|
642
|
+
const lastSerialized = React.useRef<string | null>(null)
|
|
643
|
+
|
|
644
|
+
// Reflect outside changes to `value` (initial text, edits, reset after submit) without disturbing typing.
|
|
645
|
+
React.useLayoutEffect(() => {
|
|
646
|
+
const el = c.inputRef.current
|
|
647
|
+
if (!el || c.value === lastSerialized.current) return
|
|
648
|
+
hydrate(el, c.value, c.people)
|
|
649
|
+
lastSerialized.current = c.value
|
|
650
|
+
}, [c.value, c.people, c.inputRef])
|
|
651
|
+
|
|
652
|
+
React.useEffect(() => {
|
|
653
|
+
if (autoFocus) c.inputRef.current?.focus()
|
|
654
|
+
}, [autoFocus, c.inputRef])
|
|
655
|
+
|
|
656
|
+
const { registerPicker, setValue, inputRef } = c
|
|
657
|
+
React.useEffect(
|
|
658
|
+
() =>
|
|
659
|
+
registerPicker((person) => {
|
|
660
|
+
const el = inputRef.current
|
|
661
|
+
if (!el) return
|
|
662
|
+
const hit = mentionAtCaret(el)
|
|
663
|
+
const chip = createChip(person)
|
|
664
|
+
const space = document.createTextNode(" ")
|
|
665
|
+
if (hit) {
|
|
666
|
+
const range = document.createRange()
|
|
667
|
+
range.setStart(hit.node, hit.start)
|
|
668
|
+
range.setEnd(hit.node, hit.end)
|
|
669
|
+
range.deleteContents()
|
|
670
|
+
range.insertNode(space)
|
|
671
|
+
range.insertNode(chip)
|
|
672
|
+
} else {
|
|
673
|
+
el.append(chip, space)
|
|
674
|
+
}
|
|
675
|
+
placeCaretAfter(space)
|
|
676
|
+
el.focus()
|
|
677
|
+
const next = serialize(el)
|
|
678
|
+
lastSerialized.current = next
|
|
679
|
+
setValue(next)
|
|
680
|
+
}),
|
|
681
|
+
[registerPicker, inputRef, setValue]
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
const sync = () => {
|
|
685
|
+
const el = c.inputRef.current
|
|
686
|
+
if (!el) return
|
|
687
|
+
if (el.innerHTML === "<br>") el.replaceChildren()
|
|
688
|
+
const next = serialize(el)
|
|
689
|
+
lastSerialized.current = next
|
|
690
|
+
c.setValue(next)
|
|
691
|
+
const hit = mentionAtCaret(el)
|
|
692
|
+
c.setMention(hit ? { query: hit.query, start: hit.start, index: 0 } : null)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return (
|
|
696
|
+
<div
|
|
697
|
+
ref={c.inputRef}
|
|
698
|
+
data-slot="comment-composer-input"
|
|
699
|
+
role={c.mention ? "combobox" : "textbox"}
|
|
700
|
+
contentEditable
|
|
701
|
+
suppressContentEditableWarning
|
|
702
|
+
aria-multiline={c.mention ? undefined : true}
|
|
703
|
+
aria-expanded={c.mention ? true : undefined}
|
|
704
|
+
aria-controls={c.mention ? c.listId : undefined}
|
|
705
|
+
aria-autocomplete={c.mention ? "list" : undefined}
|
|
706
|
+
aria-activedescendant={
|
|
707
|
+
c.mention && c.suggestions[c.mention.index] ? `${c.listId}-${c.suggestions[c.mention.index].id}` : undefined
|
|
708
|
+
}
|
|
709
|
+
data-placeholder={placeholder}
|
|
710
|
+
className={cn(
|
|
711
|
+
"min-h-20 px-2 py-1.5 text-sm wrap-break-word whitespace-pre-wrap outline-none",
|
|
712
|
+
"empty:before:pointer-events-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]",
|
|
713
|
+
className
|
|
714
|
+
)}
|
|
715
|
+
onInput={(event) => {
|
|
716
|
+
onInput?.(event)
|
|
717
|
+
sync()
|
|
718
|
+
}}
|
|
719
|
+
onKeyUp={(event) => {
|
|
720
|
+
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) sync()
|
|
721
|
+
}}
|
|
722
|
+
onClick={() => sync()}
|
|
723
|
+
onPaste={(event) => {
|
|
724
|
+
onPaste?.(event)
|
|
725
|
+
if (event.defaultPrevented) return
|
|
726
|
+
event.preventDefault()
|
|
727
|
+
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"))
|
|
728
|
+
}}
|
|
729
|
+
onKeyDown={(event) => {
|
|
730
|
+
onKeyDown?.(event)
|
|
731
|
+
if (event.defaultPrevented) return
|
|
732
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
733
|
+
event.preventDefault()
|
|
734
|
+
c.submit()
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
if (!c.mention) {
|
|
738
|
+
if (event.key === "Escape") c.cancel()
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
const count = c.suggestions.length
|
|
742
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
743
|
+
event.preventDefault()
|
|
744
|
+
if (!count) return
|
|
745
|
+
const delta = event.key === "ArrowDown" ? 1 : -1
|
|
746
|
+
c.setMention({ ...c.mention, index: (c.mention.index + delta + count) % count })
|
|
747
|
+
} else if ((event.key === "Enter" || event.key === "Tab") && count) {
|
|
748
|
+
event.preventDefault()
|
|
749
|
+
c.pick(c.suggestions[c.mention.index])
|
|
750
|
+
} else if (event.key === "Escape") {
|
|
751
|
+
event.preventDefault()
|
|
752
|
+
c.setMention(null)
|
|
753
|
+
}
|
|
754
|
+
}}
|
|
755
|
+
onBlur={(event) => {
|
|
756
|
+
onBlur?.(event)
|
|
757
|
+
setTimeout(() => {
|
|
758
|
+
if (document.activeElement !== event.target) c.setMention(null)
|
|
759
|
+
}, 120)
|
|
760
|
+
}}
|
|
761
|
+
{...props}
|
|
762
|
+
/>
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Floating suggestion list anchored under the input while an `@query` is active. Focus stays in the textarea. */
|
|
767
|
+
function CommentComposerMentionList({ className, ...props }: React.ComponentProps<typeof PopoverContent>) {
|
|
768
|
+
const c = useComposer()
|
|
769
|
+
const open = !!c.mention
|
|
770
|
+
return (
|
|
771
|
+
<Popover
|
|
772
|
+
open={open}
|
|
773
|
+
onOpenChange={({ open }) => {
|
|
774
|
+
if (!open) c.setMention(null)
|
|
775
|
+
}}
|
|
776
|
+
autoFocus={false}
|
|
777
|
+
modal={false}
|
|
778
|
+
positioning={{
|
|
779
|
+
placement: "bottom-start",
|
|
780
|
+
sameWidth: true,
|
|
781
|
+
gutter: 4,
|
|
782
|
+
getAnchorRect: () => {
|
|
783
|
+
const rect = c.inputRef.current?.getBoundingClientRect()
|
|
784
|
+
return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null
|
|
785
|
+
},
|
|
786
|
+
}}
|
|
787
|
+
>
|
|
788
|
+
<PopoverContent
|
|
789
|
+
id={c.listId}
|
|
790
|
+
role="listbox"
|
|
791
|
+
aria-label="People"
|
|
792
|
+
data-slot="comment-composer-mention-list"
|
|
793
|
+
className={cn("max-h-56 w-auto overflow-y-auto p-1", className)}
|
|
794
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
795
|
+
{...props}
|
|
796
|
+
>
|
|
797
|
+
{c.suggestions.length === 0 ? (
|
|
798
|
+
<div className="px-2 py-1.5 text-sm text-muted-foreground">No one matches "{c.mention?.query}"</div>
|
|
799
|
+
) : (
|
|
800
|
+
c.suggestions.map((person, index) => (
|
|
801
|
+
<div
|
|
802
|
+
key={person.id}
|
|
803
|
+
id={`${c.listId}-${person.id}`}
|
|
804
|
+
role="option"
|
|
805
|
+
aria-selected={index === c.mention?.index}
|
|
806
|
+
data-slot="comment-composer-mention-item"
|
|
807
|
+
data-highlighted={index === c.mention?.index ? "" : undefined}
|
|
808
|
+
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm data-highlighted:bg-accent data-highlighted:text-accent-foreground"
|
|
809
|
+
onMouseEnter={() => c.mention && c.setMention({ ...c.mention, index })}
|
|
810
|
+
onClick={() => c.pick(person)}
|
|
811
|
+
>
|
|
812
|
+
<Avatar size="sm" className="size-6 text-[10px]">
|
|
813
|
+
{person.avatar && <AvatarImage src={person.avatar} alt="" />}
|
|
814
|
+
<AvatarFallback>{initialsOf(person)}</AvatarFallback>
|
|
815
|
+
</Avatar>
|
|
816
|
+
<span className="truncate">{person.name}</span>
|
|
817
|
+
</div>
|
|
818
|
+
))
|
|
819
|
+
)}
|
|
820
|
+
</PopoverContent>
|
|
821
|
+
</Popover>
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function CommentComposerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
826
|
+
return (
|
|
827
|
+
<div data-slot="comment-composer-footer" className={cn("flex items-center gap-2 px-1", className)} {...props} />
|
|
828
|
+
)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function CommentComposerHint({ className, children, ...props }: React.ComponentProps<"span">) {
|
|
832
|
+
return (
|
|
833
|
+
<span
|
|
834
|
+
data-slot="comment-composer-hint"
|
|
835
|
+
className={cn("me-auto text-xs text-muted-foreground", className)}
|
|
836
|
+
{...props}
|
|
837
|
+
>
|
|
838
|
+
{children ?? (
|
|
839
|
+
<>
|
|
840
|
+
<Kbd>@</Kbd> to mention · <Kbd>⌘↵</Kbd> to send
|
|
841
|
+
</>
|
|
842
|
+
)}
|
|
843
|
+
</span>
|
|
844
|
+
)
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function CommentComposerSubmitTrigger({ asChild, children, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
|
848
|
+
const { submit, canSubmit } = useComposer()
|
|
849
|
+
return (
|
|
850
|
+
<Button
|
|
851
|
+
data-slot="comment-composer-submit-trigger"
|
|
852
|
+
size="sm"
|
|
853
|
+
asChild={asChild}
|
|
854
|
+
disabled={!canSubmit}
|
|
855
|
+
onClick={(event) => {
|
|
856
|
+
onClick?.(event)
|
|
857
|
+
if (!event.defaultPrevented) submit()
|
|
858
|
+
}}
|
|
859
|
+
{...props}
|
|
860
|
+
>
|
|
861
|
+
{asChild
|
|
862
|
+
? children
|
|
863
|
+
: (children ?? (
|
|
864
|
+
<>
|
|
865
|
+
<SendIcon /> Comment
|
|
866
|
+
</>
|
|
867
|
+
))}
|
|
868
|
+
</Button>
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function CommentComposerCancelTrigger({ asChild, children, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
|
873
|
+
const { cancel } = useComposer()
|
|
874
|
+
return (
|
|
875
|
+
<Button
|
|
876
|
+
data-slot="comment-composer-cancel-trigger"
|
|
877
|
+
variant="ghost"
|
|
878
|
+
size="sm"
|
|
879
|
+
asChild={asChild}
|
|
880
|
+
onClick={(event) => {
|
|
881
|
+
onClick?.(event)
|
|
882
|
+
if (!event.defaultPrevented) cancel()
|
|
883
|
+
}}
|
|
884
|
+
{...props}
|
|
885
|
+
>
|
|
886
|
+
{asChild ? children : (children ?? "Cancel")}
|
|
887
|
+
</Button>
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export {
|
|
892
|
+
CommentThread,
|
|
893
|
+
CommentList,
|
|
894
|
+
Comment,
|
|
895
|
+
CommentAvatar,
|
|
896
|
+
CommentContent,
|
|
897
|
+
CommentHeader,
|
|
898
|
+
CommentAuthor,
|
|
899
|
+
CommentTime,
|
|
900
|
+
CommentMeta,
|
|
901
|
+
CommentBody,
|
|
902
|
+
CommentMention,
|
|
903
|
+
CommentReactions,
|
|
904
|
+
CommentReaction,
|
|
905
|
+
CommentActions,
|
|
906
|
+
CommentReplyTrigger,
|
|
907
|
+
CommentEditTrigger,
|
|
908
|
+
CommentReplies,
|
|
909
|
+
CommentEmpty,
|
|
910
|
+
CommentComposer,
|
|
911
|
+
CommentComposerReplyingTo,
|
|
912
|
+
CommentComposerInput,
|
|
913
|
+
CommentComposerMentionList,
|
|
914
|
+
CommentComposerFooter,
|
|
915
|
+
CommentComposerHint,
|
|
916
|
+
CommentComposerSubmitTrigger,
|
|
917
|
+
CommentComposerCancelTrigger,
|
|
918
|
+
useCommentThread,
|
|
919
|
+
useComment,
|
|
920
|
+
extractMentions,
|
|
921
|
+
renderMentions,
|
|
922
|
+
type Person as CommentPerson,
|
|
923
|
+
}
|