@lark-apaas/coding-template-nestjs-react-fullstack 0.1.57-alpha.20261103143319 → 0.1.57
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 +0 -2
- package/template/package-lock.json +211 -1452
- package/template/package.json +5 -16
- package/template/client/src/components/business-ui/record-comments/README.md +0 -141
- package/template/client/src/components/business-ui/record-comments/app-source.ts +0 -93
- 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 +0 -240
- package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +0 -231
- package/template/client/src/components/business-ui/record-comments/extensions.ts +0 -129
- package/template/client/src/components/business-ui/record-comments/index.ts +0 -9
- package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +0 -529
- package/template/client/src/components/business-ui/record-comments/internal/content.tsx +0 -213
- package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +0 -517
- package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +0 -675
- package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +0 -148
- package/template/client/src/components/business-ui/record-comments/internal/images.tsx +0 -155
- package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +0 -163
- package/template/client/src/components/business-ui/record-comments/internal/model.ts +0 -581
- package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +0 -170
- package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +0 -196
- package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +0 -681
- package/template/client/src/components/business-ui/record-comments/internal/styles.css +0 -1109
- package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +0 -456
- package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +0 -84
- package/template/client/src/components/business-ui/record-comments/types.ts +0 -118
|
@@ -1,517 +0,0 @@
|
|
|
1
|
-
import type { DiscussionReply, DiscussionImage, DiscussionPanelProps } from './view-types';
|
|
2
|
-
import type { MentionRef } from '../types';
|
|
3
|
-
import { COMMENT_THEME_CLASS, formatCommentTime, commentFailureMessage } from './model';
|
|
4
|
-
import { DiscussionIcon } from './icons';
|
|
5
|
-
import { DiscussionImageViewer } from './images';
|
|
6
|
-
import { DiscussionComposer } from './composer';
|
|
7
|
-
import { CommentReactionEntry, CommentReactionList } from './reactions';
|
|
8
|
-
import { CommentContent } from './content';
|
|
9
|
-
import { MentionProvider, useMentionEntity } from './mentions';
|
|
10
|
-
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
11
|
-
import { LockKeyhole } from 'lucide-react';
|
|
12
|
-
import { createContext, forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState, type HTMLAttributes, type ReactNode } from 'react';
|
|
13
|
-
|
|
14
|
-
function PersonAvatar({
|
|
15
|
-
refValue,
|
|
16
|
-
small,
|
|
17
|
-
onClick,
|
|
18
|
-
}: {
|
|
19
|
-
refValue: MentionRef;
|
|
20
|
-
small?: boolean;
|
|
21
|
-
onClick?: (anchor: HTMLElement) => void;
|
|
22
|
-
}) {
|
|
23
|
-
const person = useMentionEntity(refValue);
|
|
24
|
-
return (
|
|
25
|
-
<button
|
|
26
|
-
type="button"
|
|
27
|
-
className="replica-author-avatar"
|
|
28
|
-
data-small={small || undefined}
|
|
29
|
-
aria-label={`查看${person?.status === 'resolved' ? person.label : '评论者'}的人员卡片`}
|
|
30
|
-
onClick={(event) => onClick?.(event.currentTarget)}
|
|
31
|
-
>
|
|
32
|
-
<svg viewBox="0 0 72 72" aria-hidden="true">
|
|
33
|
-
<circle fill="#BBBFC4" cx="36" cy="36" r="36" />
|
|
34
|
-
<path
|
|
35
|
-
d="M30 37h12c5.523 0 12 4.286 12 12v4a2 2 0 01-2 2H20a2 2 0 01-2-2v-4c0-7.708 6.477-12 12-12zm6-2c-5.523 0-10-4.477-10-10s4.477-10 10-10 10 4.477 10 10-4.477 10-10 10z"
|
|
36
|
-
fill="#FFF"
|
|
37
|
-
/>
|
|
38
|
-
</svg>
|
|
39
|
-
{person?.status === 'resolved' && person.avatarUrl && (
|
|
40
|
-
<img src={person.avatarUrl} alt="" />
|
|
41
|
-
)}
|
|
42
|
-
</button>
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
function CommentTime({ time, edited }: { time: number; edited?: boolean }) {
|
|
46
|
-
const [, tick] = useState(0);
|
|
47
|
-
useEffect(() => {
|
|
48
|
-
const timer = window.setInterval(() => tick((value) => value + 1), 20000);
|
|
49
|
-
return () => clearInterval(timer);
|
|
50
|
-
}, []);
|
|
51
|
-
// Derive from the current timestamp on every render; an edit must not wait
|
|
52
|
-
// for the next relative-time timer before showing its new time.
|
|
53
|
-
const label = formatCommentTime(time);
|
|
54
|
-
return (
|
|
55
|
-
<time
|
|
56
|
-
className="replica-time"
|
|
57
|
-
dateTime={new Date(time * 1000).toISOString()}
|
|
58
|
-
title={new Date(time * 1000).toLocaleString('zh-CN')}
|
|
59
|
-
>
|
|
60
|
-
{label}
|
|
61
|
-
{edited ? ' (编辑过)' : ''}
|
|
62
|
-
</time>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
function ReplyItem({
|
|
66
|
-
comment,
|
|
67
|
-
commentID,
|
|
68
|
-
child,
|
|
69
|
-
panel,
|
|
70
|
-
replying,
|
|
71
|
-
onReply,
|
|
72
|
-
onPreviewImage,
|
|
73
|
-
replyDisabled,
|
|
74
|
-
}: {
|
|
75
|
-
comment: DiscussionReply;
|
|
76
|
-
commentID: string;
|
|
77
|
-
child: boolean;
|
|
78
|
-
panel: DiscussionPanelProps;
|
|
79
|
-
replying: boolean;
|
|
80
|
-
onReply: () => void;
|
|
81
|
-
onPreviewImage: (images: DiscussionImage[], index: number) => void;
|
|
82
|
-
replyDisabled?: boolean;
|
|
83
|
-
}) {
|
|
84
|
-
const person = useMentionEntity(comment.author || { kind: 'user', idType: 'snapshot', id: 'unknown' });
|
|
85
|
-
const [editing, setEditing] = useState(false);
|
|
86
|
-
const [editedLocally, setEditedLocally] = useState(false);
|
|
87
|
-
const [menu, setMenu] = useState(false);
|
|
88
|
-
const [picker, setPicker] = useState(false);
|
|
89
|
-
const [error, setError] = useState('');
|
|
90
|
-
const reactionBusy = useRef(false);
|
|
91
|
-
const deleteBusy = useRef(false);
|
|
92
|
-
const [reacting, setReacting] = useState(false);
|
|
93
|
-
const owned = Boolean(
|
|
94
|
-
panel.currentUser && comment.author &&
|
|
95
|
-
[panel.currentUser.id, ...(panel.currentUserAliases || [])].includes(
|
|
96
|
-
comment.author.id,
|
|
97
|
-
),
|
|
98
|
-
);
|
|
99
|
-
const mutation = comment.mutation;
|
|
100
|
-
const mutationBlocked = Boolean(mutation || comment.mutationBlocked);
|
|
101
|
-
const pendingAdd = mutation?.kind === 'add' && mutation.phase === 'sending';
|
|
102
|
-
const [showPending, setShowPending] = useState(false);
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
setShowPending(false);
|
|
105
|
-
if (!pendingAdd) return;
|
|
106
|
-
const timer = setTimeout(() => setShowPending(true), 500);
|
|
107
|
-
return () => clearTimeout(timer);
|
|
108
|
-
}, [mutation?.id, pendingAdd]);
|
|
109
|
-
const mutationFailed = mutation?.phase === 'failed' || mutation?.phase === 'uncertain';
|
|
110
|
-
const mutationError = /network|timeout|网络|超时|结果未知/i.test(mutation?.message ?? '')
|
|
111
|
-
? '网络异常,请稍后重试' : /权限/.test(mutation?.message ?? '') ? '暂无权限评论此文档' : mutation?.kind === 'edit' ? '评论修改失败' : mutation?.kind === 'delete' ? '评论删除失败' : '评论发送失败';
|
|
112
|
-
const canEdit = !mutationBlocked && (comment.permissions?.canEdit ?? owned);
|
|
113
|
-
const canDelete = !mutationBlocked && (comment.permissions?.canDelete ?? owned);
|
|
114
|
-
const canCopyLink = !mutationBlocked && (comment.permissions?.canCopyLink ?? owned);
|
|
115
|
-
useEffect(() => { if (!canEdit) setEditing(false); }, [canEdit]);
|
|
116
|
-
const canCompose = !mutationBlocked && (panel.allowCompose ?? Boolean(panel.currentUser));
|
|
117
|
-
const canReact = !mutationBlocked && (comment.permissions?.canReact ?? true) && (panel.allowReact ?? Boolean(panel.currentUser));
|
|
118
|
-
const handleToggleReaction = async (key: string) => {
|
|
119
|
-
if (reactionBusy.current || !canReact) return;
|
|
120
|
-
reactionBusy.current = true;
|
|
121
|
-
setReacting(true);
|
|
122
|
-
setError('');
|
|
123
|
-
try { await panel.onToggleReaction(commentID, comment.replyID, key); }
|
|
124
|
-
catch (cause) { setError(commentFailureMessage(cause, '回应失败,请重试')); }
|
|
125
|
-
finally { reactionBusy.current = false; setReacting(false); }
|
|
126
|
-
};
|
|
127
|
-
const handleProfile = (anchor: HTMLElement) =>
|
|
128
|
-
comment.author && panel.onMentionClick?.(comment.author, anchor);
|
|
129
|
-
return (
|
|
130
|
-
<article
|
|
131
|
-
className={`replica-reply ${child ? 'replica-reply-child' : ''} ${replying || editing ? 'replica-reply-target' : ''}`}
|
|
132
|
-
tabIndex={0}
|
|
133
|
-
data-comment-id={commentID}
|
|
134
|
-
data-reply-id={comment.replyID}
|
|
135
|
-
data-tools-open={menu || picker || undefined}
|
|
136
|
-
data-mutation-state={mutation?.phase}
|
|
137
|
-
aria-busy={mutation?.phase === 'sending' || undefined}
|
|
138
|
-
>
|
|
139
|
-
<PersonAvatar
|
|
140
|
-
refValue={comment.author || { kind: 'user', idType: 'snapshot', id: 'unknown' }}
|
|
141
|
-
small={child}
|
|
142
|
-
onClick={handleProfile}
|
|
143
|
-
/>
|
|
144
|
-
<div className="replica-reply-main">
|
|
145
|
-
<div className="replica-reply-info">
|
|
146
|
-
<button
|
|
147
|
-
type="button"
|
|
148
|
-
className="replica-author-name"
|
|
149
|
-
onClick={(event) => handleProfile(event.currentTarget)}
|
|
150
|
-
>
|
|
151
|
-
{person?.status === 'resolved' ? person.label : comment.author && !person ? '加载中…' : '未知用户'}
|
|
152
|
-
</button>
|
|
153
|
-
{mutation?.phase !== 'sending' && !mutationFailed && <CommentTime time={comment.updateTime ?? comment.createTime} edited={comment.edited || editedLocally} />}
|
|
154
|
-
{comment.private && (
|
|
155
|
-
<LockKeyhole
|
|
156
|
-
size={12}
|
|
157
|
-
className="replica-private-marker"
|
|
158
|
-
aria-label="私有评论"
|
|
159
|
-
/>
|
|
160
|
-
)}
|
|
161
|
-
<div className="replica-reply-controls" hidden={editing}>
|
|
162
|
-
{panel.showReactions !== false && canReact && <CommentReactionEntry
|
|
163
|
-
disabled={reacting}
|
|
164
|
-
onToggle={(key) => { void handleToggleReaction(key); }}
|
|
165
|
-
onOpenChange={setPicker}
|
|
166
|
-
/>}
|
|
167
|
-
{canCompose && <button
|
|
168
|
-
type="button"
|
|
169
|
-
className="replica-icon-button"
|
|
170
|
-
aria-label="回复评论"
|
|
171
|
-
title="回复"
|
|
172
|
-
disabled={replyDisabled}
|
|
173
|
-
onClick={onReply}
|
|
174
|
-
>
|
|
175
|
-
<DiscussionIcon name="reply" />
|
|
176
|
-
</button>}
|
|
177
|
-
{(canEdit || canDelete || canCopyLink) && (
|
|
178
|
-
<Popover open={menu} onOpenChange={setMenu}>
|
|
179
|
-
<PopoverTrigger asChild>
|
|
180
|
-
<button
|
|
181
|
-
type="button"
|
|
182
|
-
className="replica-icon-button"
|
|
183
|
-
aria-label="更多评论操作"
|
|
184
|
-
title="更多"
|
|
185
|
-
>
|
|
186
|
-
<DiscussionIcon name="more" />
|
|
187
|
-
</button>
|
|
188
|
-
</PopoverTrigger>
|
|
189
|
-
<PopoverContent
|
|
190
|
-
align="end"
|
|
191
|
-
sideOffset={6}
|
|
192
|
-
data-replica-comment-overlay
|
|
193
|
-
className={`${COMMENT_THEME_CLASS} replica-action-menu`}
|
|
194
|
-
>
|
|
195
|
-
<div
|
|
196
|
-
role="menu"
|
|
197
|
-
aria-label="评论操作"
|
|
198
|
-
onKeyDown={(event) => {
|
|
199
|
-
if (
|
|
200
|
-
!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(
|
|
201
|
-
event.key,
|
|
202
|
-
)
|
|
203
|
-
)
|
|
204
|
-
return;
|
|
205
|
-
const items = Array.from(
|
|
206
|
-
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
|
207
|
-
'[role="menuitem"]',
|
|
208
|
-
),
|
|
209
|
-
);
|
|
210
|
-
const index = items.indexOf(
|
|
211
|
-
event.target as HTMLButtonElement,
|
|
212
|
-
);
|
|
213
|
-
const next =
|
|
214
|
-
event.key === 'Home'
|
|
215
|
-
? 0
|
|
216
|
-
: event.key === 'End'
|
|
217
|
-
? items.length - 1
|
|
218
|
-
: (index +
|
|
219
|
-
(event.key === 'ArrowDown' ? 1 : -1) +
|
|
220
|
-
items.length) %
|
|
221
|
-
items.length;
|
|
222
|
-
event.preventDefault();
|
|
223
|
-
items[next]?.focus();
|
|
224
|
-
}}
|
|
225
|
-
>
|
|
226
|
-
{canEdit && (
|
|
227
|
-
<button
|
|
228
|
-
type="button"
|
|
229
|
-
role="menuitem"
|
|
230
|
-
onClick={() => {
|
|
231
|
-
setMenu(false);
|
|
232
|
-
setEditing(true);
|
|
233
|
-
}}
|
|
234
|
-
>
|
|
235
|
-
编辑
|
|
236
|
-
</button>
|
|
237
|
-
)}
|
|
238
|
-
{canCopyLink && (
|
|
239
|
-
<button
|
|
240
|
-
type="button"
|
|
241
|
-
role="menuitem"
|
|
242
|
-
onClick={async () => {
|
|
243
|
-
try {
|
|
244
|
-
const fallback = new URL(window.location.href);
|
|
245
|
-
fallback.searchParams.set('comment_id', commentID);
|
|
246
|
-
fallback.searchParams.set('reply_id', comment.replyID);
|
|
247
|
-
const link =
|
|
248
|
-
panel.getCommentLink?.(commentID, comment) ||
|
|
249
|
-
comment.link ||
|
|
250
|
-
fallback.href;
|
|
251
|
-
await navigator.clipboard.writeText(link);
|
|
252
|
-
setMenu(false);
|
|
253
|
-
} catch {
|
|
254
|
-
setError('无法访问剪贴板,请重试复制链接');
|
|
255
|
-
}
|
|
256
|
-
}}
|
|
257
|
-
>
|
|
258
|
-
复制链接
|
|
259
|
-
</button>
|
|
260
|
-
)}
|
|
261
|
-
{canDelete && (
|
|
262
|
-
<button
|
|
263
|
-
type="button"
|
|
264
|
-
role="menuitem"
|
|
265
|
-
onClick={async () => {
|
|
266
|
-
if (deleteBusy.current || !canDelete) return;
|
|
267
|
-
deleteBusy.current = true;
|
|
268
|
-
setMenu(false);
|
|
269
|
-
setError('');
|
|
270
|
-
try { await panel.onDelete(commentID, comment.replyID); }
|
|
271
|
-
catch (cause) { setError(commentFailureMessage(cause, '删除失败,请重试')); }
|
|
272
|
-
finally { deleteBusy.current = false; }
|
|
273
|
-
}}
|
|
274
|
-
>
|
|
275
|
-
删除
|
|
276
|
-
</button>
|
|
277
|
-
)}
|
|
278
|
-
</div>
|
|
279
|
-
</PopoverContent>
|
|
280
|
-
</Popover>
|
|
281
|
-
)}
|
|
282
|
-
</div>
|
|
283
|
-
</div>
|
|
284
|
-
{editing ? (
|
|
285
|
-
<DiscussionComposer
|
|
286
|
-
key={comment.replyID}
|
|
287
|
-
sessionKey={`edit:${comment.replyID}`}
|
|
288
|
-
editing
|
|
289
|
-
initialValue={comment.value}
|
|
290
|
-
initialImages={comment.images}
|
|
291
|
-
onCancel={() => setEditing(false)}
|
|
292
|
-
enableDocuments={panel.enableDocuments}
|
|
293
|
-
enableImages={panel.enableEditImages ?? panel.enableImages}
|
|
294
|
-
enablePrivateComments={panel.enablePrivateComments}
|
|
295
|
-
onSubmit={async (draft) => {
|
|
296
|
-
await panel.onEdit(commentID, comment.replyID, draft.value, draft.images);
|
|
297
|
-
if (!panel.optimisticMutations) setEditedLocally(true);
|
|
298
|
-
setEditing(false);
|
|
299
|
-
}}
|
|
300
|
-
/>
|
|
301
|
-
) : (
|
|
302
|
-
<>
|
|
303
|
-
<CommentContent
|
|
304
|
-
value={comment.value}
|
|
305
|
-
className="replica-reply-body"
|
|
306
|
-
onMentionClick={panel.onMentionClick}
|
|
307
|
-
trailing={pendingAdd && showPending && !comment.images.length ? (
|
|
308
|
-
<span className="replica-mutation-loading" role="status" aria-label="正在提交评论">
|
|
309
|
-
<svg viewBox="0 0 100 100" aria-hidden="true">
|
|
310
|
-
<circle cx="50" cy="50" r="35" stroke="currentColor" strokeWidth="11" strokeLinecap="round" fill="none" />
|
|
311
|
-
</svg>
|
|
312
|
-
</span>
|
|
313
|
-
) : undefined}
|
|
314
|
-
/>
|
|
315
|
-
{comment.images.length > 0 && (
|
|
316
|
-
<div className="replica-message-images">
|
|
317
|
-
{comment.images.map((image, index) => (
|
|
318
|
-
<button
|
|
319
|
-
type="button"
|
|
320
|
-
key={image.id}
|
|
321
|
-
aria-label={`预览图片 ${image.name}`}
|
|
322
|
-
onClick={() => onPreviewImage(comment.images, index)}
|
|
323
|
-
>
|
|
324
|
-
<img src={image.url} alt={image.name} />
|
|
325
|
-
</button>
|
|
326
|
-
))}
|
|
327
|
-
</div>
|
|
328
|
-
)}
|
|
329
|
-
<div className="replica-reactions">
|
|
330
|
-
<CommentReactionList
|
|
331
|
-
replyId={comment.replyID}
|
|
332
|
-
reactions={comment.reactions}
|
|
333
|
-
onToggle={
|
|
334
|
-
canReact && !reacting
|
|
335
|
-
? (key) => { void handleToggleReaction(key); }
|
|
336
|
-
: undefined
|
|
337
|
-
}
|
|
338
|
-
loadDetails={panel.loadReactionDetails
|
|
339
|
-
? (signal) => panel.loadReactionDetails!(commentID, comment.replyID, signal)
|
|
340
|
-
: undefined}
|
|
341
|
-
onUserClick={(id, anchor) =>
|
|
342
|
-
panel.onMentionClick?.(
|
|
343
|
-
panel.resolveUserRef?.(id) || {
|
|
344
|
-
kind: 'user',
|
|
345
|
-
idType: 'apaas-user',
|
|
346
|
-
id,
|
|
347
|
-
},
|
|
348
|
-
anchor,
|
|
349
|
-
)
|
|
350
|
-
}
|
|
351
|
-
/>
|
|
352
|
-
</div>
|
|
353
|
-
</>
|
|
354
|
-
)}
|
|
355
|
-
{mutation && mutationFailed && <div className="replica-mutation-error" role="alert">
|
|
356
|
-
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
|
357
|
-
<path d="M8 16A8 8 0 118 0a8 8 0 010 16z" fill="currentColor" />
|
|
358
|
-
<path d="M7 4l.48 6H8.5L9 4s.277-1-1-1C6.723 3 7 4 7 4z" fill="#fff" />
|
|
359
|
-
<circle fill="#fff" cx="8" cy="12" r="1" />
|
|
360
|
-
</svg>
|
|
361
|
-
<span>{mutationError}{mutation.phase === 'failed' && <button type="button" onClick={() => panel.onRetryMutation?.(mutation.id)}>点击重试</button>}</span>
|
|
362
|
-
</div>}
|
|
363
|
-
{error && (
|
|
364
|
-
<p role="alert" className="replica-error">
|
|
365
|
-
{error}
|
|
366
|
-
</p>
|
|
367
|
-
)}
|
|
368
|
-
</div>
|
|
369
|
-
</article>
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
interface DiscussionSession {
|
|
374
|
-
panel: DiscussionPanelProps;
|
|
375
|
-
replyTarget: { commentID: string; reply: DiscussionReply } | null;
|
|
376
|
-
replyRevision: number;
|
|
377
|
-
composing: boolean;
|
|
378
|
-
hasComposer: boolean;
|
|
379
|
-
registerComposer: () => () => void;
|
|
380
|
-
reply: (commentID: string, reply: DiscussionReply) => void;
|
|
381
|
-
cancelReply: () => void;
|
|
382
|
-
submit: DiscussionPanelProps['onSubmit'];
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const DiscussionContext = createContext<DiscussionSession | null>(null);
|
|
386
|
-
|
|
387
|
-
function useDiscussion() {
|
|
388
|
-
const session = useContext(DiscussionContext);
|
|
389
|
-
if (!session) throw new Error('CommentList and CommentComposer must be used inside CommentsProvider');
|
|
390
|
-
return session;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/** Owns interaction state and entity resolution, but deliberately renders no DOM. */
|
|
394
|
-
export function DiscussionProvider({ panel, children }: { panel: DiscussionPanelProps; children: ReactNode }) {
|
|
395
|
-
const [replyTarget, setReplyTarget] = useState<DiscussionSession['replyTarget']>(null);
|
|
396
|
-
const [replyRevision, setReplyRevision] = useState(0);
|
|
397
|
-
const [composing, setComposing] = useState(false);
|
|
398
|
-
const [composerCount, setComposerCount] = useState(0);
|
|
399
|
-
const composeBusy = useRef(false);
|
|
400
|
-
const registerComposer = useCallback(() => {
|
|
401
|
-
setComposerCount((count) => count + 1);
|
|
402
|
-
return () => setComposerCount((count) => count - 1);
|
|
403
|
-
}, []);
|
|
404
|
-
const allowCompose = panel.allowCompose ?? Boolean(panel.currentUser);
|
|
405
|
-
useEffect(() => {
|
|
406
|
-
if (!allowCompose || !composerCount || (replyTarget && !panel.comments.some((thread) =>
|
|
407
|
-
thread.commentID === replyTarget.commentID && thread.replies.some((reply) => reply.replyID === replyTarget.reply.replyID))))
|
|
408
|
-
setReplyTarget(null);
|
|
409
|
-
}, [allowCompose, composerCount, panel.comments, replyTarget]);
|
|
410
|
-
return (
|
|
411
|
-
<MentionProvider source={panel.source} currentUserID={panel.currentUser?.id}
|
|
412
|
-
highlightUserIDs={panel.highlightUserIDs} resolveUserRef={panel.resolveUserRef}>
|
|
413
|
-
<DiscussionContext.Provider value={{
|
|
414
|
-
panel, replyTarget, replyRevision, composing, hasComposer: composerCount > 0,
|
|
415
|
-
registerComposer,
|
|
416
|
-
reply: (commentID, reply) => {
|
|
417
|
-
if (!allowCompose || composeBusy.current) return;
|
|
418
|
-
setReplyTarget({ commentID, reply });
|
|
419
|
-
setReplyRevision((value) => value + 1);
|
|
420
|
-
},
|
|
421
|
-
cancelReply: () => setReplyTarget(null),
|
|
422
|
-
submit: async (draft) => {
|
|
423
|
-
if (composeBusy.current) return;
|
|
424
|
-
composeBusy.current = true;
|
|
425
|
-
setComposing(true);
|
|
426
|
-
try { await panel.onSubmit(draft); setReplyTarget(null); }
|
|
427
|
-
finally { composeBusy.current = false; setComposing(false); }
|
|
428
|
-
},
|
|
429
|
-
}}>{children}</DiscussionContext.Provider>
|
|
430
|
-
</MentionProvider>
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/** Shared list body, also used by the compatibility panel without extra wrappers. */
|
|
435
|
-
export const DiscussionList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function DiscussionList(
|
|
436
|
-
{ className = '', ...props }, forwardedRef,
|
|
437
|
-
) {
|
|
438
|
-
const { panel, replyTarget, reply, composing, hasComposer } = useDiscussion();
|
|
439
|
-
const list = useRef<HTMLDivElement>(null);
|
|
440
|
-
useImperativeHandle(forwardedRef, () => list.current!, []);
|
|
441
|
-
const [gallery, setGallery] = useState<{ images: DiscussionImage[]; index: number } | null>(null);
|
|
442
|
-
const replies = panel.comments.flatMap((thread) => thread.replies);
|
|
443
|
-
const previousIds = useRef(new Set(replies.map((item) => item.replyID)));
|
|
444
|
-
useEffect(() => {
|
|
445
|
-
const locate = () => {
|
|
446
|
-
const id = new URL(window.location.href).searchParams.get('reply_id');
|
|
447
|
-
list.current?.querySelectorAll('.replica-reply-linked').forEach((element) => element.classList.remove('replica-reply-linked'));
|
|
448
|
-
if (!id) return;
|
|
449
|
-
const target = list.current?.querySelector<HTMLElement>(`[data-reply-id="${CSS.escape(id)}"]`);
|
|
450
|
-
if (target) { target.classList.add('replica-reply-linked'); target.scrollIntoView({ block: 'nearest' }); }
|
|
451
|
-
};
|
|
452
|
-
locate();
|
|
453
|
-
window.addEventListener('popstate', locate);
|
|
454
|
-
return () => window.removeEventListener('popstate', locate);
|
|
455
|
-
}, []);
|
|
456
|
-
useEffect(() => {
|
|
457
|
-
if (panel.submittedReplyID) {
|
|
458
|
-
const target = list.current?.querySelector(`[data-reply-id="${CSS.escape(panel.submittedReplyID)}"]`);
|
|
459
|
-
if (target) { target.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); panel.onScrolledToReply?.(); }
|
|
460
|
-
}
|
|
461
|
-
const added = replies.filter((item) => !previousIds.current.has(item.replyID));
|
|
462
|
-
const newest = added.at(-1);
|
|
463
|
-
if (newest && panel.autoScrollNewReplies !== false)
|
|
464
|
-
list.current?.querySelector(`[data-reply-id="${CSS.escape(newest.replyID)}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
465
|
-
previousIds.current = new Set(replies.map((item) => item.replyID));
|
|
466
|
-
if (gallery && !replies.some((comment) => comment.images.some((item) => item.id === gallery.images[gallery.index]?.id)))
|
|
467
|
-
setGallery(null);
|
|
468
|
-
}, [panel, replies, gallery]);
|
|
469
|
-
const itemPanel = { ...panel, allowCompose: Boolean(panel.allowCompose && hasComposer) };
|
|
470
|
-
return <>
|
|
471
|
-
<div {...props} className={`replica-discussion-list ${className}`} ref={list}>
|
|
472
|
-
{panel.listHeader}
|
|
473
|
-
{panel.comments.map((thread) => (
|
|
474
|
-
<div className="replica-thread" key={thread.commentID} data-thread-id={thread.commentID}>
|
|
475
|
-
{thread.isSolved && <span className="replica-panel-solved">已解决</span>}
|
|
476
|
-
{thread.replies.map((comment, index) => (
|
|
477
|
-
<ReplyItem key={comment.replyID} comment={comment} commentID={thread.commentID} child={index > 0}
|
|
478
|
-
panel={itemPanel} replying={replyTarget?.commentID === thread.commentID && replyTarget.reply.replyID === comment.replyID}
|
|
479
|
-
replyDisabled={composing} onReply={() => reply(thread.commentID, comment)}
|
|
480
|
-
onPreviewImage={(images, index) => setGallery({ images, index })} />
|
|
481
|
-
))}
|
|
482
|
-
</div>
|
|
483
|
-
))}
|
|
484
|
-
{!replies.length && (panel.emptyState === undefined ? <div className="replica-empty">当前记录暂无评论</div> : panel.emptyState)}
|
|
485
|
-
{panel.listFooter}
|
|
486
|
-
</div>
|
|
487
|
-
{gallery && <DiscussionImageViewer images={gallery.images} initialIndex={gallery.index} onClose={() => setGallery(null)} />}
|
|
488
|
-
</>;
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
export function DiscussionComposerView({ placeholder, disabled }: { placeholder?: string; disabled?: boolean }) {
|
|
492
|
-
const { panel, replyTarget, replyRevision, cancelReply, submit, registerComposer } = useDiscussion();
|
|
493
|
-
const allowCompose = panel.allowCompose ?? Boolean(panel.currentUser);
|
|
494
|
-
useEffect(() => allowCompose ? registerComposer() : undefined, [allowCompose, registerComposer]);
|
|
495
|
-
const replyPerson = useMentionEntity(replyTarget?.reply.author || { kind: 'user', idType: 'snapshot', id: 'unknown' });
|
|
496
|
-
if (!allowCompose) return null;
|
|
497
|
-
const name = replyTarget ? replyPerson?.status === 'resolved' ? replyPerson.label : '评论者' : undefined;
|
|
498
|
-
return <DiscussionComposer sessionKey="new" commentID={replyTarget?.commentID}
|
|
499
|
-
replyIntentKey={replyTarget ? `${replyTarget.commentID}:${replyTarget.reply.replyID}:${replyRevision}` : undefined}
|
|
500
|
-
replyMention={replyTarget?.reply.author ? { type: 'mention', ref: replyTarget.reply.author,
|
|
501
|
-
label: replyPerson?.status === 'resolved' ? replyPerson.label : undefined } : undefined}
|
|
502
|
-
enableDocuments={panel.enableDocuments} enableImages={panel.enableImages} enablePrivateComments={panel.enablePrivateComments}
|
|
503
|
-
replyName={name} placeholder={name ? `回复 ${name}...` : placeholder || '添加新的评论...'}
|
|
504
|
-
disabled={disabled} onCancel={cancelReply} onSubmit={submit} />;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** Existing DOM and spacing are retained for callers of CommentPanel. */
|
|
508
|
-
export function DiscussionLayout() {
|
|
509
|
-
const { panel } = useDiscussion();
|
|
510
|
-
const allowCompose = panel.allowCompose ?? Boolean(panel.currentUser);
|
|
511
|
-
const count = panel.comments.reduce((sum, thread) => sum + thread.replies.length, 0);
|
|
512
|
-
return <section className={`${COMMENT_THEME_CLASS} replica-discussion ${panel.className || ''}`} aria-label="评论区域">
|
|
513
|
-
<header className="replica-discussion-header">评论 ({count})</header>
|
|
514
|
-
<DiscussionList />
|
|
515
|
-
{allowCompose && <div className="replica-discussion-footer"><DiscussionComposerView /></div>}
|
|
516
|
-
</section>;
|
|
517
|
-
}
|