@lark-apaas/coding-template-nestjs-react-fullstack 0.1.54 → 0.1.55-alpha.20261030081308

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