@lark-apaas/coding-template-nestjs-react-fullstack 0.1.60-alpha.20261105142153 → 0.1.60-beta.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/package.json +1 -1
- package/template/client/src/components/business-ui/README.md +0 -2
- package/template/package-lock.json +30 -546
- package/template/package.json +3 -14
- package/template/scripts/build.sh +39 -22
- package/template/scripts/prune-smart.js +20 -1
- package/template/client/src/components/business-ui/comments/README.md +0 -101
- package/template/client/src/components/business-ui/comments/comments.tsx +0 -670
- package/template/client/src/components/business-ui/comments/contracts.ts +0 -256
- package/template/client/src/components/business-ui/comments/index.ts +0 -20
- package/template/client/src/components/business-ui/comments/use-comments.ts +0 -492
- 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 -130
- 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 -531
- 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 -523
- package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +0 -681
- 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 -198
- 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 -683
- 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 -91
- package/template/client/src/components/business-ui/record-comments/types.ts +0 -121
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import type { CommentDataSource, MentionOption, MentionResult } from './types';
|
|
2
|
-
import { getI18nText } from '../utils/user';
|
|
3
|
-
import { listUsersByIds, searchUsers } from '../api/users/service';
|
|
4
|
-
|
|
5
|
-
function avatarUrl(avatar: unknown): string | undefined {
|
|
6
|
-
if (typeof avatar === 'string') return avatar;
|
|
7
|
-
if (avatar && typeof avatar === 'object' && 'image' in avatar) {
|
|
8
|
-
const image = avatar.image;
|
|
9
|
-
if (
|
|
10
|
-
image &&
|
|
11
|
-
typeof image === 'object' &&
|
|
12
|
-
'large' in image &&
|
|
13
|
-
typeof image.large === 'string'
|
|
14
|
-
)
|
|
15
|
-
return image.large;
|
|
16
|
-
}
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** The same Miaoda user IDs and built-in service used by UserDisplay. */
|
|
21
|
-
export function createAppCommentSource(scopeKey: string): CommentDataSource {
|
|
22
|
-
return {
|
|
23
|
-
scopeKey,
|
|
24
|
-
async search({ query, cursor, signal }) {
|
|
25
|
-
signal.throwIfAborted();
|
|
26
|
-
// The built-in directory has no empty-query recommendations. In a mixed
|
|
27
|
-
// source, leave that branch empty so document recommendations still load.
|
|
28
|
-
if (!query) return { items: [] };
|
|
29
|
-
const offset = Number(cursor || 0);
|
|
30
|
-
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
31
|
-
throw new Error('分页参数无效');
|
|
32
|
-
const response = await searchUsers({ query, offset, pageSize: 20 });
|
|
33
|
-
signal.throwIfAborted();
|
|
34
|
-
if (String(response.status_code) !== '0')
|
|
35
|
-
throw new Error('人员搜索失败,请重试');
|
|
36
|
-
const users = response.data.userList ?? [];
|
|
37
|
-
const items: MentionOption[] = users.flatMap((user) => {
|
|
38
|
-
// Unregistered contacts have no Miaoda ID; never substitute a Lark ID.
|
|
39
|
-
const id = String(user.userID || '');
|
|
40
|
-
if (!/^[1-9]\d*$/.test(id)) return [];
|
|
41
|
-
return [
|
|
42
|
-
{
|
|
43
|
-
ref: { kind: 'user' as const, idType: 'apaas-user' as const, id },
|
|
44
|
-
label: getI18nText(user.name) || '未命名人员',
|
|
45
|
-
avatarUrl: avatarUrl(user.avatar),
|
|
46
|
-
description: getI18nText(user.department?.name) || user.tenantName,
|
|
47
|
-
isExternal: user.userType === '_externalUser',
|
|
48
|
-
},
|
|
49
|
-
];
|
|
50
|
-
});
|
|
51
|
-
const nextOffset = offset + users.length;
|
|
52
|
-
return {
|
|
53
|
-
items,
|
|
54
|
-
nextCursor:
|
|
55
|
-
users.length && nextOffset < response.data.total
|
|
56
|
-
? String(nextOffset)
|
|
57
|
-
: undefined,
|
|
58
|
-
};
|
|
59
|
-
},
|
|
60
|
-
async resolve(refs, signal) {
|
|
61
|
-
signal.throwIfAborted();
|
|
62
|
-
const ids = refs.flatMap((ref) =>
|
|
63
|
-
ref.kind === 'user' && ref.idType === 'apaas-user' ? [ref.id] : [],
|
|
64
|
-
);
|
|
65
|
-
const response = ids.length ? await listUsersByIds(ids) : undefined;
|
|
66
|
-
signal.throwIfAborted();
|
|
67
|
-
const base = response?.data?.baseResp;
|
|
68
|
-
if (base?.statusCode)
|
|
69
|
-
throw new Error(base.statusMessage || '人员信息加载失败');
|
|
70
|
-
if (ids.length && !response?.data?.userInfoMap)
|
|
71
|
-
throw new Error('人员信息返回格式无效');
|
|
72
|
-
const people = response?.data?.userInfoMap || {};
|
|
73
|
-
return refs.map((ref): MentionResult => {
|
|
74
|
-
if (ref.kind !== 'user' || ref.idType !== 'apaas-user')
|
|
75
|
-
return { ref, status: 'error' };
|
|
76
|
-
const user = people[ref.id];
|
|
77
|
-
if (!user) return { ref, status: 'not-found' };
|
|
78
|
-
return {
|
|
79
|
-
ref,
|
|
80
|
-
status: 'resolved',
|
|
81
|
-
label: getI18nText(user.name) || '未命名人员',
|
|
82
|
-
avatarUrl: avatarUrl(user.avatar),
|
|
83
|
-
description: getI18nText(user.department?.name),
|
|
84
|
-
isExternal: user.userType === '_externalUser',
|
|
85
|
-
};
|
|
86
|
-
});
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export { withDocumentSource } from './extensions';
|
|
92
|
-
|
|
93
|
-
export { documentRefFromUrl, safeUrl } from './internal/model';
|
package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png
DELETED
|
Binary file
|
package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts
DELETED
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import type { CommentDataSource, CommentMentionClickHandler } from './types';
|
|
2
|
-
import type { CommentExtension } from './extensions';
|
|
3
|
-
import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Public contract for the controlled CommentPanel entry point.
|
|
7
|
-
* Requests, credentials, pagination cursors and server caches belong to the host.
|
|
8
|
-
* The panel owns optimistic drafts and per-message mutation state.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export type CommentReadContent =
|
|
15
|
-
| { type: 'text'; text: string }
|
|
16
|
-
| { type: 'mention'; userID?: number }
|
|
17
|
-
| { type: 'link'; url: string };
|
|
18
|
-
|
|
19
|
-
export type CommentWriteContent =
|
|
20
|
-
| { type: 'text'; text: string }
|
|
21
|
-
| { type: 'mention'; userID: number }
|
|
22
|
-
| { type: 'link'; url: string };
|
|
23
|
-
|
|
24
|
-
export interface RecordCommentReply {
|
|
25
|
-
replyID: string;
|
|
26
|
-
creator?: number;
|
|
27
|
-
/** Unix seconds, not milliseconds. */
|
|
28
|
-
createTime: number;
|
|
29
|
-
/** Authoritative per-reply last update, Unix seconds. Omit when unavailable. */
|
|
30
|
-
updateTime?: number;
|
|
31
|
-
content: CommentReadContent[];
|
|
32
|
-
/** Omission means reactions were not loaded or are unavailable in this identity mode. */
|
|
33
|
-
reactions?: Array<{ key: string; count: number; mine: boolean; userIDs: number[] }>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface RecordComment {
|
|
37
|
-
commentID: string;
|
|
38
|
-
creator?: number;
|
|
39
|
-
createTime: number;
|
|
40
|
-
isSolved: boolean;
|
|
41
|
-
/** First reply is the body; subsequent replies belong to the same discussion. */
|
|
42
|
-
replies: RecordCommentReply[];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface ListRecordCommentsInput {
|
|
46
|
-
recordID: string;
|
|
47
|
-
commentID?: string;
|
|
48
|
-
needReaction?: boolean;
|
|
49
|
-
pageSize?: number;
|
|
50
|
-
pageToken?: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface RecordCommentPage {
|
|
54
|
-
comments: RecordComment[];
|
|
55
|
-
hasMore: boolean;
|
|
56
|
-
pageToken: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface AddRecordCommentInput {
|
|
60
|
-
recordID: string;
|
|
61
|
-
/** Omit for a new discussion. Never send an empty string. */
|
|
62
|
-
commentID?: string;
|
|
63
|
-
content: CommentWriteContent[];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface UpdateRecordCommentInput {
|
|
67
|
-
recordID: string;
|
|
68
|
-
commentID: string;
|
|
69
|
-
replyID: string;
|
|
70
|
-
content: CommentWriteContent[];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface DeleteRecordCommentInput {
|
|
74
|
-
recordID: string;
|
|
75
|
-
commentID: string;
|
|
76
|
-
/** Always supplied by the panel to delete one message, including the body.
|
|
77
|
-
* Omission is a legacy explicit whole-discussion command for external callers. */
|
|
78
|
-
replyID?: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface CommentIDs {
|
|
82
|
-
commentID: string;
|
|
83
|
-
replyID: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface DeleteCommentResult {
|
|
87
|
-
commentID: string;
|
|
88
|
-
commentDeleted: boolean;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface CommentTarget {
|
|
92
|
-
recordID: string;
|
|
93
|
-
commentID: string;
|
|
94
|
-
replyID: string;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface CommentPermissionContext extends CommentTarget {
|
|
98
|
-
comment: RecordComment;
|
|
99
|
-
reply: RecordCommentReply;
|
|
100
|
-
isBody: boolean;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export interface CommentPagination {
|
|
104
|
-
hasMore: boolean;
|
|
105
|
-
/** Host state, in addition to the panel's immediate in-flight guard. */
|
|
106
|
-
loading?: boolean;
|
|
107
|
-
/** An external page-load failure. Does not replace already loaded comments. */
|
|
108
|
-
error?: string;
|
|
109
|
-
/** Append to comments before resolving; reject on failure. Cursors stay outside. */
|
|
110
|
-
onLoadMore: () => Promise<void>;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface CommentReaction {
|
|
114
|
-
emojiKey: string;
|
|
115
|
-
count: number;
|
|
116
|
-
/** Authoritative membership. Never infer this from a truncated users list. */
|
|
117
|
-
reactedByMe: boolean;
|
|
118
|
-
previewUserIDs?: readonly number[];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export interface ChangeCommentReactionInput extends CommentTarget {
|
|
122
|
-
emojiKey: string;
|
|
123
|
-
action: 'add' | 'remove';
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export interface CommentReactionFeature {
|
|
127
|
-
canChange?: (target: CommentTarget) => boolean;
|
|
128
|
-
getReactions: (target: CommentTarget) => readonly CommentReaction[];
|
|
129
|
-
/** Host updates its controlled reaction data before resolving. */
|
|
130
|
-
onChange?: (input: ChangeCommentReactionInput) => Promise<void>;
|
|
131
|
-
/** Optional paginated users; an absent loader means no complete-members claim. */
|
|
132
|
-
loadUsers?: (
|
|
133
|
-
input: CommentTarget & { emojiKey: string; cursor?: string },
|
|
134
|
-
signal: AbortSignal,
|
|
135
|
-
) => Promise<{ userIDs: number[]; nextCursor?: string }>;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export interface CommentImage {
|
|
139
|
-
id: string;
|
|
140
|
-
url: string;
|
|
141
|
-
name?: string;
|
|
142
|
-
width?: number;
|
|
143
|
-
height?: number;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** A retained image or a newly selected local file. Never a Bitable Action element. */
|
|
147
|
-
export type CommentImageInput =
|
|
148
|
-
| { kind: 'existing'; image: CommentImage }
|
|
149
|
-
| { kind: 'local'; localID: string; file: File };
|
|
150
|
-
|
|
151
|
-
/** Separate image-bearing command: content may be empty when images are present. */
|
|
152
|
-
export interface AddImageCommentInput {
|
|
153
|
-
recordID: string;
|
|
154
|
-
commentID?: string;
|
|
155
|
-
content: CommentWriteContent[];
|
|
156
|
-
images: readonly CommentImageInput[];
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export interface UpdateImageCommentInput extends AddImageCommentInput {
|
|
160
|
-
commentID: string;
|
|
161
|
-
replyID: string;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export interface CommentImageFeature {
|
|
165
|
-
/** UI cannot derive images from the official plugin's empty content array. */
|
|
166
|
-
getImages: (target: CommentTarget) => readonly CommentImage[];
|
|
167
|
-
/** Enables file selection for new comments/replies. Owns the entire command. */
|
|
168
|
-
onAddComment?: (input: AddImageCommentInput) => Promise<CommentIDs>;
|
|
169
|
-
/** Required to edit an existing image-bearing reply without losing its images. */
|
|
170
|
-
onUpdateComment?: (input: UpdateImageCommentInput) => Promise<CommentIDs>;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Plain Error means a rejected write. Mark outcome=unknown for a timeout or
|
|
174
|
-
* transport failure that may have committed; the panel then prevents blind retry. */
|
|
175
|
-
export interface CommentMutationFailure extends Error {
|
|
176
|
-
outcome?: 'not-applied' | 'unknown';
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export interface CommentPanelProps {
|
|
180
|
-
/** Stable app/tenant/identity/data-source scope. No credentials. */
|
|
181
|
-
scopeKey: string;
|
|
182
|
-
recordID: string;
|
|
183
|
-
/** Controlled server data; the panel never mutates this array. */
|
|
184
|
-
comments: readonly RecordComment[];
|
|
185
|
-
/** Externally supplied @ lookup/resolution; contains no comment CRUD methods. */
|
|
186
|
-
mentionSource: CommentDataSource;
|
|
187
|
-
/** Enable document suggestions only with a user-authorized document source. Writes remain link elements. */
|
|
188
|
-
enableDocuments?: boolean;
|
|
189
|
-
/** Miaoda user ID; used for presentation, not authorization. */
|
|
190
|
-
currentUserID?: number;
|
|
191
|
-
|
|
192
|
-
/** Initial read is initiated by the host. */
|
|
193
|
-
loading?: boolean;
|
|
194
|
-
/** Initial/background read error; existing comments remain visible. */
|
|
195
|
-
error?: string;
|
|
196
|
-
onRetry?: () => Promise<void>;
|
|
197
|
-
/** Omit when the host has already supplied the entire list. */
|
|
198
|
-
pagination?: CommentPagination;
|
|
199
|
-
|
|
200
|
-
/** Resolve with real IDs on write success; comments may be updated later.
|
|
201
|
-
* The panel immediately renders a local overlay, owns pending/error/retry state,
|
|
202
|
-
* and reconciles it when matching controlled data arrives. Refresh failures must
|
|
203
|
-
* not reject a committed write. Mark ambiguous failures with outcome=unknown. */
|
|
204
|
-
onAddComment?: (input: AddRecordCommentInput) => Promise<CommentIDs>;
|
|
205
|
-
onUpdateComment?: (input: UpdateRecordCommentInput) => Promise<CommentIDs>;
|
|
206
|
-
onDeleteComment?: (input: DeleteRecordCommentInput) => Promise<DeleteCommentResult>;
|
|
207
|
-
/** Absent policy means edit/delete are hidden. Backend remains authoritative. */
|
|
208
|
-
getReplyPermissions?: (
|
|
209
|
-
context: CommentPermissionContext,
|
|
210
|
-
) => { canEdit: boolean; canDelete: boolean };
|
|
211
|
-
|
|
212
|
-
/** Absent callback means no copy-link menu item. */
|
|
213
|
-
getCommentLink?: (target: CommentTarget) => string | undefined;
|
|
214
|
-
onMentionClick?: CommentMentionClickHandler;
|
|
215
|
-
/** Requests remain external. Prefer extensions; direct feature props remain compatible. */
|
|
216
|
-
reactions?: CommentReactionFeature;
|
|
217
|
-
images?: CommentImageFeature;
|
|
218
|
-
/** Stable, per-session registrations. One of each kind; registered kinds override legacy props. */
|
|
219
|
-
extensions?: readonly CommentExtension[];
|
|
220
|
-
|
|
221
|
-
/** Default: nearest scrollable ancestor of the bottom sentinel, else viewport. */
|
|
222
|
-
getScrollContainer?: () => HTMLElement | null;
|
|
223
|
-
className?: string;
|
|
224
|
-
/** Layout only; current internal design tokens and visual styles are preserved. */
|
|
225
|
-
style?: CSSProperties;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** One record/identity session. Shares behavior without rendering a layout element. */
|
|
229
|
-
export interface CommentsProviderProps extends Omit<CommentPanelProps, 'className' | 'style'> {
|
|
230
|
-
children: ReactNode;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** The host owns the heading, outer scroll region and surrounding layout. */
|
|
234
|
-
export type CommentListProps = Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'dangerouslySetInnerHTML'>;
|
|
235
|
-
|
|
236
|
-
/** One primary composer per provider; inline edit composers stay inside the list. */
|
|
237
|
-
export interface CommentComposerProps extends CommentListProps {
|
|
238
|
-
placeholder?: string;
|
|
239
|
-
disabled?: boolean;
|
|
240
|
-
}
|
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import type { CommentComposerProps, CommentListProps, CommentPanelProps, CommentsProviderProps, CommentTarget } from './comment-panel.contract';
|
|
2
|
-
import { CommentMutations } from './internal/mutations';
|
|
3
|
-
import { buildPanelThreads, CommentPageLoader, preparePanelDeletion, preparePanelEdit, loadPanelReactionDetails, preparePanelComment } from './internal/model';
|
|
4
|
-
import { DiscussionComposerView, DiscussionLayout, DiscussionList, DiscussionProvider } from './internal/discussion';
|
|
5
|
-
import { commentExtensionFeatures, withDocumentSource } from './extensions';
|
|
6
|
-
import { COMMENT_THEME_CLASS } from './internal/model';
|
|
7
|
-
import { LoaderCircle } from 'lucide-react';
|
|
8
|
-
import { forwardRef, useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';
|
|
9
|
-
import './internal/styles.css';
|
|
10
|
-
|
|
11
|
-
export function ReadStatus({ loading, error, onRetry, empty, hasMore }: Pick<CommentPanelProps, 'loading' | 'error' | 'onRetry'> & { empty: boolean; hasMore?: boolean }) {
|
|
12
|
-
const [failure, setFailure] = useState('');
|
|
13
|
-
const [retrying, setRetrying] = useState(false);
|
|
14
|
-
const busy = useRef(false);
|
|
15
|
-
// A successful external read must clear a previous retry failure as well.
|
|
16
|
-
useEffect(() => setFailure(''), [error]);
|
|
17
|
-
const pending = loading || retrying;
|
|
18
|
-
const message = failure || error;
|
|
19
|
-
if (!empty && !pending && !message) return null;
|
|
20
|
-
const state = pending ? 'loading' : message ? 'error' : 'empty';
|
|
21
|
-
return <div className={`replica-panel-status${empty ? ' replica-panel-placeholder' : ''}`}
|
|
22
|
-
data-state={state} role={state === 'error' ? 'alert' : 'status'} aria-busy={pending}>
|
|
23
|
-
<div className="replica-panel-status-content">
|
|
24
|
-
<span>{pending ? <LoaderCircle className="size-4 animate-spin text-primary" aria-label="正在加载评论" />
|
|
25
|
-
: message || (empty && !hasMore ? '当前记录暂无评论' : null)}</span>
|
|
26
|
-
{!pending && message && onRetry && <button type="button" onClick={async () => {
|
|
27
|
-
if (busy.current) return;
|
|
28
|
-
busy.current = true; setRetrying(true); setFailure('');
|
|
29
|
-
try { await onRetry(); }
|
|
30
|
-
catch (cause) { setFailure(cause instanceof Error ? cause.message : '刷新失败,请重试'); }
|
|
31
|
-
finally { busy.current = false; setRetrying(false); }
|
|
32
|
-
}}>重试刷新</button>}
|
|
33
|
-
</div>
|
|
34
|
-
</div>;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function nearestScrollContainer(node: HTMLElement): HTMLElement | null {
|
|
38
|
-
for (let parent = node.parentElement; parent; parent = parent.parentElement) {
|
|
39
|
-
if (/(auto|scroll)/.test(getComputedStyle(parent).overflowY) && parent.clientHeight > 0) return parent;
|
|
40
|
-
}
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function PaginationFooter({ pagination, loading, getScrollContainer }: Pick<CommentPanelProps, 'pagination' | 'loading' | 'getScrollContainer'>) {
|
|
45
|
-
const sentinel = useRef<HTMLDivElement>(null);
|
|
46
|
-
const root = useRef<HTMLElement | null>(null);
|
|
47
|
-
const [gate] = useState(() => new CommentPageLoader());
|
|
48
|
-
const [, refresh] = useReducer((value: number) => value + 1, 0);
|
|
49
|
-
const latest = useRef({ pagination, loading });
|
|
50
|
-
latest.current = { pagination, loading };
|
|
51
|
-
const alive = useRef(true);
|
|
52
|
-
const run = useRef((manual = false) => { void manual; });
|
|
53
|
-
run.current = (manual = false) => {
|
|
54
|
-
const current = latest.current;
|
|
55
|
-
if (!alive.current || current.loading || !current.pagination?.hasMore || current.pagination.loading || gate.pending) return;
|
|
56
|
-
if (!manual && (gate.error || current.pagination.error || gate.automaticLoads >= 3)) return;
|
|
57
|
-
const promise = gate.load(current.pagination, manual);
|
|
58
|
-
refresh();
|
|
59
|
-
void promise.finally(() => { if (alive.current) refresh(); });
|
|
60
|
-
};
|
|
61
|
-
const check = useCallback(() => {
|
|
62
|
-
const element = sentinel.current;
|
|
63
|
-
if (!element) return;
|
|
64
|
-
const bounds = element.getBoundingClientRect();
|
|
65
|
-
const viewport = root.current?.getBoundingClientRect();
|
|
66
|
-
const visibleTop = Math.max(0, viewport?.top ?? 0);
|
|
67
|
-
const visibleBottom = Math.min(window.innerHeight, viewport?.bottom ?? window.innerHeight);
|
|
68
|
-
if (visibleBottom > visibleTop && bounds.top <= visibleBottom + 200 && bounds.bottom >= visibleTop)
|
|
69
|
-
run.current();
|
|
70
|
-
}, []);
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
alive.current = true;
|
|
73
|
-
const element = sentinel.current;
|
|
74
|
-
if (!element) return;
|
|
75
|
-
root.current = getScrollContainer ? getScrollContainer() : nearestScrollContainer(element);
|
|
76
|
-
const container = root.current ?? window;
|
|
77
|
-
const observer = new IntersectionObserver(() => check(), { root: root.current, rootMargin: '0px 0px 200px 0px' });
|
|
78
|
-
observer.observe(element);
|
|
79
|
-
const resize = new ResizeObserver(check);
|
|
80
|
-
resize.observe(root.current ?? document.documentElement);
|
|
81
|
-
const resume = (event: Event) => {
|
|
82
|
-
if (event instanceof WheelEvent && event.deltaY > 0) gate.automaticLoads = 0;
|
|
83
|
-
if (event instanceof KeyboardEvent && ['PageDown', 'End', 'ArrowDown'].includes(event.key)) gate.automaticLoads = 0;
|
|
84
|
-
check();
|
|
85
|
-
};
|
|
86
|
-
container.addEventListener('scroll', check, { passive: true });
|
|
87
|
-
if (root.current) window.addEventListener('scroll', check, { passive: true });
|
|
88
|
-
container.addEventListener('wheel', resume, { passive: true });
|
|
89
|
-
container.addEventListener('keydown', resume);
|
|
90
|
-
return () => {
|
|
91
|
-
alive.current = false;
|
|
92
|
-
observer.disconnect(); resize.disconnect();
|
|
93
|
-
container.removeEventListener('scroll', check);
|
|
94
|
-
window.removeEventListener('scroll', check);
|
|
95
|
-
container.removeEventListener('wheel', resume);
|
|
96
|
-
container.removeEventListener('keydown', resume);
|
|
97
|
-
};
|
|
98
|
-
}, [check, gate, getScrollContainer]);
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
const frame = requestAnimationFrame(check);
|
|
101
|
-
return () => cancelAnimationFrame(frame);
|
|
102
|
-
});
|
|
103
|
-
const error = pagination?.hasMore && (gate.error || pagination.error);
|
|
104
|
-
const pending = gate.pending || pagination?.loading;
|
|
105
|
-
return <div ref={sentinel} className={pagination?.hasMore ? 'replica-panel-pagination' : undefined} aria-live="polite">
|
|
106
|
-
{pagination?.hasMore && (error ? <><span role="alert">{error}</span><button type="button" disabled={pending} onClick={() => run.current(true)}>重试加载</button></>
|
|
107
|
-
: pending ? <LoaderCircle className="size-4 animate-spin text-primary" aria-label="正在加载更多评论" />
|
|
108
|
-
: pagination?.hasMore ? <button type="button" disabled={loading} onClick={() => run.current(true)}>继续加载</button>
|
|
109
|
-
: null)}
|
|
110
|
-
</div>;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function PanelSession(props: CommentsProviderProps) {
|
|
114
|
-
const latest = useRef(props);
|
|
115
|
-
latest.current = props;
|
|
116
|
-
const locks = useRef(new Set<string>());
|
|
117
|
-
const [submitted, setSubmitted] = useState<string>();
|
|
118
|
-
const [mutations] = useState(() => new CommentMutations());
|
|
119
|
-
useSyncExternalStore(mutations.subscribe, mutations.getVersion, mutations.getVersion);
|
|
120
|
-
const base = buildPanelThreads(props);
|
|
121
|
-
const comments = mutations.view(base);
|
|
122
|
-
useEffect(() => {
|
|
123
|
-
mutations.activate();
|
|
124
|
-
return () => mutations.dispose();
|
|
125
|
-
}, [mutations]);
|
|
126
|
-
useEffect(() => { mutations.reconcile(base); }, [base, mutations]);
|
|
127
|
-
async function locked<T>(key: string, action: () => Promise<T>): Promise<T> {
|
|
128
|
-
if (locks.current.has(key)) throw new Error('该评论正在处理中,请稍后重试');
|
|
129
|
-
locks.current.add(key);
|
|
130
|
-
try { return await action(); }
|
|
131
|
-
finally { locks.current.delete(key); }
|
|
132
|
-
}
|
|
133
|
-
const allowCompose = Boolean(props.onAddComment || props.images?.onAddComment);
|
|
134
|
-
const currentUser = props.currentUserID == null ? undefined
|
|
135
|
-
: { kind: 'user' as const, idType: 'apaas-user' as const, id: String(props.currentUserID) };
|
|
136
|
-
return <DiscussionProvider panel={{
|
|
137
|
-
source: props.mentionSource,
|
|
138
|
-
currentUser,
|
|
139
|
-
comments,
|
|
140
|
-
allowCompose,
|
|
141
|
-
optimisticMutations: true,
|
|
142
|
-
onRetryMutation: (id) => mutations.retry(id),
|
|
143
|
-
showReactions: Boolean(props.reactions),
|
|
144
|
-
allowReact: Boolean(props.reactions?.onChange),
|
|
145
|
-
enableImages: Boolean(props.images?.onAddComment),
|
|
146
|
-
enableEditImages: Boolean(props.images?.onUpdateComment),
|
|
147
|
-
enablePrivateComments: false,
|
|
148
|
-
enableDocuments: props.enableDocuments ?? false,
|
|
149
|
-
autoScrollNewReplies: false,
|
|
150
|
-
emptyState: null,
|
|
151
|
-
submittedReplyID: submitted ? mutations.replyID(submitted) : undefined,
|
|
152
|
-
onScrolledToReply: () => setSubmitted(undefined),
|
|
153
|
-
listHeader: <ReadStatus loading={props.loading} error={props.error} onRetry={props.onRetry}
|
|
154
|
-
empty={!comments.some((thread) => thread.replies.length > 0)} hasMore={props.pagination?.hasMore} />,
|
|
155
|
-
listFooter: props.pagination && <PaginationFooter pagination={props.pagination}
|
|
156
|
-
loading={props.loading || Boolean(props.error)} getScrollContainer={props.getScrollContainer} />,
|
|
157
|
-
onMentionClick: props.onMentionClick,
|
|
158
|
-
getCommentLink: (commentID, reply) => props.getCommentLink?.({ recordID: props.recordID, commentID, replyID: reply.replyID }) ?? '',
|
|
159
|
-
onSubmit: (draft) => {
|
|
160
|
-
const current = latest.current;
|
|
161
|
-
preparePanelComment(current, draft); // Validate before accepting/clearing the draft.
|
|
162
|
-
const thread = base.find((item) => item.commentID === draft.commentID);
|
|
163
|
-
const id = mutations.add(thread, draft, currentUser, () => preparePanelComment(latest.current, draft)());
|
|
164
|
-
setSubmitted(id);
|
|
165
|
-
},
|
|
166
|
-
onEdit: (commentID, replyID, value, images) => {
|
|
167
|
-
const draft = { value, images, private: false };
|
|
168
|
-
preparePanelEdit(latest.current, commentID, replyID, draft);
|
|
169
|
-
const thread = base.find((item) => item.commentID === commentID)!;
|
|
170
|
-
mutations.edit(thread, replyID, draft, () => preparePanelEdit(latest.current, commentID, replyID, draft)());
|
|
171
|
-
},
|
|
172
|
-
onDelete: (commentID, replyID) => {
|
|
173
|
-
if (!replyID) throw new Error('未选择要删除的评论');
|
|
174
|
-
preparePanelDeletion(latest.current, commentID, replyID);
|
|
175
|
-
const thread = base.find((item) => item.commentID === commentID)!;
|
|
176
|
-
mutations.delete(thread, replyID, () => preparePanelDeletion(latest.current, commentID, replyID)());
|
|
177
|
-
},
|
|
178
|
-
onToggleReaction: async (commentID, replyID, emojiKey) => {
|
|
179
|
-
await locked(replyID, async () => {
|
|
180
|
-
const current = latest.current;
|
|
181
|
-
const feature = current.reactions;
|
|
182
|
-
if (!feature?.onChange) throw new Error('未提供回应写入能力');
|
|
183
|
-
const target: CommentTarget = { recordID: current.recordID, commentID, replyID };
|
|
184
|
-
if (feature.canChange?.(target) === false) throw new Error('表情状态尚未加载或当前身份不可用');
|
|
185
|
-
const existing = feature.getReactions(target).find((reaction) => reaction.emojiKey === emojiKey ||
|
|
186
|
-
(emojiKey === 'THUMBSUP' && reaction.emojiKey === 'MediumThumbsup'));
|
|
187
|
-
await feature.onChange({ ...target, emojiKey: existing?.emojiKey ?? emojiKey, action: existing?.reactedByMe ? 'remove' : 'add' });
|
|
188
|
-
});
|
|
189
|
-
},
|
|
190
|
-
loadReactionDetails: props.reactions?.loadUsers ? (commentID, replyID, signal) => loadPanelReactionDetails(
|
|
191
|
-
latest.current, { recordID: props.recordID, commentID, replyID }, signal,
|
|
192
|
-
) : undefined,
|
|
193
|
-
}}>{props.children}</DiscussionProvider>;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Shared record session, with no layout DOM and no persistence implementation. */
|
|
197
|
-
export function CommentsProvider(props: CommentsProviderProps) {
|
|
198
|
-
const features = useMemo(() => commentExtensionFeatures(props.extensions), [props.extensions]);
|
|
199
|
-
const mentionSource = useMemo(() => features.documents
|
|
200
|
-
? withDocumentSource(props.mentionSource, features.documents) : props.mentionSource,
|
|
201
|
-
[props.mentionSource, features.documents]);
|
|
202
|
-
return <PanelSession key={JSON.stringify([props.scopeKey, props.recordID])} {...props}
|
|
203
|
-
mentionSource={mentionSource} reactions={features.reactions ?? props.reactions}
|
|
204
|
-
images={features.images ?? props.images} enableDocuments={Boolean(features.documents) || props.enableDocuments} />;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** The theme is scoped to this leaf; no panel heading, card or fixed height. */
|
|
208
|
-
export const CommentList = forwardRef<HTMLDivElement, CommentListProps>(function CommentList(
|
|
209
|
-
{ className = '', style, ...props }, ref,
|
|
210
|
-
) {
|
|
211
|
-
return <div {...props} ref={ref} data-slot="comment-list"
|
|
212
|
-
className={`${COMMENT_THEME_CLASS} replica-comment-panel replica-discussion ${className}`} style={{ height: 'auto', ...style }}>
|
|
213
|
-
<DiscussionList style={{ overflowY: 'visible' }} />
|
|
214
|
-
</div>;
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
export const CommentComposer = forwardRef<HTMLDivElement, CommentComposerProps>(function CommentComposer(
|
|
218
|
-
{ placeholder, disabled, className = '', style, ...props }, ref,
|
|
219
|
-
) {
|
|
220
|
-
return <div {...props} ref={ref} data-slot="comment-composer"
|
|
221
|
-
className={`${COMMENT_THEME_CLASS} replica-discussion ${className}`} style={{ height: 'auto', ...style }}>
|
|
222
|
-
<DiscussionComposerView placeholder={placeholder} disabled={disabled} />
|
|
223
|
-
</div>;
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
/** Compatibility composition with the existing heading, list and footer layout. */
|
|
227
|
-
export function CommentPanel({ className, style, ...props }: CommentPanelProps) {
|
|
228
|
-
return <CommentsProvider {...props}>
|
|
229
|
-
<div className={`replica-comment-panel ${className ?? ''}`} style={style}><DiscussionLayout /></div>
|
|
230
|
-
</CommentsProvider>;
|
|
231
|
-
}
|