@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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +10 -0
  3. package/template/client/src/components/business-ui/api/user-profiles/queries.ts +1 -7
  4. package/template/client/src/components/business-ui/comment/README.md +139 -0
  5. package/template/client/src/components/business-ui/comment/app-source.ts +175 -0
  6. package/template/client/src/components/business-ui/comment/ccm-document-source.ts +252 -0
  7. package/template/client/src/components/business-ui/comment/comment-content.tsx +235 -0
  8. package/template/client/src/components/business-ui/comment/comment-input.tsx +191 -0
  9. package/template/client/src/components/business-ui/comment/comment-provider.tsx +40 -0
  10. package/template/client/src/components/business-ui/comment/content.ts +329 -0
  11. package/template/client/src/components/business-ui/comment/document-icon.tsx +104 -0
  12. package/template/client/src/components/business-ui/comment/document-icons.ts +19 -0
  13. package/template/client/src/components/business-ui/comment/dom-editor.ts +581 -0
  14. package/template/client/src/components/business-ui/comment/editor.tsx +496 -0
  15. package/template/client/src/components/business-ui/comment/entity-store.ts +97 -0
  16. package/template/client/src/components/business-ui/comment/image-attachments.tsx +374 -0
  17. package/template/client/src/components/business-ui/comment/mention-appearance.ts +35 -0
  18. package/template/client/src/components/business-ui/comment/mention-provider.tsx +73 -0
  19. package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +312 -0
  20. package/template/client/src/components/business-ui/comment/types.ts +154 -0
  21. package/template/client/src/components/business-ui/comment/use-mention-search.ts +107 -0
  22. package/template/client/src/components/business-ui/user-display/user-display.tsx +0 -13
  23. package/template/client/src/components/business-ui/user-profile/user-profile.tsx +146 -397
  24. package/template/client/src/components/business-ui/user-select/user-pill.tsx +0 -13
  25. package/template/client/src/hooks/use-example.ts +45 -36
  26. package/template/client/src/pages/ExamplePage/ExamplePage.tsx +7 -8
  27. package/template/client/src/tailwind-theme.css +5 -6
  28. package/template/eslint.config.js +0 -6
  29. package/template/package-lock.json +83 -62
  30. package/template/package.json +5 -4
  31. package/template/server/modules/hello/hello.controller.ts +4 -8
  32. package/template/server/modules/hello/hello.service.ts +3 -5
  33. package/template/shared/api.interface.ts +1 -8
  34. package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +0 -217
  35. package/template/client/src/lib/api-error.ts +0 -36
  36. package/template/server/modules/hello/hello.dto.ts +0 -21
@@ -0,0 +1,496 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useImperativeHandle,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import * as React from 'react';
10
+ import { cn } from '@/lib/utils';
11
+ import { DivEditor, clipboardText } from './dom-editor';
12
+ import {
13
+ fromApiContent,
14
+ documentSnapshot,
15
+ normalizeValue,
16
+ readApiContent,
17
+ safeUrl,
18
+ toPlainText,
19
+ } from './content';
20
+ import { COMMENT_THEME_CLASS } from './mention-appearance';
21
+ import { useMentionContext } from './mention-provider';
22
+ import { MentionSuggestions } from './mention-suggestions';
23
+ import { useMentionSearch } from './use-mention-search';
24
+ import {
25
+ AttachmentToolbar,
26
+ ImageAttachments,
27
+ useCommentImages,
28
+ } from './image-attachments';
29
+ import type { CommentEditorProps, CommentValue, MentionOption } from './types';
30
+
31
+ type Session = { id: number; from: number; to: number; query: string };
32
+ const MIME = 'application/x-miaoda-comment+json';
33
+
34
+ export default function CommentEditor(props: CommentEditorProps) {
35
+ const { store, strict } = useMentionContext();
36
+ const latest = useRef(props);
37
+ latest.current = props;
38
+ const root = useRef<HTMLDivElement>(null);
39
+ const controller = useRef<DivEditor | null>(null);
40
+ const pendingExternal = useRef<CommentValue | null>(null);
41
+ const handlers = useRef({
42
+ beforeInput: (_event: InputEvent) => {},
43
+ input: (_event: InputEvent) => {},
44
+ refresh: () => {},
45
+ });
46
+ const [empty, setEmpty] = useState(!props.value.content.length);
47
+ const [session, setSession] = useState<Session | null>(null);
48
+ const active = useRef<Session | null>(null);
49
+ const sequence = useRef(0);
50
+ const dismissed = useRef('');
51
+ const [selected, setSelected] = useState(0);
52
+ const listId = useId();
53
+ const images = useCommentImages(props);
54
+ const [editable, setEditable] = useState(true);
55
+ const blocked = Boolean(props.disabled || !editable);
56
+ const search = useMentionSearch(
57
+ store.source,
58
+ session,
59
+ props.enableDocuments ? 'all' : 'user',
60
+ );
61
+ const items = search.items.filter(
62
+ (item) => props.enableDocuments || item.ref.kind === 'user',
63
+ );
64
+ const close = () => {
65
+ active.current = null;
66
+ setSession(null);
67
+ };
68
+ const refresh = () => {
69
+ const editor = controller.current;
70
+ if (!editor || blocked || document.activeElement !== root.current) {
71
+ close();
72
+ return;
73
+ }
74
+ const query = editor.query();
75
+ if (!query || JSON.stringify(query) === dismissed.current) {
76
+ close();
77
+ return;
78
+ }
79
+ const old = active.current;
80
+ if (
81
+ old?.from === query.from &&
82
+ old.to === query.to &&
83
+ old.query === query.query
84
+ )
85
+ return;
86
+ if (!old || old.query !== query.query || old.from !== query.from)
87
+ setSelected(0);
88
+ const next = {
89
+ ...query,
90
+ id: old?.from === query.from ? old.id : ++sequence.current,
91
+ };
92
+ active.current = next;
93
+ setSession(next);
94
+ };
95
+ const publish = (value: CommentValue) => {
96
+ setEmpty(!value.content.length);
97
+ latest.current.onChange(value);
98
+ };
99
+ useLayoutEffect(() => {
100
+ const element = root.current!;
101
+ const editor = new DivEditor(
102
+ element,
103
+ latest.current.value,
104
+ store,
105
+ strict,
106
+ publish,
107
+ );
108
+ controller.current = editor;
109
+ const beforeInput = (event: Event) =>
110
+ handlers.current.beforeInput(event as InputEvent);
111
+ const input = (event: Event) => handlers.current.input(event as InputEvent);
112
+ const selection = () => handlers.current.refresh();
113
+ element.addEventListener('beforeinput', beforeInput);
114
+ element.addEventListener('input', input);
115
+ document.addEventListener('selectionchange', selection);
116
+ // Matching the old activation behavior, focus only when the lazy editor mounts.
117
+ editor.focus();
118
+ return () => {
119
+ editor.destroy();
120
+ controller.current = null;
121
+ element.removeEventListener('beforeinput', beforeInput);
122
+ element.removeEventListener('input', input);
123
+ document.removeEventListener('selectionchange', selection);
124
+ };
125
+ }, [store, strict]);
126
+ const incoming = JSON.stringify(normalizeValue(props.value));
127
+ useLayoutEffect(() => {
128
+ const editor = controller.current;
129
+ if (!editor) return;
130
+ if (editor.composing) {
131
+ pendingExternal.current = props.value;
132
+ return;
133
+ }
134
+ editor.external(props.value);
135
+ setEmpty(!editor.value.content.length);
136
+ refresh();
137
+ }, [incoming]);
138
+ useEffect(() => {
139
+ if (blocked) close();
140
+ }, [blocked]);
141
+ useEffect(() => {
142
+ const dismiss = (event: PointerEvent) => {
143
+ const target = event.target as Node;
144
+ const panel = document
145
+ .getElementById(listId)
146
+ ?.closest('[data-comment-suggestions]');
147
+ if (!root.current?.contains(target) && !panel?.contains(target)) close();
148
+ };
149
+ document.addEventListener('pointerdown', dismiss);
150
+ return () => document.removeEventListener('pointerdown', dismiss);
151
+ }, [listId]);
152
+ useImperativeHandle(props.editorRef, () => ({
153
+ getContent: () =>
154
+ readApiContent(controller.current?.read() || latest.current.value),
155
+ getImages: images.getImages,
156
+ clear() {
157
+ controller.current?.clear();
158
+ close();
159
+ },
160
+ clearImages: images.clear,
161
+ setEditable,
162
+ focus() {
163
+ controller.current?.focus();
164
+ },
165
+ }));
166
+
167
+ handlers.current = {
168
+ refresh,
169
+ beforeInput(event) {
170
+ const editor = controller.current;
171
+ if (!editor) return;
172
+ if (blocked) {
173
+ event.preventDefault();
174
+ return;
175
+ }
176
+ if (editor.composing || event.isComposing) return;
177
+ editor.capture();
178
+ if (
179
+ event.inputType === 'historyUndo' ||
180
+ event.inputType === 'historyRedo'
181
+ ) {
182
+ if (event.cancelable) {
183
+ event.preventDefault();
184
+ editor.undo(event.inputType === 'historyRedo');
185
+ refresh();
186
+ }
187
+ } else if (
188
+ ['insertParagraph', 'insertLineBreak'].includes(event.inputType) &&
189
+ event.cancelable
190
+ ) {
191
+ event.preventDefault();
192
+ if (
193
+ event.inputType === 'insertParagraph' &&
194
+ props.submitMode !== 'mod-enter' &&
195
+ props.onSubmitRequest
196
+ )
197
+ props.onSubmitRequest();
198
+ else editor.insert({ version: 1, content: [{ type: 'break' }] });
199
+ refresh();
200
+ } else if (
201
+ [
202
+ 'formatBold',
203
+ 'formatItalic',
204
+ 'formatUnderline',
205
+ 'insertOrderedList',
206
+ 'insertUnorderedList',
207
+ ].includes(event.inputType)
208
+ ) {
209
+ event.preventDefault();
210
+ }
211
+ },
212
+ input(event) {
213
+ const editor = controller.current;
214
+ if (!editor || editor.composing) return;
215
+ if (blocked) {
216
+ editor.repair();
217
+ return;
218
+ }
219
+ if (['historyUndo', 'historyRedo'].includes(event.inputType)) {
220
+ // Some browsers report noncancelable native undo; restore our authoritative model.
221
+ editor.repair();
222
+ editor.undo(event.inputType === 'historyRedo');
223
+ refresh();
224
+ return;
225
+ }
226
+ dismissed.current = '';
227
+ editor.input(event.inputType);
228
+ refresh();
229
+ },
230
+ };
231
+ const choose = (item: MentionOption) => {
232
+ const editor = controller.current;
233
+ const current = editor?.query();
234
+ const expected = active.current;
235
+ if (
236
+ !editor ||
237
+ blocked ||
238
+ !current ||
239
+ !expected ||
240
+ current.from !== expected.from ||
241
+ current.to !== expected.to ||
242
+ current.query !== expected.query
243
+ )
244
+ return;
245
+ store.seed([item]);
246
+ if (item.ref.kind === 'document') store.ensure([item.ref], true);
247
+ editor.replace(
248
+ current.from,
249
+ current.to,
250
+ {
251
+ version: 1,
252
+ content: [
253
+ {
254
+ type: 'mention',
255
+ ref: item.ref,
256
+ label: item.label,
257
+ ...documentSnapshot(item),
258
+ },
259
+ { type: 'text', text: ' ' },
260
+ ],
261
+ },
262
+ 'mention',
263
+ );
264
+ close();
265
+ };
266
+ const copy = (event: React.ClipboardEvent, cut = false) => {
267
+ const editor = controller.current;
268
+ const value = editor?.selected();
269
+ if (!value || (cut && blocked) || editor?.composing) return;
270
+ event.clipboardData.setData(MIME, JSON.stringify(value));
271
+ event.clipboardData.setData('text/plain', toPlainText(value));
272
+ event.preventDefault();
273
+ if (cut) {
274
+ editor?.insert({ version: 1, content: [] });
275
+ refresh();
276
+ }
277
+ };
278
+ const addImages = (files: File[]) => {
279
+ if (!blocked) images.add(files);
280
+ };
281
+ return (
282
+ <div
283
+ className={cn(COMMENT_THEME_CLASS, 'text-foreground', props.className)}
284
+ >
285
+ <div className="flex min-h-[34px] flex-col justify-center rounded-[6px] border-[0.5px] border-border bg-card px-3 py-[5px] transition-colors hover:border-primary focus-within:border-primary">
286
+ <div className="relative w-full cursor-text text-sm">
287
+ <div
288
+ ref={root}
289
+ contentEditable={!blocked}
290
+ suppressContentEditableWarning
291
+ role="textbox"
292
+ aria-label={props.placeholder || '输入评论'}
293
+ aria-multiline="true"
294
+ aria-autocomplete="list"
295
+ aria-haspopup="listbox"
296
+ aria-disabled={blocked}
297
+ aria-expanded={Boolean(session)}
298
+ aria-controls={session ? listId : undefined}
299
+ aria-activedescendant={
300
+ session && items.length ? `${listId}-${selected}` : undefined
301
+ }
302
+ className="relative z-[1] min-h-[22px] max-h-60 overflow-y-auto whitespace-pre-wrap break-words text-sm leading-[22px] outline-none"
303
+ onFocus={refresh}
304
+ onKeyUp={refresh}
305
+ onMouseUp={refresh}
306
+ onCompositionStart={() => {
307
+ controller.current?.startComposition();
308
+ close();
309
+ }}
310
+ onCompositionEnd={() => {
311
+ queueMicrotask(() => {
312
+ const editor = controller.current;
313
+ if (!editor) return;
314
+ if (pendingExternal.current) {
315
+ editor.composing = false;
316
+ editor.external(pendingExternal.current);
317
+ pendingExternal.current = null;
318
+ setEmpty(!editor.value.content.length);
319
+ } else editor.endComposition();
320
+ dismissed.current = '';
321
+ refresh();
322
+ });
323
+ }}
324
+ onMouseDown={(event) => {
325
+ if ((event.target as Element).closest('a'))
326
+ event.preventDefault();
327
+ }}
328
+ onCopy={(event) => copy(event)}
329
+ onCut={(event) => copy(event, true)}
330
+ onDragOver={(event) => event.preventDefault()}
331
+ onDrop={(event) => {
332
+ event.preventDefault();
333
+ if (event.dataTransfer.files.length)
334
+ addImages(Array.from(event.dataTransfer.files));
335
+ }}
336
+ onPaste={(event) => {
337
+ event.preventDefault();
338
+ const editor = controller.current;
339
+ if (!editor || blocked || editor.composing) return;
340
+ const data = event.clipboardData;
341
+ const files = Array.from(data.files);
342
+ if (files.length) addImages(files);
343
+ const encoded = data.getData(MIME);
344
+ if (encoded && encoded.length < 200_000) {
345
+ try {
346
+ const pasted = normalizeValue(JSON.parse(encoded));
347
+ if (
348
+ !props.enableDocuments &&
349
+ pasted.content.some(
350
+ (part) =>
351
+ part.type === 'mention' && part.ref.kind === 'document',
352
+ )
353
+ )
354
+ throw new Error('文档提及未启用');
355
+ editor.insert(pasted);
356
+ refresh();
357
+ return;
358
+ } catch {
359
+ /* Invalid rich clipboard falls back to plain text. */
360
+ }
361
+ }
362
+ const text = clipboardText(data);
363
+ if (text) {
364
+ editor.insert(
365
+ /^https?:\/\/\S+$/.test(text) && safeUrl(text)
366
+ ? props.enableDocuments
367
+ ? fromApiContent([{ type: 'link', url: text }])
368
+ : { version: 1, content: [{ type: 'link', url: text }] }
369
+ : { version: 1, content: [{ type: 'text', text }] },
370
+ );
371
+ refresh();
372
+ }
373
+ }}
374
+ onKeyDown={(event) => {
375
+ const editor = controller.current;
376
+ if (
377
+ !editor ||
378
+ blocked ||
379
+ editor.composing ||
380
+ event.nativeEvent.isComposing ||
381
+ event.keyCode === 229
382
+ )
383
+ return;
384
+ const modified = event.metaKey || event.ctrlKey;
385
+ if (
386
+ modified &&
387
+ ['z', 'y'].includes(event.key.toLowerCase()) &&
388
+ !event.altKey
389
+ ) {
390
+ event.preventDefault();
391
+ editor.undo(event.key.toLowerCase() === 'y' || event.shiftKey);
392
+ refresh();
393
+ return;
394
+ }
395
+ if (
396
+ modified &&
397
+ ['b', 'i', 'u'].includes(event.key.toLowerCase())
398
+ ) {
399
+ event.preventDefault();
400
+ return;
401
+ }
402
+ if (active.current && !event.shiftKey) {
403
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
404
+ event.preventDefault();
405
+ setSelected((index) =>
406
+ items.length
407
+ ? (index +
408
+ (event.key === 'ArrowDown' ? 1 : -1) +
409
+ items.length) %
410
+ items.length
411
+ : 0,
412
+ );
413
+ return;
414
+ }
415
+ if (event.key === 'Enter') {
416
+ event.preventDefault();
417
+ if (items[selected]) choose(items[selected]);
418
+ return;
419
+ }
420
+ if (event.key === 'Escape') {
421
+ event.preventDefault();
422
+ dismissed.current = JSON.stringify(editor.query());
423
+ close();
424
+ return;
425
+ }
426
+ }
427
+ if (event.key === 'Enter') {
428
+ event.preventDefault();
429
+ if (
430
+ !event.shiftKey &&
431
+ (props.submitMode === 'mod-enter') === modified &&
432
+ props.onSubmitRequest
433
+ )
434
+ props.onSubmitRequest();
435
+ else {
436
+ editor.insert({ version: 1, content: [{ type: 'break' }] });
437
+ refresh();
438
+ }
439
+ } else if (
440
+ ['Backspace', 'Delete'].includes(event.key) &&
441
+ !modified &&
442
+ !event.altKey &&
443
+ !event.shiftKey
444
+ ) {
445
+ event.preventDefault();
446
+ editor.erase(event.key === 'Backspace');
447
+ dismissed.current = '';
448
+ refresh();
449
+ }
450
+ }}
451
+ />
452
+ {empty ? (
453
+ <p
454
+ aria-hidden
455
+ className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 truncate text-sm leading-[22px] text-muted-foreground"
456
+ >
457
+ {props.placeholder || '输入评论'}
458
+ </p>
459
+ ) : null}
460
+ </div>
461
+ <ImageAttachments
462
+ images={images.images}
463
+ disabled={blocked}
464
+ remove={images.remove}
465
+ retry={images.retry}
466
+ />
467
+ {props.enableImages || props.onGrantMentionAccessChange ? (
468
+ <AttachmentToolbar props={props} disabled={blocked} add={addImages} />
469
+ ) : null}
470
+ </div>
471
+ {images.error ? (
472
+ <p role="alert" className="mt-1 text-xs text-destructive">
473
+ {images.error}
474
+ </p>
475
+ ) : null}
476
+ {session && root.current ? (
477
+ <MentionSuggestions
478
+ container={props.portalContainer}
479
+ anchor={() => controller.current?.caretRect() || null}
480
+ editorElement={root.current}
481
+ id={listId}
482
+ items={items}
483
+ selected={selected}
484
+ query={session.query}
485
+ onSelect={choose}
486
+ onHover={setSelected}
487
+ loading={search.loading}
488
+ error={search.error}
489
+ retry={search.retry}
490
+ loadMore={search.loadMore}
491
+ hasMore={Boolean(search.nextCursor)}
492
+ />
493
+ ) : null}
494
+ </div>
495
+ );
496
+ }
@@ -0,0 +1,97 @@
1
+ import { mentionKey } from './content';
2
+ import type {
3
+ CommentDataSource,
4
+ MentionOption,
5
+ MentionRef,
6
+ MentionResult,
7
+ } from './types';
8
+
9
+ /** One store per authenticated scope, shared by editors and all list items. */
10
+ export class EntityStore {
11
+ private cache = new Map<string, { value: MentionResult; until: number }>();
12
+ private listeners = new Set<() => void>();
13
+ private queue = new Map<string, MentionRef>();
14
+ private pending = new Set<string>();
15
+ private controllers = new Set<AbortController>();
16
+ private scheduled = false;
17
+ private generation = 0;
18
+
19
+ constructor(readonly source: CommentDataSource) {}
20
+ subscribe = (listener: () => void) => {
21
+ this.listeners.add(listener);
22
+ return () => {
23
+ this.listeners.delete(listener);
24
+ };
25
+ };
26
+ peek = (ref: MentionRef) => this.cache.get(mentionKey(ref))?.value;
27
+ seed(items: MentionOption[]) {
28
+ for (const item of items) this.write({ ...item, status: 'resolved' });
29
+ }
30
+ private write(value: MentionResult) {
31
+ const key = mentionKey(value.ref);
32
+ this.cache.delete(key);
33
+ this.cache.set(key, {
34
+ value,
35
+ until: Date.now() + (value.status === 'resolved' ? 300_000 : 30_000),
36
+ });
37
+ if (this.cache.size > 500)
38
+ this.cache.delete(this.cache.keys().next().value!);
39
+ for (const listener of this.listeners) listener();
40
+ }
41
+ ensure(refs: MentionRef[], force = false) {
42
+ for (const ref of refs) {
43
+ const key = mentionKey(ref);
44
+ if (
45
+ !this.pending.has(key) &&
46
+ (force || (this.cache.get(key)?.until || 0) <= Date.now())
47
+ )
48
+ this.queue.set(key, ref);
49
+ }
50
+ if (!this.scheduled && this.queue.size) {
51
+ this.scheduled = true;
52
+ queueMicrotask(() => {
53
+ this.scheduled = false;
54
+ void this.flush();
55
+ });
56
+ }
57
+ }
58
+ private async flush() {
59
+ const refs = [...this.queue.values()];
60
+ this.queue.clear();
61
+ const generation = this.generation;
62
+ for (let i = 0; i < refs.length; i += 50) {
63
+ const batch = refs.slice(i, i + 50);
64
+ batch.forEach((ref) => this.pending.add(mentionKey(ref)));
65
+ const controller = new AbortController();
66
+ this.controllers.add(controller);
67
+ void this.source
68
+ .resolve(batch, controller.signal)
69
+ .then((results) => {
70
+ if (generation !== this.generation) return;
71
+ const byKey = new Map(
72
+ results.map((item) => [mentionKey(item.ref), item]),
73
+ );
74
+ for (const ref of batch)
75
+ this.write(byKey.get(mentionKey(ref)) || { ref, status: 'error' });
76
+ })
77
+ .catch(() => {
78
+ if (generation === this.generation)
79
+ for (const ref of batch) this.write({ ref, status: 'error' });
80
+ })
81
+ .finally(() => {
82
+ this.controllers.delete(controller);
83
+ if (generation === this.generation)
84
+ batch.forEach((ref) => this.pending.delete(mentionKey(ref)));
85
+ });
86
+ }
87
+ }
88
+ clear() {
89
+ this.generation++;
90
+ for (const controller of this.controllers) controller.abort();
91
+ this.controllers.clear();
92
+ this.pending.clear();
93
+ this.queue.clear();
94
+ this.cache.clear();
95
+ for (const listener of this.listeners) listener();
96
+ }
97
+ }