@lark-apaas/coding-template-nestjs-react-fullstack 0.1.53 → 0.1.55-alpha.20261030035108
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 +10 -0
- package/template/client/src/components/business-ui/api/user-profiles/queries.ts +1 -7
- package/template/client/src/components/business-ui/comment/README.md +139 -0
- package/template/client/src/components/business-ui/comment/app-source.ts +175 -0
- package/template/client/src/components/business-ui/comment/ccm-document-source.ts +252 -0
- package/template/client/src/components/business-ui/comment/comment-content.tsx +235 -0
- package/template/client/src/components/business-ui/comment/comment-input.tsx +191 -0
- package/template/client/src/components/business-ui/comment/comment-provider.tsx +40 -0
- package/template/client/src/components/business-ui/comment/content.ts +329 -0
- package/template/client/src/components/business-ui/comment/document-icon.tsx +104 -0
- package/template/client/src/components/business-ui/comment/document-icons.ts +19 -0
- package/template/client/src/components/business-ui/comment/dom-editor.ts +581 -0
- package/template/client/src/components/business-ui/comment/editor.tsx +496 -0
- package/template/client/src/components/business-ui/comment/entity-store.ts +97 -0
- package/template/client/src/components/business-ui/comment/image-attachments.tsx +374 -0
- package/template/client/src/components/business-ui/comment/mention-appearance.ts +35 -0
- package/template/client/src/components/business-ui/comment/mention-provider.tsx +73 -0
- package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +312 -0
- package/template/client/src/components/business-ui/comment/types.ts +154 -0
- package/template/client/src/components/business-ui/comment/use-mention-search.ts +107 -0
- package/template/client/src/components/business-ui/user-display/user-display.tsx +0 -13
- package/template/client/src/components/business-ui/user-profile/user-profile.tsx +146 -397
- package/template/client/src/components/business-ui/user-select/user-pill.tsx +0 -13
- package/template/client/src/hooks/use-example.ts +45 -36
- package/template/client/src/pages/ExamplePage/ExamplePage.tsx +7 -8
- package/template/client/src/tailwind-theme.css +5 -6
- package/template/eslint.config.js +0 -6
- package/template/package-lock.json +83 -62
- package/template/package.json +5 -4
- package/template/server/modules/hello/hello.controller.ts +4 -8
- package/template/server/modules/hello/hello.service.ts +3 -5
- package/template/shared/api.interface.ts +1 -8
- package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +0 -217
- package/template/client/src/lib/api-error.ts +0 -36
- package/template/server/modules/hello/hello.dto.ts +0 -21
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { ImagePlus, LoaderCircle, RotateCcw, X } from 'lucide-react';
|
|
4
|
+
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|
5
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
6
|
+
import { safeUrl } from './content';
|
|
7
|
+
import type {
|
|
8
|
+
CommentImage,
|
|
9
|
+
CommentImageUploader,
|
|
10
|
+
CommentInputProps,
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
export const IMAGE_ACCEPT =
|
|
14
|
+
'image/png,image/jpeg,image/webp,image/gif,image/avif,image/bmp';
|
|
15
|
+
const imageTypes = new Set(IMAGE_ACCEPT.split(','));
|
|
16
|
+
|
|
17
|
+
const defaultUpload: CommentImageUploader = async (file, { signal }) => {
|
|
18
|
+
signal.throwIfAborted();
|
|
19
|
+
const { uploadFile } = await import('../api/files/service');
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
const result = await uploadFile(file);
|
|
22
|
+
// The storage SDK does not support cancellation; deleted/session-stale results are ignored.
|
|
23
|
+
signal.throwIfAborted();
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function useCommentImages(props: CommentInputProps) {
|
|
28
|
+
const latest = useRef(props);
|
|
29
|
+
latest.current = props;
|
|
30
|
+
const [images, setImages] = useState<CommentImage[]>([]);
|
|
31
|
+
const [error, setError] = useState('');
|
|
32
|
+
const state = useRef<CommentImage[]>([]);
|
|
33
|
+
const fileMap = useRef(new Map<string, File>());
|
|
34
|
+
const jobs = useRef(
|
|
35
|
+
new Map<
|
|
36
|
+
string,
|
|
37
|
+
{ file: File; controller: AbortController; running: boolean }
|
|
38
|
+
>(),
|
|
39
|
+
);
|
|
40
|
+
const urls = useRef(new Set<string>());
|
|
41
|
+
const running = useRef(0);
|
|
42
|
+
const alive = useRef(true);
|
|
43
|
+
const publish = (next: CommentImage[]) => {
|
|
44
|
+
state.current = next;
|
|
45
|
+
if (alive.current) {
|
|
46
|
+
setImages(next);
|
|
47
|
+
latest.current.onImagesChange?.(
|
|
48
|
+
next.map((image) => ({
|
|
49
|
+
...image,
|
|
50
|
+
result: image.result && { ...image.result },
|
|
51
|
+
})),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const pump = () => {
|
|
56
|
+
if (!alive.current) return;
|
|
57
|
+
for (const [id, job] of jobs.current) {
|
|
58
|
+
if (running.current >= 3) break;
|
|
59
|
+
if (job.running) continue;
|
|
60
|
+
job.running = true;
|
|
61
|
+
running.current++;
|
|
62
|
+
// Promise wrapping also turns a synchronous custom uploader throw into an error state.
|
|
63
|
+
void Promise.resolve()
|
|
64
|
+
.then(() =>
|
|
65
|
+
(latest.current.uploadImage || defaultUpload)(job.file, {
|
|
66
|
+
signal: job.controller.signal,
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
.then((result) => {
|
|
70
|
+
if (
|
|
71
|
+
!alive.current ||
|
|
72
|
+
job.controller.signal.aborted ||
|
|
73
|
+
jobs.current.get(id) !== job
|
|
74
|
+
)
|
|
75
|
+
return;
|
|
76
|
+
if (!result?.id || !safeUrl(result.url))
|
|
77
|
+
throw new Error('图片上传结果缺少有效的文件标识或地址');
|
|
78
|
+
publish(
|
|
79
|
+
state.current.map((image) =>
|
|
80
|
+
image.id === id
|
|
81
|
+
? {
|
|
82
|
+
...image,
|
|
83
|
+
status: 'uploaded',
|
|
84
|
+
result: { ...result },
|
|
85
|
+
error: undefined,
|
|
86
|
+
}
|
|
87
|
+
: image,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
})
|
|
91
|
+
.catch((cause) => {
|
|
92
|
+
if (
|
|
93
|
+
!alive.current ||
|
|
94
|
+
job.controller.signal.aborted ||
|
|
95
|
+
jobs.current.get(id) !== job
|
|
96
|
+
)
|
|
97
|
+
return;
|
|
98
|
+
publish(
|
|
99
|
+
state.current.map((image) =>
|
|
100
|
+
image.id === id
|
|
101
|
+
? {
|
|
102
|
+
...image,
|
|
103
|
+
status: 'error',
|
|
104
|
+
error:
|
|
105
|
+
cause instanceof Error ? cause.message : '图片上传失败',
|
|
106
|
+
}
|
|
107
|
+
: image,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
})
|
|
111
|
+
.finally(() => {
|
|
112
|
+
running.current--;
|
|
113
|
+
if (jobs.current.get(id) === job) jobs.current.delete(id);
|
|
114
|
+
pump();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const release = (image: CommentImage) => {
|
|
119
|
+
jobs.current.get(image.id)?.controller.abort();
|
|
120
|
+
jobs.current.delete(image.id);
|
|
121
|
+
fileMap.current.delete(image.id);
|
|
122
|
+
URL.revokeObjectURL(image.previewUrl);
|
|
123
|
+
urls.current.delete(image.previewUrl);
|
|
124
|
+
};
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
alive.current = true;
|
|
127
|
+
latest.current.onImagesChange?.([]);
|
|
128
|
+
return () => {
|
|
129
|
+
alive.current = false;
|
|
130
|
+
for (const job of jobs.current.values()) job.controller.abort();
|
|
131
|
+
jobs.current.clear();
|
|
132
|
+
fileMap.current.clear();
|
|
133
|
+
for (const url of urls.current) URL.revokeObjectURL(url);
|
|
134
|
+
urls.current.clear();
|
|
135
|
+
};
|
|
136
|
+
}, []);
|
|
137
|
+
return {
|
|
138
|
+
images,
|
|
139
|
+
error,
|
|
140
|
+
getImages: () =>
|
|
141
|
+
state.current.map((image) => ({
|
|
142
|
+
...image,
|
|
143
|
+
result: image.result && { ...image.result },
|
|
144
|
+
})),
|
|
145
|
+
add(files: File[]) {
|
|
146
|
+
if (!alive.current || !latest.current.enableImages) return;
|
|
147
|
+
const max = latest.current.maxImages ?? 9;
|
|
148
|
+
const maxSize = latest.current.maxImageSize ?? 10 * 1024 * 1024;
|
|
149
|
+
const added: CommentImage[] = [];
|
|
150
|
+
const errors: string[] = [];
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
if (state.current.length + added.length >= max) {
|
|
153
|
+
errors.push(`最多添加 ${max} 张图片`);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (!imageTypes.has(file.type)) {
|
|
157
|
+
errors.push(`${file.name || '图片'}:不支持的图片格式`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!file.size || file.size > maxSize) {
|
|
161
|
+
errors.push(
|
|
162
|
+
`${file.name || '图片'}:图片不能为空且不能超过 ${Math.round(maxSize / 1024 / 1024)} MB`,
|
|
163
|
+
);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const id = crypto.randomUUID();
|
|
167
|
+
const previewUrl = URL.createObjectURL(file);
|
|
168
|
+
urls.current.add(previewUrl);
|
|
169
|
+
added.push({
|
|
170
|
+
id,
|
|
171
|
+
name: file.name || '粘贴的图片',
|
|
172
|
+
size: file.size,
|
|
173
|
+
previewUrl,
|
|
174
|
+
status: 'uploading',
|
|
175
|
+
});
|
|
176
|
+
jobs.current.set(id, {
|
|
177
|
+
file,
|
|
178
|
+
controller: new AbortController(),
|
|
179
|
+
running: false,
|
|
180
|
+
});
|
|
181
|
+
fileMap.current.set(id, file);
|
|
182
|
+
}
|
|
183
|
+
setError([...new Set(errors)].join(';'));
|
|
184
|
+
if (added.length) {
|
|
185
|
+
publish([...state.current, ...added]);
|
|
186
|
+
pump();
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
remove(id: string) {
|
|
190
|
+
const image = state.current.find((item) => item.id === id);
|
|
191
|
+
if (!image) return;
|
|
192
|
+
release(image);
|
|
193
|
+
publish(state.current.filter((item) => item.id !== id));
|
|
194
|
+
setError('');
|
|
195
|
+
pump();
|
|
196
|
+
},
|
|
197
|
+
retry(id: string) {
|
|
198
|
+
// Failed files remain available for explicit retry, without exposing File in public state.
|
|
199
|
+
const image = state.current.find((item) => item.id === id);
|
|
200
|
+
const file = fileMap.current.get(id);
|
|
201
|
+
if (!image || image.status !== 'error' || !file || jobs.current.has(id))
|
|
202
|
+
return;
|
|
203
|
+
jobs.current.set(id, {
|
|
204
|
+
file,
|
|
205
|
+
controller: new AbortController(),
|
|
206
|
+
running: false,
|
|
207
|
+
});
|
|
208
|
+
publish(
|
|
209
|
+
state.current.map((item) =>
|
|
210
|
+
item.id === id
|
|
211
|
+
? { ...item, status: 'uploading', error: undefined }
|
|
212
|
+
: item,
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
pump();
|
|
216
|
+
},
|
|
217
|
+
clear() {
|
|
218
|
+
for (const image of state.current) release(image);
|
|
219
|
+
fileMap.current.clear();
|
|
220
|
+
publish([]);
|
|
221
|
+
setError('');
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function ImageAttachments({
|
|
227
|
+
images,
|
|
228
|
+
disabled,
|
|
229
|
+
remove,
|
|
230
|
+
retry,
|
|
231
|
+
}: {
|
|
232
|
+
images: CommentImage[];
|
|
233
|
+
disabled: boolean;
|
|
234
|
+
remove: (id: string) => void;
|
|
235
|
+
retry: (id: string) => void;
|
|
236
|
+
}) {
|
|
237
|
+
const [previewId, setPreviewId] = useState<string | null>(null);
|
|
238
|
+
const preview = images.find((image) => image.id === previewId);
|
|
239
|
+
return (
|
|
240
|
+
<>
|
|
241
|
+
{images.length ? (
|
|
242
|
+
<div className="my-2 flex flex-wrap gap-1.5" aria-label="评论图片">
|
|
243
|
+
{images.map((image) => (
|
|
244
|
+
<div
|
|
245
|
+
key={image.id}
|
|
246
|
+
className="relative size-16 overflow-hidden rounded-[6px] border border-border bg-muted"
|
|
247
|
+
data-image-status={image.status}
|
|
248
|
+
>
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
className="block size-full"
|
|
252
|
+
aria-label={`预览 ${image.name}`}
|
|
253
|
+
onClick={() => setPreviewId(image.id)}
|
|
254
|
+
>
|
|
255
|
+
<img
|
|
256
|
+
src={image.previewUrl}
|
|
257
|
+
alt={image.name}
|
|
258
|
+
className="size-full object-cover"
|
|
259
|
+
/>
|
|
260
|
+
</button>
|
|
261
|
+
{image.status === 'uploading' ? (
|
|
262
|
+
<span
|
|
263
|
+
role="status"
|
|
264
|
+
aria-label={`${image.name} 正在上传`}
|
|
265
|
+
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/25 text-white"
|
|
266
|
+
>
|
|
267
|
+
<LoaderCircle className="size-5 animate-spin" />
|
|
268
|
+
</span>
|
|
269
|
+
) : null}
|
|
270
|
+
{image.status === 'error' ? (
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
disabled={disabled}
|
|
274
|
+
title={image.error}
|
|
275
|
+
aria-label={`重试上传 ${image.name}`}
|
|
276
|
+
onClick={() => retry(image.id)}
|
|
277
|
+
className="absolute inset-x-0 bottom-0 flex items-center justify-center gap-1 bg-black/65 py-1 text-[10px] text-white"
|
|
278
|
+
>
|
|
279
|
+
<RotateCcw className="size-3" />
|
|
280
|
+
重试
|
|
281
|
+
</button>
|
|
282
|
+
) : null}
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
disabled={disabled}
|
|
286
|
+
aria-label={`删除 ${image.name}`}
|
|
287
|
+
onClick={() => remove(image.id)}
|
|
288
|
+
className="absolute right-0.5 top-0.5 flex size-5 items-center justify-center rounded-full bg-black/45 text-white hover:bg-black/65 disabled:opacity-50"
|
|
289
|
+
>
|
|
290
|
+
<X className="size-4" />
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
) : null}
|
|
296
|
+
<Dialog
|
|
297
|
+
open={Boolean(preview)}
|
|
298
|
+
onOpenChange={(open) => {
|
|
299
|
+
if (!open) setPreviewId(null);
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
<DialogContent className="max-w-3xl">
|
|
303
|
+
<DialogTitle className="sr-only">
|
|
304
|
+
{preview?.name || '图片预览'}
|
|
305
|
+
</DialogTitle>
|
|
306
|
+
{preview ? (
|
|
307
|
+
<img
|
|
308
|
+
src={preview.previewUrl}
|
|
309
|
+
alt={preview.name}
|
|
310
|
+
className="max-h-[80vh] w-full object-contain"
|
|
311
|
+
/>
|
|
312
|
+
) : null}
|
|
313
|
+
</DialogContent>
|
|
314
|
+
</Dialog>
|
|
315
|
+
</>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function AttachmentToolbar({
|
|
320
|
+
props,
|
|
321
|
+
disabled,
|
|
322
|
+
add,
|
|
323
|
+
}: {
|
|
324
|
+
props: CommentInputProps;
|
|
325
|
+
disabled: boolean;
|
|
326
|
+
add: (files: File[]) => void;
|
|
327
|
+
}) {
|
|
328
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
329
|
+
return (
|
|
330
|
+
<div className="mt-2 flex min-h-6 items-center justify-between gap-3">
|
|
331
|
+
{props.onGrantMentionAccessChange ? (
|
|
332
|
+
<label className="flex cursor-pointer items-center gap-1.5 text-sm text-foreground">
|
|
333
|
+
<Checkbox
|
|
334
|
+
disabled={disabled}
|
|
335
|
+
checked={Boolean(props.grantMentionAccess)}
|
|
336
|
+
onCheckedChange={(checked) =>
|
|
337
|
+
props.onGrantMentionAccessChange?.(checked === true)
|
|
338
|
+
}
|
|
339
|
+
/>
|
|
340
|
+
授予提及的人阅读权限
|
|
341
|
+
</label>
|
|
342
|
+
) : (
|
|
343
|
+
<span />
|
|
344
|
+
)}
|
|
345
|
+
{props.enableImages ? (
|
|
346
|
+
<>
|
|
347
|
+
<input
|
|
348
|
+
ref={fileInput}
|
|
349
|
+
type="file"
|
|
350
|
+
multiple
|
|
351
|
+
accept={IMAGE_ACCEPT}
|
|
352
|
+
disabled={disabled}
|
|
353
|
+
className="hidden"
|
|
354
|
+
aria-label="选择评论图片"
|
|
355
|
+
onChange={(event) => {
|
|
356
|
+
add(Array.from(event.currentTarget.files || []));
|
|
357
|
+
event.currentTarget.value = '';
|
|
358
|
+
}}
|
|
359
|
+
/>
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
disabled={disabled}
|
|
363
|
+
title="上传图片"
|
|
364
|
+
aria-label="上传图片"
|
|
365
|
+
className="flex size-6 shrink-0 items-center justify-center rounded text-subtle-foreground hover:bg-accent disabled:opacity-50"
|
|
366
|
+
onClick={() => fileInput.current?.click()}
|
|
367
|
+
>
|
|
368
|
+
<ImagePlus className="size-[18px]" />
|
|
369
|
+
</button>
|
|
370
|
+
</>
|
|
371
|
+
) : null}
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Values from miaoda/design themes.css and comment/utils/comment-at-style.ts. */
|
|
2
|
+
export const COMMENT_THEME_CLASS = [
|
|
3
|
+
'font-sans text-sm leading-[22px]',
|
|
4
|
+
'[--foreground:#1f2329] [--background:#fff] [--card:#fff] [--card-foreground:#1f2329]',
|
|
5
|
+
'[--popover:#fff] [--popover-foreground:#1f2329] [--border:#dee0e3]',
|
|
6
|
+
'[--primary:#1456f0] [--primary-foreground:#fff] [--button-outline:#dee0e3]',
|
|
7
|
+
'[--primary-border:transparent] [--comment-warning:#ff811a] [--muted:#f2f3f5] [--muted-foreground:#8f959e]',
|
|
8
|
+
'[--accent:rgba(31,35,41,0.05)] [--accent-foreground:#1f2329] [--subtle-foreground:#646a73]',
|
|
9
|
+
'[--info:#1456f0] [--info-foreground:#f5f7ff] [--comment-hover:rgba(31,35,41,0.05)]',
|
|
10
|
+
'dark:[--foreground:#f0f0f2] dark:[--background:#141414] dark:[--card:#292929]',
|
|
11
|
+
'dark:[--card-foreground:#f0f0f2] dark:[--popover:#292929] dark:[--popover-foreground:#f0f0f2]',
|
|
12
|
+
'dark:[--border:rgba(255,255,255,0.15)] dark:[--primary:#4c88ff]',
|
|
13
|
+
'dark:[--muted:#292929] dark:[--muted-foreground:#a3a3a3] dark:[--subtle-foreground:#a3a3a3]',
|
|
14
|
+
'dark:[--accent:rgba(255,255,255,0.05)] dark:[--accent-foreground:#f0f0f2]',
|
|
15
|
+
'dark:[--comment-hover:rgba(255,255,255,0.08)]',
|
|
16
|
+
].join(' ');
|
|
17
|
+
|
|
18
|
+
export const USER_MENTION_CLASS = 'inline-block max-w-[220px] px-0.5 align-top';
|
|
19
|
+
export const USER_MENTION_LABEL_CLASS =
|
|
20
|
+
'mt-px block cursor-pointer truncate rounded-[4px] px-0.5 text-primary no-underline leading-5 hover:bg-accent';
|
|
21
|
+
export const USER_MENTION_SELF_CLASS =
|
|
22
|
+
'mt-px block cursor-pointer truncate rounded px-1 bg-[var(--info)] text-[var(--info-foreground)] no-underline leading-5';
|
|
23
|
+
export const DOCUMENT_MENTION_CLASS =
|
|
24
|
+
'mx-0.5 inline cursor-pointer break-words rounded-[2px] p-0.5 text-primary no-underline hover:bg-accent';
|
|
25
|
+
export const DOCUMENT_MENTION_UNREADABLE_CLASS =
|
|
26
|
+
'mx-0.5 inline cursor-pointer break-words rounded-[2px] p-0.5 text-subtle-foreground no-underline hover:bg-muted';
|
|
27
|
+
export function atUserLabel(label: string) {
|
|
28
|
+
return /^\s*@/.test(label) ? label : `@${label}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Editable mentions are blue pills; rounded-full keeps semicircular ends for long names. */
|
|
32
|
+
export const INPUT_USER_MENTION_CLASS =
|
|
33
|
+
'mx-0.5 inline-flex h-5 max-w-[220px] items-center rounded-full bg-primary px-1.5 align-baseline text-primary-foreground leading-5';
|
|
34
|
+
export const INPUT_USER_MENTION_LABEL_CLASS =
|
|
35
|
+
'min-w-0 truncate leading-5 text-inherit';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import { EntityStore } from './entity-store';
|
|
11
|
+
import { collectMentionRefs, mentionKey } from './content';
|
|
12
|
+
import type { CommentDataSource, CommentValue, MentionRef } from './types';
|
|
13
|
+
|
|
14
|
+
const Context = createContext<{
|
|
15
|
+
store: EntityStore;
|
|
16
|
+
strict: boolean;
|
|
17
|
+
currentUserID?: string;
|
|
18
|
+
} | null>(null);
|
|
19
|
+
|
|
20
|
+
export function MentionProvider({
|
|
21
|
+
source,
|
|
22
|
+
snapshotPolicy = 'immediate',
|
|
23
|
+
currentUserID,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
source: CommentDataSource;
|
|
27
|
+
currentUserID?: string;
|
|
28
|
+
snapshotPolicy?: 'immediate' | 'after-resolve';
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}) {
|
|
31
|
+
const store = useMemo(
|
|
32
|
+
() => new EntityStore(source),
|
|
33
|
+
[source, source.scopeKey],
|
|
34
|
+
);
|
|
35
|
+
useEffect(() => () => store.clear(), [store]);
|
|
36
|
+
const context = useMemo(
|
|
37
|
+
() => ({
|
|
38
|
+
store,
|
|
39
|
+
strict: snapshotPolicy === 'after-resolve',
|
|
40
|
+
currentUserID,
|
|
41
|
+
}),
|
|
42
|
+
[store, snapshotPolicy, currentUserID],
|
|
43
|
+
);
|
|
44
|
+
return React.createElement(Context.Provider, { value: context }, children);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useMentionContext() {
|
|
48
|
+
const context = useContext(Context);
|
|
49
|
+
if (!context) throw new Error('评论组件需要放在 MentionProvider 中');
|
|
50
|
+
return context;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useMentionEntity(ref: MentionRef) {
|
|
54
|
+
const { store } = useMentionContext();
|
|
55
|
+
const key = mentionKey(ref);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
store.ensure([ref]);
|
|
58
|
+
}, [store, key]);
|
|
59
|
+
return useSyncExternalStore(
|
|
60
|
+
store.subscribe,
|
|
61
|
+
() => store.peek(ref),
|
|
62
|
+
() => undefined,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useResolveComment(value: CommentValue) {
|
|
67
|
+
const { store } = useMentionContext();
|
|
68
|
+
const refs = collectMentionRefs(value);
|
|
69
|
+
const keys = JSON.stringify(refs.map(mentionKey));
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
store.ensure(refs);
|
|
72
|
+
}, [store, keys]);
|
|
73
|
+
}
|