@lark-apaas/coding-template-nestjs-react-fullstack 0.1.59 → 0.1.60-alpha.20261105085132
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/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 +129 -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 +517 -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 +170 -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 +84 -0
- package/template/client/src/components/business-ui/record-comments/types.ts +118 -0
- package/template/package-lock.json +1449 -216
- package/template/package.json +16 -5
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import type { CommentDataSource, MentionKind, MentionOption } from '../types';
|
|
2
|
+
import { mentionKey, safeUrl, COMMENT_THEME_CLASS } from './model';
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { useEffect, useRef, useState, useLayoutEffect } from 'react';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
7
|
+
import { DocumentIcon } from './icons';
|
|
8
|
+
import { LoaderCircle } from 'lucide-react';
|
|
9
|
+
import { autoUpdate } from '@floating-ui/dom';
|
|
10
|
+
import { createPortal } from 'react-dom';
|
|
11
|
+
|
|
12
|
+
interface Result {
|
|
13
|
+
key: string;
|
|
14
|
+
sessionKey: string;
|
|
15
|
+
painted: boolean;
|
|
16
|
+
items: MentionOption[];
|
|
17
|
+
nextCursor?: string;
|
|
18
|
+
loading: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
export function useMentionSearch(
|
|
22
|
+
source: CommentDataSource,
|
|
23
|
+
session: { id: number; query: string } | null,
|
|
24
|
+
kind: MentionKind,
|
|
25
|
+
) {
|
|
26
|
+
const key = JSON.stringify([
|
|
27
|
+
source.scopeKey,
|
|
28
|
+
session?.id,
|
|
29
|
+
session?.query,
|
|
30
|
+
kind,
|
|
31
|
+
]);
|
|
32
|
+
const sessionKey = JSON.stringify([source.scopeKey, session?.id, kind]);
|
|
33
|
+
const [state, setState] = useState<Result>({
|
|
34
|
+
key: '',
|
|
35
|
+
sessionKey: '',
|
|
36
|
+
painted: false,
|
|
37
|
+
items: [],
|
|
38
|
+
loading: false,
|
|
39
|
+
});
|
|
40
|
+
const controller = useRef<AbortController | null>(null);
|
|
41
|
+
const currentKey = useRef(key);
|
|
42
|
+
currentKey.current = key;
|
|
43
|
+
const runRef = useRef<(cursor?: string) => void>(() => {});
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const abort = new AbortController();
|
|
46
|
+
controller.current = abort;
|
|
47
|
+
let pending = false;
|
|
48
|
+
const run = async (cursor?: string) => {
|
|
49
|
+
if (!session || pending) return;
|
|
50
|
+
pending = true;
|
|
51
|
+
setState((previous) => ({
|
|
52
|
+
key,
|
|
53
|
+
sessionKey,
|
|
54
|
+
painted: previous.sessionKey === sessionKey && previous.painted,
|
|
55
|
+
items: previous.sessionKey === sessionKey ? previous.items : [],
|
|
56
|
+
loading: true,
|
|
57
|
+
}));
|
|
58
|
+
try {
|
|
59
|
+
const args = { kind, cursor, signal: abort.signal };
|
|
60
|
+
const result =
|
|
61
|
+
!session.query && source.recommend
|
|
62
|
+
? await source.recommend(args)
|
|
63
|
+
: await source.search({ ...args, query: session.query });
|
|
64
|
+
if (abort.signal.aborted || currentKey.current !== key) return;
|
|
65
|
+
setState((previous) => {
|
|
66
|
+
const unique = new Map(
|
|
67
|
+
(cursor ? previous.items : []).map((item) => [
|
|
68
|
+
mentionKey(item.ref),
|
|
69
|
+
item,
|
|
70
|
+
]),
|
|
71
|
+
);
|
|
72
|
+
for (const item of result.items)
|
|
73
|
+
unique.set(mentionKey(item.ref), item);
|
|
74
|
+
return {
|
|
75
|
+
key,
|
|
76
|
+
sessionKey,
|
|
77
|
+
painted: true,
|
|
78
|
+
items: [...unique.values()],
|
|
79
|
+
nextCursor: result.nextCursor,
|
|
80
|
+
loading: false,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (!abort.signal.aborted && currentKey.current === key)
|
|
85
|
+
setState((previous) => ({
|
|
86
|
+
...previous,
|
|
87
|
+
key,
|
|
88
|
+
sessionKey,
|
|
89
|
+
painted: true,
|
|
90
|
+
items: cursor ? previous.items : [],
|
|
91
|
+
loading: false,
|
|
92
|
+
error: error instanceof Error ? error.message : '搜索失败,请重试',
|
|
93
|
+
nextCursor: cursor,
|
|
94
|
+
}));
|
|
95
|
+
} finally {
|
|
96
|
+
pending = false;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
runRef.current = (cursor) => {
|
|
100
|
+
void run(cursor);
|
|
101
|
+
};
|
|
102
|
+
const timer = session
|
|
103
|
+
? setTimeout(
|
|
104
|
+
() => {
|
|
105
|
+
void run();
|
|
106
|
+
},
|
|
107
|
+
session.query ? 200 : 0,
|
|
108
|
+
)
|
|
109
|
+
: undefined;
|
|
110
|
+
return () => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
abort.abort();
|
|
113
|
+
};
|
|
114
|
+
}, [source, key, sessionKey]);
|
|
115
|
+
const result =
|
|
116
|
+
state.key === key
|
|
117
|
+
? state
|
|
118
|
+
: {
|
|
119
|
+
key,
|
|
120
|
+
sessionKey,
|
|
121
|
+
painted: state.sessionKey === sessionKey && state.painted,
|
|
122
|
+
items: state.sessionKey === sessionKey ? state.items : [],
|
|
123
|
+
loading: Boolean(session),
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
...result,
|
|
127
|
+
initialLoading: result.loading && !result.painted,
|
|
128
|
+
loadMore: () => runRef.current(result.nextCursor),
|
|
129
|
+
retry: () => runRef.current(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function HighlightedLabel({ label, query }: { label: string; query: string }) {
|
|
134
|
+
if (!query) return <>{label}</>;
|
|
135
|
+
const parts: React.ReactNode[] = [];
|
|
136
|
+
let start = 0;
|
|
137
|
+
const lower = label.toLocaleLowerCase();
|
|
138
|
+
const needle = query.toLocaleLowerCase();
|
|
139
|
+
for (
|
|
140
|
+
let index = lower.indexOf(needle);
|
|
141
|
+
index >= 0;
|
|
142
|
+
index = lower.indexOf(needle, start)
|
|
143
|
+
) {
|
|
144
|
+
parts.push(label.slice(start, index));
|
|
145
|
+
parts.push(
|
|
146
|
+
<span key={index} className="text-primary">
|
|
147
|
+
{label.slice(index, index + query.length)}
|
|
148
|
+
</span>,
|
|
149
|
+
);
|
|
150
|
+
start = index + query.length;
|
|
151
|
+
}
|
|
152
|
+
parts.push(label.slice(start));
|
|
153
|
+
return <>{parts}</>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type Props = {
|
|
157
|
+
container?: HTMLElement;
|
|
158
|
+
anchor: () => DOMRect | null;
|
|
159
|
+
editorElement: HTMLElement;
|
|
160
|
+
id: string;
|
|
161
|
+
items: MentionOption[];
|
|
162
|
+
selected: number;
|
|
163
|
+
query?: string;
|
|
164
|
+
onSelect: (item: MentionOption) => void;
|
|
165
|
+
onHover: (index: number) => void;
|
|
166
|
+
loading: boolean;
|
|
167
|
+
initialLoading?: boolean;
|
|
168
|
+
error?: string;
|
|
169
|
+
retry: () => void;
|
|
170
|
+
loadMore: () => void;
|
|
171
|
+
hasMore: boolean;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** UD SkeletonMention: 4 × 280/40 SVGs, 32px circle, 70/200 × 14 text, left 16px. */
|
|
175
|
+
function MentionSkeleton() {
|
|
176
|
+
const gradient = React.useId().replace(/:/g, '');
|
|
177
|
+
return (
|
|
178
|
+
<div
|
|
179
|
+
className="replica-comment-mention-skeleton"
|
|
180
|
+
role="status"
|
|
181
|
+
aria-label="正在加载推荐人员"
|
|
182
|
+
>
|
|
183
|
+
{[0, 1, 2, 3].map((row) => (
|
|
184
|
+
<svg
|
|
185
|
+
key={row}
|
|
186
|
+
width="280"
|
|
187
|
+
height="40"
|
|
188
|
+
viewBox="0 0 280 40"
|
|
189
|
+
aria-hidden="true"
|
|
190
|
+
>
|
|
191
|
+
<defs>
|
|
192
|
+
<linearGradient id={`${gradient}-${row}`}>
|
|
193
|
+
<stop offset="0%" stopColor="var(--muted)" />
|
|
194
|
+
<stop offset="50%" stopColor="var(--muted)" stopOpacity=".7" />
|
|
195
|
+
<stop offset="100%" stopColor="var(--muted)" />
|
|
196
|
+
</linearGradient>
|
|
197
|
+
</defs>
|
|
198
|
+
<g fill={`url(#${gradient}-${row})`}>
|
|
199
|
+
<circle cx="16" cy="16" r="16" />
|
|
200
|
+
<rect x="40" y="0" rx="6" width="70" height="14" />
|
|
201
|
+
<rect x="40" y="20" rx="6" width="200" height="14" />
|
|
202
|
+
</g>
|
|
203
|
+
</svg>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Source: miaoda biz-mention/position + UD MentionPanel and OptionItem less. */
|
|
210
|
+
export function MentionSuggestions({
|
|
211
|
+
container: providedContainer,
|
|
212
|
+
anchor,
|
|
213
|
+
editorElement,
|
|
214
|
+
id,
|
|
215
|
+
items,
|
|
216
|
+
selected,
|
|
217
|
+
query = '',
|
|
218
|
+
onSelect,
|
|
219
|
+
onHover,
|
|
220
|
+
loading,
|
|
221
|
+
initialLoading: loadingInitial,
|
|
222
|
+
error,
|
|
223
|
+
retry,
|
|
224
|
+
loadMore,
|
|
225
|
+
hasMore,
|
|
226
|
+
}: Props) {
|
|
227
|
+
// Stay inside the editor's modal boundary so its focus scope and pointer
|
|
228
|
+
// guard treat a candidate click as an interaction within that modal.
|
|
229
|
+
const container =
|
|
230
|
+
providedContainer ||
|
|
231
|
+
editorElement.closest<HTMLElement>('[role="dialog"], [role="alertdialog"]') ||
|
|
232
|
+
document.body;
|
|
233
|
+
const panel = React.useRef<HTMLDivElement>(null);
|
|
234
|
+
const list = React.useRef<HTMLDivElement>(null);
|
|
235
|
+
const initialLoading = loadingInitial ?? (loading && !items.length);
|
|
236
|
+
const [showMask, setShowMask] = React.useState(false);
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
setShowMask(false);
|
|
239
|
+
if (!loading || initialLoading) return;
|
|
240
|
+
const timer = setTimeout(() => setShowMask(true), 300);
|
|
241
|
+
return () => clearTimeout(timer);
|
|
242
|
+
}, [loading, initialLoading, query]);
|
|
243
|
+
useLayoutEffect(() => {
|
|
244
|
+
const element = panel.current;
|
|
245
|
+
if (!element) return;
|
|
246
|
+
const reference = {
|
|
247
|
+
contextElement: editorElement,
|
|
248
|
+
getBoundingClientRect: () =>
|
|
249
|
+
anchor() || editorElement.getBoundingClientRect(),
|
|
250
|
+
};
|
|
251
|
+
const update = () => {
|
|
252
|
+
const rect = reference.getBoundingClientRect();
|
|
253
|
+
const viewport = document.documentElement;
|
|
254
|
+
const screenWidth = viewport.clientWidth || window.innerWidth;
|
|
255
|
+
const screenHeight = viewport.clientHeight || window.innerHeight;
|
|
256
|
+
const bounds =
|
|
257
|
+
container && container !== document.body
|
|
258
|
+
? container.getBoundingClientRect()
|
|
259
|
+
: null;
|
|
260
|
+
const topEdge = Math.max(0, bounds?.top || 0);
|
|
261
|
+
const bottomEdge = Math.min(screenHeight, bounds?.bottom ?? screenHeight);
|
|
262
|
+
const leftEdge = Math.max(0, bounds?.left || 0);
|
|
263
|
+
const rightEdge = Math.min(screenWidth, bounds?.right ?? screenWidth);
|
|
264
|
+
const below = bottomEdge - rect.bottom;
|
|
265
|
+
const above = rect.top - topEdge;
|
|
266
|
+
const up = below < 431 && (above >= 431 || above > below);
|
|
267
|
+
const maxHeight = Math.min(417, Math.max(0, (up ? above : below) - 14));
|
|
268
|
+
const boxWidth = Math.min(360, Math.max(0, rightEdge - leftEdge - 24));
|
|
269
|
+
element.style.width = `${boxWidth}px`;
|
|
270
|
+
const height = Math.min(
|
|
271
|
+
Math.max(64, (list.current?.scrollHeight || 0) + 2),
|
|
272
|
+
maxHeight,
|
|
273
|
+
);
|
|
274
|
+
const preferLeft =
|
|
275
|
+
rightEdge - rect.left < 372 && rect.right - leftEdge >= 372;
|
|
276
|
+
Object.assign(element.style, {
|
|
277
|
+
left: `${Math.max(leftEdge + 12, Math.min(preferLeft ? rect.right - boxWidth : rect.left, rightEdge - boxWidth - 12))}px`,
|
|
278
|
+
top: `${up ? Math.max(topEdge + 12, rect.top - height - 2) : rect.bottom + 2}px`,
|
|
279
|
+
height: `${height}px`,
|
|
280
|
+
minHeight: `${Math.min(64, maxHeight)}px`,
|
|
281
|
+
visibility: 'visible',
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
const stop = autoUpdate(reference, element, update);
|
|
285
|
+
const observer =
|
|
286
|
+
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
|
|
287
|
+
if (list.current) observer?.observe(list.current);
|
|
288
|
+
return () => {
|
|
289
|
+
stop();
|
|
290
|
+
observer?.disconnect();
|
|
291
|
+
};
|
|
292
|
+
}, [
|
|
293
|
+
anchor,
|
|
294
|
+
editorElement,
|
|
295
|
+
container,
|
|
296
|
+
items,
|
|
297
|
+
initialLoading,
|
|
298
|
+
error,
|
|
299
|
+
hasMore,
|
|
300
|
+
query,
|
|
301
|
+
]);
|
|
302
|
+
useLayoutEffect(() => {
|
|
303
|
+
list.current
|
|
304
|
+
?.querySelector('[aria-selected="true"]')
|
|
305
|
+
?.scrollIntoView({ block: 'nearest' });
|
|
306
|
+
}, [selected, items]);
|
|
307
|
+
return createPortal(
|
|
308
|
+
<div
|
|
309
|
+
ref={panel}
|
|
310
|
+
data-replica-comment-suggestions
|
|
311
|
+
data-loading={loading || undefined}
|
|
312
|
+
className={cn(COMMENT_THEME_CLASS, 'replica-comment-mention-panel')}
|
|
313
|
+
style={{
|
|
314
|
+
visibility: 'hidden',
|
|
315
|
+
width: 360,
|
|
316
|
+
minHeight: 64,
|
|
317
|
+
maxHeight: 417,
|
|
318
|
+
}}
|
|
319
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
320
|
+
onWheel={(event) => event.stopPropagation()}
|
|
321
|
+
>
|
|
322
|
+
<span className="sr-only" role="status">
|
|
323
|
+
{loading ? '正在搜索' : `${items.length} 个结果`}
|
|
324
|
+
</span>
|
|
325
|
+
<div className="replica-comment-mention-list-container">
|
|
326
|
+
<div
|
|
327
|
+
ref={list}
|
|
328
|
+
id={id}
|
|
329
|
+
role="listbox"
|
|
330
|
+
aria-label="提及搜索结果"
|
|
331
|
+
aria-busy={loading}
|
|
332
|
+
className="replica-comment-mention-list"
|
|
333
|
+
onScroll={(event) => {
|
|
334
|
+
const element = event.currentTarget;
|
|
335
|
+
if (
|
|
336
|
+
hasMore &&
|
|
337
|
+
!loading &&
|
|
338
|
+
!error &&
|
|
339
|
+
element.scrollHeight - element.scrollTop - element.clientHeight <
|
|
340
|
+
32
|
|
341
|
+
)
|
|
342
|
+
loadMore();
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
{initialLoading ? (
|
|
346
|
+
<MentionSkeleton />
|
|
347
|
+
) : (
|
|
348
|
+
items.map((item, index) => (
|
|
349
|
+
<div
|
|
350
|
+
key={mentionKey(item.ref)}
|
|
351
|
+
id={`${id}-${index}`}
|
|
352
|
+
role="option"
|
|
353
|
+
aria-selected={selected === index}
|
|
354
|
+
aria-disabled={loading || undefined}
|
|
355
|
+
className="replica-comment-mention-option-container"
|
|
356
|
+
onPointerMove={() => {
|
|
357
|
+
if (!loading && selected !== index) onHover(index);
|
|
358
|
+
}}
|
|
359
|
+
onClick={() => {
|
|
360
|
+
if (!loading) onSelect(item);
|
|
361
|
+
}}
|
|
362
|
+
>
|
|
363
|
+
<div className="replica-comment-mention-option">
|
|
364
|
+
<div className="replica-comment-mention-avatar">
|
|
365
|
+
{item.ref.kind === 'user' ? (
|
|
366
|
+
<Avatar className="size-8 shrink-0">
|
|
367
|
+
<AvatarImage src={safeUrl(item.avatarUrl)} alt="" />
|
|
368
|
+
<AvatarFallback className="bg-primary text-sm text-primary-foreground">
|
|
369
|
+
{Array.from(item.label.trim())[0] || '@'}
|
|
370
|
+
</AvatarFallback>
|
|
371
|
+
</Avatar>
|
|
372
|
+
) : (
|
|
373
|
+
<DocumentIcon
|
|
374
|
+
refValue={item.ref}
|
|
375
|
+
metadata={item.document}
|
|
376
|
+
variant="option"
|
|
377
|
+
/>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
<div className="replica-comment-mention-main">
|
|
381
|
+
<div className="replica-comment-mention-title">
|
|
382
|
+
<span
|
|
383
|
+
className="replica-comment-mention-title-content"
|
|
384
|
+
title={item.label}
|
|
385
|
+
>
|
|
386
|
+
<HighlightedLabel label={item.label} query={query} />
|
|
387
|
+
</span>
|
|
388
|
+
{item.isExternal ? (
|
|
389
|
+
<span className="shrink-0 rounded-[2px] border border-border px-1 text-xs text-[var(--subtle-foreground)]">
|
|
390
|
+
外部
|
|
391
|
+
</span>
|
|
392
|
+
) : null}
|
|
393
|
+
</div>
|
|
394
|
+
{item.description ? (
|
|
395
|
+
<div
|
|
396
|
+
className="replica-comment-mention-subtitle"
|
|
397
|
+
title={item.description}
|
|
398
|
+
>
|
|
399
|
+
<HighlightedLabel
|
|
400
|
+
label={item.description}
|
|
401
|
+
query={query}
|
|
402
|
+
/>
|
|
403
|
+
</div>
|
|
404
|
+
) : null}
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
))
|
|
409
|
+
)}
|
|
410
|
+
{!items.length && !loading && !error ? (
|
|
411
|
+
<div role="status" className="replica-comment-mention-text">
|
|
412
|
+
没有匹配结果,换个关键词试试吧
|
|
413
|
+
</div>
|
|
414
|
+
) : null}
|
|
415
|
+
{error ? (
|
|
416
|
+
<div role="alert" className="replica-comment-mention-text">
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
disabled={loading}
|
|
420
|
+
onClick={retry}
|
|
421
|
+
className="inline text-left"
|
|
422
|
+
title="点击重新加载"
|
|
423
|
+
>
|
|
424
|
+
<svg
|
|
425
|
+
aria-hidden="true"
|
|
426
|
+
viewBox="0 0 24 24"
|
|
427
|
+
className="mr-2 inline-block size-[1em] align-[-0.14em] text-[var(--comment-warning)]"
|
|
428
|
+
>
|
|
429
|
+
<path
|
|
430
|
+
fill="currentColor"
|
|
431
|
+
d="M23 12c0 6.075-4.925 11-11 11S1 18.075 1 12 5.925 1 12 1s11 4.925 11 11ZM12 7a1 1 0 0 0-1 1v5a1 1 0 1 0 2 0V8a1 1 0 0 0-1-1Zm0 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"
|
|
432
|
+
/>
|
|
433
|
+
</svg>
|
|
434
|
+
加载失败,请检查网络后重试
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
) : null}
|
|
438
|
+
{hasMore && !initialLoading && !error ? (
|
|
439
|
+
<span className="sr-only" role="status">
|
|
440
|
+
{loading ? '正在加载…' : '向下滚动加载更多'}
|
|
441
|
+
</span>
|
|
442
|
+
) : null}
|
|
443
|
+
</div>
|
|
444
|
+
{showMask && loading ? (
|
|
445
|
+
<div className="replica-comment-mention-loading-mask">
|
|
446
|
+
<LoaderCircle
|
|
447
|
+
aria-label="正在搜索"
|
|
448
|
+
className="size-5 animate-spin text-primary"
|
|
449
|
+
/>
|
|
450
|
+
</div>
|
|
451
|
+
) : null}
|
|
452
|
+
</div>
|
|
453
|
+
</div>,
|
|
454
|
+
container || document.body,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { CommentReaction } from './reactions';
|
|
2
|
+
import type { CommentDataSource, CommentMentionClickHandler, CommentValue, MentionRef } from '../types';
|
|
3
|
+
import type { LocalCommentMutation } from './mutations';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
export interface DiscussionImage {
|
|
7
|
+
id: string;
|
|
8
|
+
url: string;
|
|
9
|
+
name: string;
|
|
10
|
+
file?: File;
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface DiscussionReply {
|
|
15
|
+
/** Internal UI state; never part of the server comment protocol. */
|
|
16
|
+
mutation?: LocalCommentMutation;
|
|
17
|
+
mutationBlocked?: boolean;
|
|
18
|
+
replyID: string;
|
|
19
|
+
author: MentionRef | null;
|
|
20
|
+
link?: string;
|
|
21
|
+
/** Explicit capabilities supplied by the host/backend, independent of display names. */
|
|
22
|
+
permissions?: { canReact?: boolean; canEdit: boolean; canDelete: boolean; canCopyLink: boolean };
|
|
23
|
+
createTime: number;
|
|
24
|
+
/** Last confirmed edit in this component session, in Unix seconds. */
|
|
25
|
+
updateTime?: number;
|
|
26
|
+
value: CommentValue;
|
|
27
|
+
private?: boolean;
|
|
28
|
+
edited?: boolean;
|
|
29
|
+
images: DiscussionImage[];
|
|
30
|
+
reactions: CommentReaction[];
|
|
31
|
+
}
|
|
32
|
+
export interface DiscussionThread {
|
|
33
|
+
commentID: string;
|
|
34
|
+
creator: MentionRef | null;
|
|
35
|
+
createTime: number;
|
|
36
|
+
isSolved: boolean;
|
|
37
|
+
/** First reply is the comment body; all subsequent replies share this discussion. */
|
|
38
|
+
replies: DiscussionReply[];
|
|
39
|
+
}
|
|
40
|
+
export interface DiscussionDraft {
|
|
41
|
+
commentID?: string;
|
|
42
|
+
value: CommentValue;
|
|
43
|
+
images: DiscussionImage[];
|
|
44
|
+
private: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface DiscussionPanelProps {
|
|
47
|
+
source: CommentDataSource;
|
|
48
|
+
currentUser?: MentionRef;
|
|
49
|
+
/** IDs belonging to the same signed-in person, e.g. their Lark and Miaoda IDs. */
|
|
50
|
+
currentUserAliases?: readonly string[];
|
|
51
|
+
/** Optional reference-viewer highlights when comparing a captured thread. */
|
|
52
|
+
highlightUserIDs?: readonly string[];
|
|
53
|
+
comments: readonly DiscussionThread[];
|
|
54
|
+
onSubmit: (draft: DiscussionDraft) => Promise<void> | void;
|
|
55
|
+
onEdit: (
|
|
56
|
+
commentID: string,
|
|
57
|
+
replyID: string,
|
|
58
|
+
value: CommentValue,
|
|
59
|
+
images: DiscussionImage[],
|
|
60
|
+
) => Promise<void> | void;
|
|
61
|
+
onDelete: (commentID: string, replyID?: string) => Promise<void> | void;
|
|
62
|
+
onToggleReaction: (commentID: string, replyID: string, key: string) => Promise<void> | void;
|
|
63
|
+
/** Optional controlled-panel integration; legacy previews keep their defaults. */
|
|
64
|
+
allowCompose?: boolean;
|
|
65
|
+
optimisticMutations?: boolean;
|
|
66
|
+
onRetryMutation?: (id: string) => void;
|
|
67
|
+
allowReact?: boolean;
|
|
68
|
+
showReactions?: boolean;
|
|
69
|
+
enableImages?: boolean;
|
|
70
|
+
enableEditImages?: boolean;
|
|
71
|
+
enablePrivateComments?: boolean;
|
|
72
|
+
autoScrollNewReplies?: boolean;
|
|
73
|
+
listHeader?: ReactNode;
|
|
74
|
+
listFooter?: ReactNode;
|
|
75
|
+
emptyState?: ReactNode;
|
|
76
|
+
loadReactionDetails?: (commentID: string, replyID: string, signal: AbortSignal) => Promise<readonly CommentReaction[]>;
|
|
77
|
+
onMentionClick?: CommentMentionClickHandler;
|
|
78
|
+
resolveUserRef?: (id: string) => MentionRef;
|
|
79
|
+
getCommentLink?: (commentID: string, reply: DiscussionReply) => string;
|
|
80
|
+
enableDocuments?: boolean;
|
|
81
|
+
className?: string;
|
|
82
|
+
submittedReplyID?: string;
|
|
83
|
+
onScrolledToReply?: () => void;
|
|
84
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
|
|
3
|
+
export type MentionRef =
|
|
4
|
+
| { kind: 'user'; id: string; idType: 'apaas-user' | 'lark-user' | 'snapshot' }
|
|
5
|
+
| { kind: 'document'; id: string; resourceType: string };
|
|
6
|
+
|
|
7
|
+
export interface MentionOption {
|
|
8
|
+
ref: MentionRef;
|
|
9
|
+
label: string;
|
|
10
|
+
avatarUrl?: string;
|
|
11
|
+
href?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
document?: DocumentMentionMetadata;
|
|
14
|
+
isExternal?: boolean;
|
|
15
|
+
/** Known host-document embed permission; a grey link still opens the access-request page. */
|
|
16
|
+
readable?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DocumentMentionMetadata {
|
|
20
|
+
subType?: number;
|
|
21
|
+
iconInfo?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MentionResult =
|
|
25
|
+
| (MentionOption & { status: 'resolved' })
|
|
26
|
+
| { ref: MentionRef; status: 'not-found' | 'forbidden' | 'error' };
|
|
27
|
+
|
|
28
|
+
export type CommentPart =
|
|
29
|
+
| { type: 'text'; text: string }
|
|
30
|
+
| { type: 'break' }
|
|
31
|
+
| { type: 'link'; url: string }
|
|
32
|
+
| { type: 'unresolved-mention' }
|
|
33
|
+
| {
|
|
34
|
+
type: 'mention';
|
|
35
|
+
ref: MentionRef;
|
|
36
|
+
label?: string;
|
|
37
|
+
href?: string;
|
|
38
|
+
document?: DocumentMentionMetadata;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** A host supplies authenticated document search and metadata, independently of user IDs. */
|
|
42
|
+
export type CommentDocumentSource = Pick<
|
|
43
|
+
CommentDataSource,
|
|
44
|
+
'scopeKey' | 'search' | 'resolve' | 'recommend'
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
export interface CommentValue {
|
|
48
|
+
version: 1;
|
|
49
|
+
content: CommentPart[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Confirmed reply.content protocol. Editor metadata is not part of the request. */
|
|
53
|
+
export type CommentApiContent =
|
|
54
|
+
| { type: 'text'; text: string }
|
|
55
|
+
| { type: 'mention'; userID?: number | null }
|
|
56
|
+
| { type: 'link'; url: string };
|
|
57
|
+
|
|
58
|
+
export interface CommentSubmitPayload {
|
|
59
|
+
content: CommentApiContent[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type MentionKind = 'all' | 'user' | 'document';
|
|
63
|
+
|
|
64
|
+
/** Keep this object stable. Change scopeKey when the user/tenant/app/permissions change. */
|
|
65
|
+
export interface CommentDataSource {
|
|
66
|
+
scopeKey: string;
|
|
67
|
+
/** Bare @ default list, independent of keyword search. Failures must reject. */
|
|
68
|
+
recommend?: (args: {
|
|
69
|
+
kind: MentionKind;
|
|
70
|
+
cursor?: string;
|
|
71
|
+
signal: AbortSignal;
|
|
72
|
+
}) => Promise<{ items: MentionOption[]; nextCursor?: string }>;
|
|
73
|
+
/** Legacy sources without recommend can still handle an empty query here. */
|
|
74
|
+
search(args: {
|
|
75
|
+
query: string;
|
|
76
|
+
kind: MentionKind;
|
|
77
|
+
cursor?: string;
|
|
78
|
+
signal: AbortSignal;
|
|
79
|
+
}): Promise<{ items: MentionOption[]; nextCursor?: string }>;
|
|
80
|
+
resolve(refs: MentionRef[], signal: AbortSignal): Promise<MentionResult[]>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CommentInputProps {
|
|
84
|
+
ref?: Ref<CommentInputHandle>;
|
|
85
|
+
documentKey: string;
|
|
86
|
+
value: CommentValue;
|
|
87
|
+
/** Synchronously set the controlled value; debounce persistence, not this callback. */
|
|
88
|
+
onChange: (value: CommentValue) => void;
|
|
89
|
+
/** A keyboard intent only. The host owns requests, buttons, loading and errors. */
|
|
90
|
+
onSubmitRequest?: () => void;
|
|
91
|
+
disabled?: boolean;
|
|
92
|
+
enableDocuments?: boolean;
|
|
93
|
+
placeholder?: string;
|
|
94
|
+
submitMode?: 'enter' | 'mod-enter';
|
|
95
|
+
className?: string;
|
|
96
|
+
/** Mount the suggestion panel inside a Dialog/Sheet when appropriate. */
|
|
97
|
+
portalContainer?: HTMLElement;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CommentInputHandle {
|
|
101
|
+
/** Read the latest wire content. Does not validate whether the host should send. */
|
|
102
|
+
getContent(): CommentApiContent[];
|
|
103
|
+
/** Clear the body as an undoable edit; keep the same editing session. */
|
|
104
|
+
clear(): void;
|
|
105
|
+
setEditable(editable: boolean): void;
|
|
106
|
+
focus(): void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Internal bridge; the public entry stays available before Tiptap has loaded. */
|
|
110
|
+
export interface CommentEditorProps extends Omit<CommentInputProps, 'ref'> {
|
|
111
|
+
editorRef: Ref<CommentInputHandle>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The host decides how to display a profile or navigate a reference. */
|
|
115
|
+
export type CommentMentionClickHandler = (
|
|
116
|
+
ref: MentionRef,
|
|
117
|
+
anchor: HTMLElement,
|
|
118
|
+
) => void;
|