@lark-apaas/coding-template-nestjs-react-fullstack 0.1.59 → 0.1.60-alpha.20261105123111
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/package.json +1 -1
- package/template/client/src/components/business-ui/README.md +2 -0
- package/template/client/src/components/business-ui/comments/README.md +36 -0
- package/template/client/src/components/business-ui/comments/comments.tsx +626 -0
- package/template/client/src/components/business-ui/comments/contracts.ts +165 -0
- package/template/client/src/components/business-ui/comments/index.ts +19 -0
- package/template/client/src/components/business-ui/record-comments/README.md +141 -0
- package/template/client/src/components/business-ui/record-comments/app-source.ts +93 -0
- package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
- package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +240 -0
- package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +231 -0
- package/template/client/src/components/business-ui/record-comments/extensions.ts +130 -0
- package/template/client/src/components/business-ui/record-comments/index.ts +9 -0
- package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +529 -0
- package/template/client/src/components/business-ui/record-comments/internal/content.tsx +213 -0
- package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +522 -0
- package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +681 -0
- package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +148 -0
- package/template/client/src/components/business-ui/record-comments/internal/images.tsx +155 -0
- package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +163 -0
- package/template/client/src/components/business-ui/record-comments/internal/model.ts +581 -0
- package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +184 -0
- package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +196 -0
- package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +681 -0
- package/template/client/src/components/business-ui/record-comments/internal/styles.css +1109 -0
- package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +456 -0
- package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +89 -0
- package/template/client/src/components/business-ui/record-comments/types.ts +121 -0
- package/template/package-lock.json +1449 -216
- package/template/package.json +16 -5
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import spriteUrl from '../assets/reaction-sprite.png';
|
|
2
|
+
import { DEFAULT_USED_REACTIONS, REACTION_CATALOG, REACTION_SPRITE_SIZE } from './reaction-catalog';
|
|
3
|
+
import { safeUrl, COMMENT_THEME_CLASS } from './model';
|
|
4
|
+
import { useMentionContext, useMentionEntity } from './mentions';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
import { Image } from '@/components/ui/image';
|
|
7
|
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
8
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
10
|
+
import { LoaderCircle } from 'lucide-react';
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
13
|
+
|
|
14
|
+
/** Independent of reply.content; the page owns persistence and toggle direction. */
|
|
15
|
+
export interface CommentReaction {
|
|
16
|
+
key: string;
|
|
17
|
+
count: number;
|
|
18
|
+
mine: boolean;
|
|
19
|
+
/** Miaoda user IDs. This can be the server's ahead subset, not the full count. */
|
|
20
|
+
users: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CommentEmoji {
|
|
24
|
+
key: string;
|
|
25
|
+
label: string;
|
|
26
|
+
group?: string;
|
|
27
|
+
/** Authenticated/custom assets are supplied by the host, not fetched via a Lark gateway here. */
|
|
28
|
+
url?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const catalog = new Map(REACTION_CATALOG.map((entry) => [entry[0], entry]));
|
|
32
|
+
// Keep the server key for explicit add/delete, alias only its visual asset.
|
|
33
|
+
catalog.set('MediumThumbsup', catalog.get('THUMBSUP')!);
|
|
34
|
+
export const DEFAULT_REACTION_EMOJIS: readonly CommentEmoji[] =
|
|
35
|
+
REACTION_CATALOG.map(([key, label]) => ({ key, label }));
|
|
36
|
+
|
|
37
|
+
export function CommentReactionEmoji({
|
|
38
|
+
code,
|
|
39
|
+
size = 24,
|
|
40
|
+
emoji,
|
|
41
|
+
}: {
|
|
42
|
+
code: string;
|
|
43
|
+
size?: number;
|
|
44
|
+
emoji?: CommentEmoji;
|
|
45
|
+
}) {
|
|
46
|
+
const entry = catalog.get(code);
|
|
47
|
+
const label = emoji?.label || entry?.[1] || code;
|
|
48
|
+
if (emoji?.url && safeUrl(emoji.url))
|
|
49
|
+
return (
|
|
50
|
+
<Image
|
|
51
|
+
src={safeUrl(emoji.url)}
|
|
52
|
+
alt={label}
|
|
53
|
+
draggable={false}
|
|
54
|
+
className="replica-comment-reaction-emoji"
|
|
55
|
+
style={{ height: size, width: 'auto' }}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
if (!entry)
|
|
59
|
+
return (
|
|
60
|
+
<span className="text-xs" role="img" aria-label={label}>
|
|
61
|
+
[{label}]
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
const [, , x, y, width, height] = entry;
|
|
65
|
+
const ratio = size / height;
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
role="img"
|
|
69
|
+
aria-label={label}
|
|
70
|
+
className="replica-comment-reaction-emoji"
|
|
71
|
+
style={{
|
|
72
|
+
width: width * ratio,
|
|
73
|
+
height: size,
|
|
74
|
+
backgroundImage: `url(${spriteUrl})`,
|
|
75
|
+
backgroundSize: `${REACTION_SPRITE_SIZE[0] * ratio}px ${REACTION_SPRITE_SIZE[1] * ratio}px`,
|
|
76
|
+
backgroundPosition: `${-x * ratio}px ${-y * ratio}px`,
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Same 14px default/thumbs-up icon as miaoda/replica-comment-reaction-icon.tsx. */
|
|
83
|
+
function ReactionIcon() {
|
|
84
|
+
return (
|
|
85
|
+
<svg
|
|
86
|
+
width="14"
|
|
87
|
+
height="14"
|
|
88
|
+
viewBox="0 0 14 14"
|
|
89
|
+
fill="none"
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
>
|
|
92
|
+
<path
|
|
93
|
+
d="M1.75 12.833h-.583a.29.29 0 01-.292-.291V4.958a.292.292 0 01.292-.291h.583a.292.292 0 01.292.291v7.584a.292.292 0 01-.292.291zm7-8.167h2.501c1.74 0 2.14 1.536 1.74 2.688l-1.74 4.582a1.237 1.237 0 01-1.196.902H3.204a.293.293 0 01-.292-.291v-7.59a.292.292 0 01.292-.291h.6a.583.583 0 00.477-.248L6.702.973c.164-.26.607-.46 1.078-.246.708.322 1.554 1.025 1.554 2.19 0 .44-.195 1.023-.585 1.749z"
|
|
94
|
+
fill="currentColor"
|
|
95
|
+
/>
|
|
96
|
+
</svg>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Source hover timings: 200ms open/close, 400ms suppression after selection. */
|
|
101
|
+
function useReactionHover() {
|
|
102
|
+
const [open, setOpen] = useState(false);
|
|
103
|
+
const opening = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
104
|
+
const closing = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
105
|
+
const until = useRef(0);
|
|
106
|
+
const cancel = () => {
|
|
107
|
+
clearTimeout(opening.current);
|
|
108
|
+
clearTimeout(closing.current);
|
|
109
|
+
opening.current = undefined;
|
|
110
|
+
closing.current = undefined;
|
|
111
|
+
};
|
|
112
|
+
useEffect(() => cancel, []);
|
|
113
|
+
return {
|
|
114
|
+
open,
|
|
115
|
+
change(next: boolean) {
|
|
116
|
+
cancel();
|
|
117
|
+
if (!next || Date.now() >= until.current) setOpen(next);
|
|
118
|
+
},
|
|
119
|
+
enter() {
|
|
120
|
+
clearTimeout(closing.current);
|
|
121
|
+
if (Date.now() < until.current || open || opening.current) return;
|
|
122
|
+
opening.current = setTimeout(() => {
|
|
123
|
+
opening.current = undefined;
|
|
124
|
+
setOpen(true);
|
|
125
|
+
}, 200);
|
|
126
|
+
},
|
|
127
|
+
leave() {
|
|
128
|
+
cancel();
|
|
129
|
+
closing.current = setTimeout(() => setOpen(false), 200);
|
|
130
|
+
},
|
|
131
|
+
close() {
|
|
132
|
+
cancel();
|
|
133
|
+
until.current = Date.now() + 400;
|
|
134
|
+
setOpen(false);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ReactionPanel({
|
|
140
|
+
emojis,
|
|
141
|
+
scopeKey,
|
|
142
|
+
onSelect,
|
|
143
|
+
}: {
|
|
144
|
+
emojis: readonly CommentEmoji[];
|
|
145
|
+
scopeKey: string;
|
|
146
|
+
onSelect: (key: string) => void;
|
|
147
|
+
}) {
|
|
148
|
+
const storageKey = `miaoda-comment-replica-reactions:${scopeKey}`;
|
|
149
|
+
const [recent, setRecent] = useState<string[]>(() => {
|
|
150
|
+
try {
|
|
151
|
+
const saved: unknown = JSON.parse(
|
|
152
|
+
localStorage.getItem(storageKey) || 'null',
|
|
153
|
+
);
|
|
154
|
+
if (Array.isArray(saved) && saved.every((key) => typeof key === 'string'))
|
|
155
|
+
return saved;
|
|
156
|
+
} catch {
|
|
157
|
+
/* Storage is optional; preserve the source default order when unavailable. */
|
|
158
|
+
}
|
|
159
|
+
return DEFAULT_USED_REACTIONS;
|
|
160
|
+
});
|
|
161
|
+
const byKey = new Map(emojis.map((emoji) => [emoji.key, emoji]));
|
|
162
|
+
const groups = new Map<string, CommentEmoji[]>();
|
|
163
|
+
for (const emoji of emojis) {
|
|
164
|
+
const title = emoji.group || '默认表情';
|
|
165
|
+
const group = groups.get(title) || [];
|
|
166
|
+
group.push(emoji);
|
|
167
|
+
groups.set(title, group);
|
|
168
|
+
}
|
|
169
|
+
const select = (key: string) => {
|
|
170
|
+
const next = [key, ...recent.filter((item) => item !== key)].slice(0, 30);
|
|
171
|
+
setRecent(next);
|
|
172
|
+
try {
|
|
173
|
+
localStorage.setItem(storageKey, JSON.stringify(next));
|
|
174
|
+
} catch {
|
|
175
|
+
/* Selection works without storage. */
|
|
176
|
+
}
|
|
177
|
+
onSelect(key);
|
|
178
|
+
};
|
|
179
|
+
const keydown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
180
|
+
if (
|
|
181
|
+
![
|
|
182
|
+
'ArrowLeft',
|
|
183
|
+
'ArrowRight',
|
|
184
|
+
'ArrowDown',
|
|
185
|
+
'ArrowUp',
|
|
186
|
+
'Home',
|
|
187
|
+
'End',
|
|
188
|
+
].includes(event.key)
|
|
189
|
+
)
|
|
190
|
+
return;
|
|
191
|
+
const group = (event.target as HTMLElement).closest('[data-reaction-grid]');
|
|
192
|
+
const buttons = Array.from(
|
|
193
|
+
group?.querySelectorAll<HTMLButtonElement>('button') || [],
|
|
194
|
+
);
|
|
195
|
+
if (!buttons.length) return;
|
|
196
|
+
const index = buttons.indexOf(event.target as HTMLButtonElement);
|
|
197
|
+
const delta =
|
|
198
|
+
{ ArrowLeft: -1, ArrowRight: 1, ArrowDown: 9, ArrowUp: -9 }[event.key] ||
|
|
199
|
+
0;
|
|
200
|
+
const next =
|
|
201
|
+
event.key === 'Home'
|
|
202
|
+
? 0
|
|
203
|
+
: event.key === 'End'
|
|
204
|
+
? buttons.length - 1
|
|
205
|
+
: (index + delta + buttons.length) % buttons.length;
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
buttons[next]?.focus();
|
|
208
|
+
};
|
|
209
|
+
const item = (emoji: CommentEmoji, size = 24) => {
|
|
210
|
+
const entry = catalog.get(emoji.key);
|
|
211
|
+
const width = entry
|
|
212
|
+
? size === 28
|
|
213
|
+
? (entry[4] / entry[5]) * size
|
|
214
|
+
: Math.max(36, (entry[4] / entry[5]) * size + 4)
|
|
215
|
+
: size;
|
|
216
|
+
return (
|
|
217
|
+
<button
|
|
218
|
+
key={emoji.key}
|
|
219
|
+
type="button"
|
|
220
|
+
aria-label={emoji.label}
|
|
221
|
+
title={emoji.label}
|
|
222
|
+
className="replica-comment-reaction-choice"
|
|
223
|
+
style={{ minWidth: width }}
|
|
224
|
+
onClick={() => select(emoji.key)}
|
|
225
|
+
>
|
|
226
|
+
<CommentReactionEmoji code={emoji.key} size={size} emoji={emoji} />
|
|
227
|
+
</button>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
className="replica-comment-reaction-panel"
|
|
233
|
+
onKeyDown={keydown}
|
|
234
|
+
onClick={(event) => event.stopPropagation()}
|
|
235
|
+
>
|
|
236
|
+
<div className="replica-comment-reaction-recent">
|
|
237
|
+
<div className="replica-comment-reaction-used-title">最近使用</div>
|
|
238
|
+
<div className="replica-comment-reaction-used-grid" data-reaction-grid>
|
|
239
|
+
{recent
|
|
240
|
+
.filter((key) => byKey.has(key))
|
|
241
|
+
.slice(0, 7)
|
|
242
|
+
.flatMap((key) => {
|
|
243
|
+
const emoji = byKey.get(key);
|
|
244
|
+
return emoji ? [item(emoji, 28)] : [];
|
|
245
|
+
})}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
<div className="replica-comment-reaction-scroll">
|
|
249
|
+
{[...groups].map(([title, group]) => (
|
|
250
|
+
<React.Fragment key={title}>
|
|
251
|
+
<div className="replica-comment-reaction-all-title">{title}</div>
|
|
252
|
+
<div className="replica-comment-reaction-grid" data-reaction-grid>
|
|
253
|
+
{group.map((emoji) => item(emoji))}
|
|
254
|
+
</div>
|
|
255
|
+
</React.Fragment>
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function CommentReactionEntry({
|
|
263
|
+
onToggle,
|
|
264
|
+
onOpenChange,
|
|
265
|
+
disabled,
|
|
266
|
+
emojis = DEFAULT_REACTION_EMOJIS,
|
|
267
|
+
}: {
|
|
268
|
+
onToggle: (key: string) => void;
|
|
269
|
+
onOpenChange?: (open: boolean) => void;
|
|
270
|
+
disabled?: boolean;
|
|
271
|
+
emojis?: readonly CommentEmoji[];
|
|
272
|
+
}) {
|
|
273
|
+
const { store } = useMentionContext();
|
|
274
|
+
const hover = useReactionHover();
|
|
275
|
+
const keyboardOpen = useRef(false);
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
onOpenChange?.(hover.open);
|
|
278
|
+
}, [hover.open, onOpenChange]);
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (disabled) hover.change(false);
|
|
281
|
+
}, [disabled]);
|
|
282
|
+
return (
|
|
283
|
+
<Popover
|
|
284
|
+
open={!disabled && hover.open}
|
|
285
|
+
onOpenChange={hover.change}
|
|
286
|
+
modal={false}
|
|
287
|
+
>
|
|
288
|
+
<PopoverTrigger asChild>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
aria-label="表情回复"
|
|
292
|
+
title="表情回复(点击点赞,悬停选择)"
|
|
293
|
+
disabled={disabled}
|
|
294
|
+
className={cn(
|
|
295
|
+
COMMENT_THEME_CLASS,
|
|
296
|
+
'inline-flex size-[14px] items-center justify-center text-[var(--subtle-foreground)] hover:text-foreground disabled:text-muted-foreground',
|
|
297
|
+
)}
|
|
298
|
+
onMouseEnter={() => {
|
|
299
|
+
keyboardOpen.current = false;
|
|
300
|
+
hover.enter();
|
|
301
|
+
}}
|
|
302
|
+
onMouseLeave={hover.leave}
|
|
303
|
+
onKeyDown={(event) => {
|
|
304
|
+
if (event.key === 'ArrowDown') {
|
|
305
|
+
event.preventDefault();
|
|
306
|
+
keyboardOpen.current = true;
|
|
307
|
+
hover.change(true);
|
|
308
|
+
}
|
|
309
|
+
}}
|
|
310
|
+
onClick={(event) => {
|
|
311
|
+
event.preventDefault();
|
|
312
|
+
event.stopPropagation();
|
|
313
|
+
hover.close();
|
|
314
|
+
onToggle('THUMBSUP');
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
<ReactionIcon />
|
|
318
|
+
</button>
|
|
319
|
+
</PopoverTrigger>
|
|
320
|
+
{hover.open && !disabled ? (
|
|
321
|
+
<PopoverContent
|
|
322
|
+
align="end"
|
|
323
|
+
side="bottom"
|
|
324
|
+
sideOffset={6}
|
|
325
|
+
data-replica-comment-overlay
|
|
326
|
+
data-reaction-picker
|
|
327
|
+
className={cn(
|
|
328
|
+
COMMENT_THEME_CLASS,
|
|
329
|
+
'w-auto max-w-[calc(100vw-24px)] overflow-hidden rounded-lg border-[0.5px] p-0 data-[state=closed]:animate-none!',
|
|
330
|
+
)}
|
|
331
|
+
onOpenAutoFocus={(event) => {
|
|
332
|
+
event.preventDefault();
|
|
333
|
+
if (keyboardOpen.current && event.target instanceof HTMLElement)
|
|
334
|
+
event.target
|
|
335
|
+
.querySelector<HTMLButtonElement>('.replica-comment-reaction-choice')
|
|
336
|
+
?.focus();
|
|
337
|
+
}}
|
|
338
|
+
onMouseEnter={hover.enter}
|
|
339
|
+
onMouseLeave={hover.leave}
|
|
340
|
+
>
|
|
341
|
+
<ReactionPanel
|
|
342
|
+
key={store.source.scopeKey}
|
|
343
|
+
scopeKey={store.source.scopeKey}
|
|
344
|
+
emojis={emojis}
|
|
345
|
+
onSelect={(key) => {
|
|
346
|
+
hover.close();
|
|
347
|
+
onToggle(key);
|
|
348
|
+
}}
|
|
349
|
+
/>
|
|
350
|
+
</PopoverContent>
|
|
351
|
+
) : null}
|
|
352
|
+
</Popover>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
type UserClick = (userID: string, anchor: HTMLElement) => void;
|
|
357
|
+
function ReactionPerson({
|
|
358
|
+
userID,
|
|
359
|
+
onUserClick,
|
|
360
|
+
avatar = false,
|
|
361
|
+
comma = false,
|
|
362
|
+
}: {
|
|
363
|
+
userID: string;
|
|
364
|
+
onUserClick?: UserClick;
|
|
365
|
+
avatar?: boolean;
|
|
366
|
+
comma?: boolean;
|
|
367
|
+
}) {
|
|
368
|
+
const { resolveUserRef } = useMentionContext();
|
|
369
|
+
const entity = useMentionEntity(resolveUserRef?.(userID) || {
|
|
370
|
+
kind: 'user', idType: 'apaas-user', id: userID,
|
|
371
|
+
});
|
|
372
|
+
const name: string =
|
|
373
|
+
entity?.status === 'resolved'
|
|
374
|
+
? entity.label
|
|
375
|
+
: entity?.status === 'not-found'
|
|
376
|
+
? '用户不存在'
|
|
377
|
+
: '用户';
|
|
378
|
+
const avatarUrl =
|
|
379
|
+
entity?.status === 'resolved' ? safeUrl(entity.avatarUrl) : undefined;
|
|
380
|
+
return (
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
className={avatar ? 'replica-comment-reaction-person' : 'replica-comment-reaction-user'}
|
|
384
|
+
onClick={(event) => {
|
|
385
|
+
event.stopPropagation();
|
|
386
|
+
onUserClick?.(userID, event.currentTarget);
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
{avatar ? (
|
|
390
|
+
<Avatar className="mr-2.5 size-8 shrink-0">
|
|
391
|
+
<AvatarImage src={avatarUrl} alt="" />
|
|
392
|
+
<AvatarFallback>{Array.from(name)[0]}</AvatarFallback>
|
|
393
|
+
</Avatar>
|
|
394
|
+
) : null}
|
|
395
|
+
<span className="truncate" title={name}>
|
|
396
|
+
{name}
|
|
397
|
+
{comma ? ',' : ''}
|
|
398
|
+
</span>
|
|
399
|
+
</button>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function ReactionBadge({
|
|
404
|
+
reaction,
|
|
405
|
+
emoji,
|
|
406
|
+
onToggle,
|
|
407
|
+
onMore,
|
|
408
|
+
onUserClick,
|
|
409
|
+
}: {
|
|
410
|
+
reaction: CommentReaction;
|
|
411
|
+
emoji?: CommentEmoji;
|
|
412
|
+
onToggle?: (key: string) => void;
|
|
413
|
+
onMore: () => void;
|
|
414
|
+
onUserClick?: UserClick;
|
|
415
|
+
}) {
|
|
416
|
+
const badge = useRef<HTMLDivElement>(null);
|
|
417
|
+
const [visible, setVisible] = useState(reaction.users.length);
|
|
418
|
+
useLayoutEffect(() => {
|
|
419
|
+
const element = badge.current;
|
|
420
|
+
if (!element?.parentElement) return;
|
|
421
|
+
const measure = () => {
|
|
422
|
+
const names = Array.from(
|
|
423
|
+
element.querySelectorAll<HTMLElement>('[data-measure-user]'),
|
|
424
|
+
);
|
|
425
|
+
let available = Math.max(0, element.parentElement!.clientWidth - 41);
|
|
426
|
+
let count = 0;
|
|
427
|
+
const max = names.length > 6 ? 5 : names.length;
|
|
428
|
+
const moreWidth = reaction.count > max ? 50 : 0;
|
|
429
|
+
for (const name of names.slice(0, max)) {
|
|
430
|
+
const width = name.getBoundingClientRect().width + 5;
|
|
431
|
+
if (count && width + moreWidth > available) break;
|
|
432
|
+
available -= width;
|
|
433
|
+
count++;
|
|
434
|
+
}
|
|
435
|
+
setVisible(count);
|
|
436
|
+
};
|
|
437
|
+
const observer = new ResizeObserver(measure);
|
|
438
|
+
observer.observe(element.parentElement);
|
|
439
|
+
observer.observe(element);
|
|
440
|
+
const labels = element.querySelector('.replica-comment-reaction-measure');
|
|
441
|
+
if (labels) observer.observe(labels);
|
|
442
|
+
measure();
|
|
443
|
+
return () => observer.disconnect();
|
|
444
|
+
}, [reaction.users, reaction.count]);
|
|
445
|
+
const count = Math.min(visible, reaction.users.length);
|
|
446
|
+
const remaining = Math.max(0, reaction.count - count);
|
|
447
|
+
return (
|
|
448
|
+
<div
|
|
449
|
+
ref={badge}
|
|
450
|
+
className="replica-comment-reaction-badge"
|
|
451
|
+
data-mine={reaction.mine || undefined}
|
|
452
|
+
onClick={(event) => event.stopPropagation()}
|
|
453
|
+
>
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
className="shrink-0"
|
|
457
|
+
aria-label={`${reaction.mine ? '撤销' : '添加'}${emoji?.label || catalog.get(reaction.key)?.[1] || reaction.key}`}
|
|
458
|
+
disabled={!onToggle}
|
|
459
|
+
onClick={() => onToggle?.(reaction.key)}
|
|
460
|
+
>
|
|
461
|
+
<CommentReactionEmoji code={reaction.key} size={18} emoji={emoji} />
|
|
462
|
+
</button>
|
|
463
|
+
<span className="replica-comment-reaction-separator" />
|
|
464
|
+
<div className="replica-comment-reaction-users">
|
|
465
|
+
{reaction.users.slice(0, count).map((userID, index) => (
|
|
466
|
+
<ReactionPerson
|
|
467
|
+
key={userID}
|
|
468
|
+
userID={userID}
|
|
469
|
+
comma={index < reaction.count - 1}
|
|
470
|
+
onUserClick={onUserClick}
|
|
471
|
+
/>
|
|
472
|
+
))}
|
|
473
|
+
</div>
|
|
474
|
+
{remaining ? (
|
|
475
|
+
<button
|
|
476
|
+
type="button"
|
|
477
|
+
className="replica-comment-reaction-more"
|
|
478
|
+
onClick={onMore}
|
|
479
|
+
>
|
|
480
|
+
+{remaining} 人
|
|
481
|
+
</button>
|
|
482
|
+
) : null}
|
|
483
|
+
<div className="replica-comment-reaction-measure" aria-hidden="true">
|
|
484
|
+
{reaction.users.map((userID) => (
|
|
485
|
+
<span key={userID} data-measure-user>
|
|
486
|
+
<ReactionPerson userID={userID} comma />
|
|
487
|
+
</span>
|
|
488
|
+
))}
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function CommentReactionList({
|
|
495
|
+
replyId,
|
|
496
|
+
reactions,
|
|
497
|
+
onToggle,
|
|
498
|
+
onUserClick,
|
|
499
|
+
loadDetails,
|
|
500
|
+
emojis = DEFAULT_REACTION_EMOJIS,
|
|
501
|
+
}: {
|
|
502
|
+
replyId: string;
|
|
503
|
+
reactions: readonly CommentReaction[];
|
|
504
|
+
onToggle?: (key: string) => void;
|
|
505
|
+
onUserClick?: UserClick;
|
|
506
|
+
/** Fetch full data when the list contains only an ahead subset. */
|
|
507
|
+
loadDetails?: (signal: AbortSignal) => Promise<readonly CommentReaction[]>;
|
|
508
|
+
emojis?: readonly CommentEmoji[];
|
|
509
|
+
}) {
|
|
510
|
+
const { store } = useMentionContext();
|
|
511
|
+
const sessionKey = JSON.stringify([store.source.scopeKey, replyId]);
|
|
512
|
+
const [target, setTarget] = useState<{
|
|
513
|
+
sessionKey: string;
|
|
514
|
+
key: string;
|
|
515
|
+
} | null>(null);
|
|
516
|
+
const activeKey = target?.sessionKey === sessionKey ? target.key : null;
|
|
517
|
+
const setActiveKey = (key: string | null) =>
|
|
518
|
+
setTarget(key === null ? null : { sessionKey, key });
|
|
519
|
+
const [full, setFull] = useState<{
|
|
520
|
+
sessionKey: string;
|
|
521
|
+
data: readonly CommentReaction[];
|
|
522
|
+
} | null>(null);
|
|
523
|
+
const [error, setError] = useState('');
|
|
524
|
+
const [retry, setRetry] = useState(0);
|
|
525
|
+
const latest = useRef({ reactions, loadDetails });
|
|
526
|
+
latest.current = { reactions, loadDetails };
|
|
527
|
+
const open =
|
|
528
|
+
activeKey !== null && reactions.some((reaction) => reaction.count > 0);
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
setFull(null);
|
|
531
|
+
setError('');
|
|
532
|
+
if (!open) return;
|
|
533
|
+
const controller = new AbortController();
|
|
534
|
+
const load = latest.current.loadDetails;
|
|
535
|
+
if (!load) {
|
|
536
|
+
setFull({ sessionKey, data: latest.current.reactions });
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
void load(controller.signal)
|
|
540
|
+
.then((data) => {
|
|
541
|
+
if (!controller.signal.aborted) setFull({ sessionKey, data });
|
|
542
|
+
})
|
|
543
|
+
.catch((cause: unknown) => {
|
|
544
|
+
if (!controller.signal.aborted)
|
|
545
|
+
setError(cause instanceof Error ? cause.message : '加载失败,请重试');
|
|
546
|
+
});
|
|
547
|
+
return () => controller.abort();
|
|
548
|
+
}, [open, sessionKey, retry]);
|
|
549
|
+
const data = loadDetails
|
|
550
|
+
? full?.sessionKey === sessionKey
|
|
551
|
+
? full.data
|
|
552
|
+
: null
|
|
553
|
+
: reactions;
|
|
554
|
+
const selected =
|
|
555
|
+
data?.find((reaction) => reaction.key === activeKey) || data?.[0];
|
|
556
|
+
if (!reactions.length) return null;
|
|
557
|
+
return (
|
|
558
|
+
<>
|
|
559
|
+
<div
|
|
560
|
+
className={cn(COMMENT_THEME_CLASS, 'mt-1 flex flex-wrap gap-1')}
|
|
561
|
+
data-replica-comment-reactions
|
|
562
|
+
>
|
|
563
|
+
{reactions
|
|
564
|
+
.filter((reaction) => reaction.count > 0)
|
|
565
|
+
.map((reaction) => (
|
|
566
|
+
<ReactionBadge
|
|
567
|
+
key={reaction.key}
|
|
568
|
+
reaction={reaction}
|
|
569
|
+
emoji={emojis.find((emoji) => emoji.key === reaction.key)}
|
|
570
|
+
onToggle={onToggle}
|
|
571
|
+
onMore={() => setActiveKey(reaction.key)}
|
|
572
|
+
onUserClick={onUserClick}
|
|
573
|
+
/>
|
|
574
|
+
))}
|
|
575
|
+
</div>
|
|
576
|
+
<Dialog
|
|
577
|
+
open={open}
|
|
578
|
+
onOpenChange={(next) => {
|
|
579
|
+
if (!next) setActiveKey(null);
|
|
580
|
+
}}
|
|
581
|
+
>
|
|
582
|
+
<DialogContent
|
|
583
|
+
data-replica-comment-overlay
|
|
584
|
+
className={cn(
|
|
585
|
+
COMMENT_THEME_CLASS,
|
|
586
|
+
'w-auto max-w-[calc(100vw-24px)] gap-0 p-0 sm:max-w-none',
|
|
587
|
+
)}
|
|
588
|
+
onClick={(event) => event.stopPropagation()}
|
|
589
|
+
onInteractOutside={(event) => {
|
|
590
|
+
if (
|
|
591
|
+
event.target instanceof Element &&
|
|
592
|
+
event.target.closest('[data-replica-comment-overlay]')
|
|
593
|
+
)
|
|
594
|
+
event.preventDefault();
|
|
595
|
+
}}
|
|
596
|
+
aria-describedby={undefined}
|
|
597
|
+
>
|
|
598
|
+
<DialogHeader className="px-6 pb-2 pt-6 text-left">
|
|
599
|
+
<DialogTitle className="text-base font-medium leading-6">
|
|
600
|
+
回应详情
|
|
601
|
+
</DialogTitle>
|
|
602
|
+
</DialogHeader>
|
|
603
|
+
<div className="px-6 pb-6">
|
|
604
|
+
<div className="replica-comment-reaction-detail">
|
|
605
|
+
{error ? (
|
|
606
|
+
<div
|
|
607
|
+
role="alert"
|
|
608
|
+
className="flex h-full items-center justify-center gap-2 text-sm"
|
|
609
|
+
>
|
|
610
|
+
<span>{error}</span>
|
|
611
|
+
<button
|
|
612
|
+
type="button"
|
|
613
|
+
className="text-primary"
|
|
614
|
+
onClick={() => setRetry((value) => value + 1)}
|
|
615
|
+
>
|
|
616
|
+
重试
|
|
617
|
+
</button>
|
|
618
|
+
</div>
|
|
619
|
+
) : !data ? (
|
|
620
|
+
<div className="flex h-full items-center justify-center">
|
|
621
|
+
<LoaderCircle
|
|
622
|
+
className="size-5 animate-spin text-primary"
|
|
623
|
+
aria-label="正在加载回应人员"
|
|
624
|
+
/>
|
|
625
|
+
</div>
|
|
626
|
+
) : (
|
|
627
|
+
<>
|
|
628
|
+
<div
|
|
629
|
+
className="replica-comment-reaction-tabs"
|
|
630
|
+
role="tablist"
|
|
631
|
+
aria-label="回应表情"
|
|
632
|
+
>
|
|
633
|
+
{data.map((reaction) => (
|
|
634
|
+
<button
|
|
635
|
+
type="button"
|
|
636
|
+
role="tab"
|
|
637
|
+
key={reaction.key}
|
|
638
|
+
aria-selected={selected?.key === reaction.key}
|
|
639
|
+
onClick={() => setActiveKey(reaction.key)}
|
|
640
|
+
>
|
|
641
|
+
<CommentReactionEmoji
|
|
642
|
+
code={reaction.key}
|
|
643
|
+
size={20}
|
|
644
|
+
emoji={emojis.find(
|
|
645
|
+
(emoji) => emoji.key === reaction.key,
|
|
646
|
+
)}
|
|
647
|
+
/>
|
|
648
|
+
<span>{reaction.count}</span>
|
|
649
|
+
</button>
|
|
650
|
+
))}
|
|
651
|
+
</div>
|
|
652
|
+
<div
|
|
653
|
+
className="replica-comment-reaction-detail-border"
|
|
654
|
+
role="tabpanel"
|
|
655
|
+
aria-label="回应人员"
|
|
656
|
+
>
|
|
657
|
+
<div className="replica-comment-reaction-detail-users">
|
|
658
|
+
{selected?.users.map((userID) => (
|
|
659
|
+
<ReactionPerson
|
|
660
|
+
key={userID}
|
|
661
|
+
userID={userID}
|
|
662
|
+
avatar
|
|
663
|
+
onUserClick={onUserClick}
|
|
664
|
+
/>
|
|
665
|
+
))}
|
|
666
|
+
</div>
|
|
667
|
+
{selected && selected.count > selected.users.length ? (
|
|
668
|
+
<p className="p-3 text-xs text-[var(--subtle-foreground)]">
|
|
669
|
+
当前仅显示部分回应人员。
|
|
670
|
+
</p>
|
|
671
|
+
) : null}
|
|
672
|
+
</div>
|
|
673
|
+
</>
|
|
674
|
+
)}
|
|
675
|
+
</div>
|
|
676
|
+
</div>
|
|
677
|
+
</DialogContent>
|
|
678
|
+
</Dialog>
|
|
679
|
+
</>
|
|
680
|
+
);
|
|
681
|
+
}
|