@lark-apaas/coding-template-nestjs-react-fullstack 0.1.57-alpha.20261102143457 → 0.1.57-alpha.20261103143319

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 (27) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +2 -0
  3. package/template/client/src/components/business-ui/record-comments/README.md +141 -0
  4. package/template/client/src/components/business-ui/record-comments/app-source.ts +93 -0
  5. package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
  6. package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +240 -0
  7. package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +231 -0
  8. package/template/client/src/components/business-ui/record-comments/extensions.ts +129 -0
  9. package/template/client/src/components/business-ui/record-comments/index.ts +9 -0
  10. package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +529 -0
  11. package/template/client/src/components/business-ui/record-comments/internal/content.tsx +213 -0
  12. package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +517 -0
  13. package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +675 -0
  14. package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +148 -0
  15. package/template/client/src/components/business-ui/record-comments/internal/images.tsx +155 -0
  16. package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +163 -0
  17. package/template/client/src/components/business-ui/record-comments/internal/model.ts +581 -0
  18. package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +170 -0
  19. package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +196 -0
  20. package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +681 -0
  21. package/template/client/src/components/business-ui/record-comments/internal/styles.css +1109 -0
  22. package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +456 -0
  23. package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +84 -0
  24. package/template/client/src/components/business-ui/record-comments/types.ts +118 -0
  25. package/template/package-lock.json +529 -13
  26. package/template/package.json +15 -4
  27. package/template/scripts/build.sh +37 -2
@@ -0,0 +1,675 @@
1
+ import type { MentionOption, MentionRef, DocumentMentionMetadata, CommentEditorProps, CommentValue } from '../types';
2
+ import type { EntityStore } from './mentions';
3
+ import { INPUT_USER_MENTION_CLASS, userMentionLabelClass, atUserLabel, DOCUMENT_MENTION_CLASS, DOCUMENT_MENTION_UNREADABLE_CLASS, documentSnapshot, isMentionRef, mentionLabel, safeUrl, fromApiContent, readApiContent, fromEditorDocument, emptyComment, normalizeValue, toEditorDocument, toPlainText, COMMENT_THEME_CLASS } from './model';
4
+ import { documentEmoji, documentIconSvg } from './icons';
5
+ import { Suggestion, type SuggestionProps, exitSuggestion } from '@tiptap/suggestion';
6
+ import { Node, Extension } from '@tiptap/core';
7
+ import { useMentionSearch, MentionSuggestions } from './suggestions';
8
+ import { useMentionContext } from './mentions';
9
+ import { cn } from '@/lib/utils';
10
+ import type { EditorView } from '@tiptap/pm/view';
11
+ import { closeHistory, history, redo, undo } from '@tiptap/pm/history';
12
+ import HardBreak from '@tiptap/extension-hard-break';
13
+ import Text from '@tiptap/extension-text';
14
+ import Paragraph from '@tiptap/extension-paragraph';
15
+ import Document from '@tiptap/extension-document';
16
+ import { EditorContent, useEditor } from '@tiptap/react';
17
+ import * as React from 'react';
18
+ import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
19
+
20
+ export type EntitySuggestion = SuggestionProps<MentionOption, MentionOption>;
21
+
22
+ function documentBody(
23
+ ref: MentionRef,
24
+ title: string,
25
+ href?: string,
26
+ metadata?: DocumentMentionMetadata,
27
+ unreadable = false,
28
+ ) {
29
+ const url = safeUrl(href);
30
+ const dom = document.createElement(url ? 'a' : 'span');
31
+ dom.className = unreadable
32
+ ? DOCUMENT_MENTION_UNREADABLE_CLASS
33
+ : DOCUMENT_MENTION_CLASS;
34
+ dom.setAttribute('data-doclink', 'true');
35
+ dom.setAttribute('data-doclink-token', ref.id);
36
+ dom.setAttribute(
37
+ 'data-doclink-type',
38
+ ref.kind === 'document' ? ref.resourceType : '',
39
+ );
40
+ if (url) {
41
+ dom.setAttribute('href', url);
42
+ dom.setAttribute('target', '_blank');
43
+ dom.setAttribute('rel', 'noopener noreferrer');
44
+ dom.addEventListener('click', (event) => event.stopPropagation());
45
+ dom.addEventListener('mousedown', (event) => event.preventDefault());
46
+ }
47
+ if (title) {
48
+ const icon = document.createElement('span');
49
+ icon.className = '-mb-0.5 mr-0.5 inline-block text-center leading-none';
50
+ icon.style.width = icon.style.height = icon.style.fontSize = '14px';
51
+ icon.setAttribute('aria-hidden', 'true');
52
+ const emoji = documentEmoji(metadata?.iconInfo);
53
+ if (emoji) icon.textContent = emoji;
54
+ else icon.innerHTML = documentIconSvg(ref, metadata); // Bundled static SVG only.
55
+ dom.append(icon);
56
+ }
57
+ const name = document.createElement('span');
58
+ name.textContent = title || url || '文档';
59
+ dom.append(name);
60
+ return dom;
61
+ }
62
+
63
+ export function entityMention(
64
+ store: EntityStore,
65
+ strict: boolean,
66
+ onSession: (props: EntitySuggestion | null) => void,
67
+ selfUserIDs: readonly string[] = [],
68
+ ) {
69
+ return Node.create({
70
+ name: 'entityMention',
71
+ group: 'inline',
72
+ inline: true,
73
+ atom: true,
74
+ selectable: true,
75
+ addAttributes() {
76
+ return {
77
+ ref: { default: null },
78
+ label: { default: null },
79
+ href: { default: null },
80
+ document: { default: null },
81
+ };
82
+ },
83
+ // Clipboard references are restored only from our validated custom MIME, not arbitrary HTML.
84
+ parseHTML() {
85
+ return [];
86
+ },
87
+ renderHTML({ node }) {
88
+ const ref = node.attrs.ref;
89
+ const label = isMentionRef(ref)
90
+ ? mentionLabel(ref, node.attrs.label, store.peek(ref), strict)
91
+ : '引用';
92
+ if (isMentionRef(ref) && ref.kind === 'document') {
93
+ const result = store.peek(ref);
94
+ const resolved = result?.status === 'resolved' ? result : undefined;
95
+ const snapshot = strict ? {} : documentSnapshot(node.attrs);
96
+ const title =
97
+ resolved ||
98
+ node.attrs.label ||
99
+ strict ||
100
+ result?.status === 'forbidden' ||
101
+ result?.status === 'not-found'
102
+ ? label
103
+ : '';
104
+ return documentBody(
105
+ ref,
106
+ title,
107
+ resolved?.href || snapshot.href,
108
+ resolved?.document || snapshot.document,
109
+ result?.status === 'forbidden' || resolved?.readable === false,
110
+ );
111
+ }
112
+ return [
113
+ 'span',
114
+ {
115
+ 'data-replica-comment-mention': '',
116
+ class:
117
+ ref?.kind === 'user'
118
+ ? INPUT_USER_MENTION_CLASS
119
+ : DOCUMENT_MENTION_CLASS,
120
+ },
121
+ [
122
+ 'span',
123
+ {
124
+ class: userMentionLabelClass(
125
+ selfUserIDs.includes(ref?.id),
126
+ isMentionRef(ref) && store.peek(ref)?.status === 'forbidden',
127
+ ),
128
+ },
129
+ ref?.kind === 'user' ? atUserLabel(label) : label,
130
+ ],
131
+ ];
132
+ },
133
+ renderText({ node }) {
134
+ return node.attrs.ref?.kind === 'user'
135
+ ? atUserLabel(node.attrs.label || '引用')
136
+ : node.attrs.label || '引用';
137
+ },
138
+ addKeyboardShortcuts() {
139
+ return {
140
+ Backspace: () =>
141
+ this.editor.commands.command(({ state, tr }) => {
142
+ const { empty, $from } = state.selection;
143
+ const previous = $from.nodeBefore;
144
+ if (!empty || previous?.type.name !== 'entityMention') return false;
145
+ tr.delete($from.pos - previous.nodeSize, $from.pos);
146
+ return true;
147
+ }),
148
+ };
149
+ },
150
+ addNodeView() {
151
+ return ({ node }) => {
152
+ const dom = document.createElement('span');
153
+ dom.contentEditable = 'false';
154
+ dom.dataset.replicaCommentMention = '';
155
+ const ref = node.attrs.ref;
156
+ const isUser = isMentionRef(ref) && ref.kind === 'user';
157
+ dom.className = isUser ? INPUT_USER_MENTION_CLASS : '';
158
+ const name = document.createElement('span');
159
+ if (isUser) {
160
+ dom.append(name);
161
+ }
162
+ const update = () => {
163
+ const result = isMentionRef(ref) ? store.peek(ref) : undefined;
164
+ const label = isMentionRef(ref)
165
+ ? mentionLabel(ref, node.attrs.label, result, strict)
166
+ : '引用';
167
+ if (isUser) {
168
+ name.className = userMentionLabelClass(selfUserIDs.includes(ref.id), result?.status === 'forbidden');
169
+ name.textContent = atUserLabel(label);
170
+ }
171
+ else if (isMentionRef(ref)) {
172
+ const resolved = result?.status === 'resolved' ? result : undefined;
173
+ const snapshot = strict ? {} : documentSnapshot(node.attrs);
174
+ const title =
175
+ resolved ||
176
+ node.attrs.label ||
177
+ strict ||
178
+ result?.status === 'forbidden' ||
179
+ result?.status === 'not-found'
180
+ ? label
181
+ : '';
182
+ dom.replaceChildren(
183
+ documentBody(
184
+ ref,
185
+ title,
186
+ resolved?.href || snapshot.href,
187
+ resolved?.document || snapshot.document,
188
+ result?.status === 'forbidden' || resolved?.readable === false,
189
+ ),
190
+ );
191
+ }
192
+ dom.setAttribute('aria-label', isUser ? atUserLabel(label) : label);
193
+ dom.title = label;
194
+ };
195
+ update();
196
+ if (isMentionRef(node.attrs.ref)) store.ensure([node.attrs.ref]);
197
+ const unsubscribe = store.subscribe(update);
198
+ return { dom, destroy: unsubscribe };
199
+ };
200
+ },
201
+ addProseMirrorPlugins() {
202
+ return [
203
+ Suggestion<MentionOption, MentionOption>({
204
+ editor: this.editor,
205
+ char: '@',
206
+ allowedPrefixes: null,
207
+ allow: ({ state, range }) =>
208
+ !/[a-zA-Z0-9_]/.test(
209
+ state.doc.textBetween(Math.max(0, range.from - 1), range.from),
210
+ ),
211
+ items: () => [],
212
+ command: ({ editor, range, props }) =>
213
+ editor
214
+ .chain()
215
+ .focus()
216
+ .insertContentAt(range, [
217
+ {
218
+ type: 'entityMention',
219
+ attrs: {
220
+ ref: props.ref,
221
+ label: props.label,
222
+ ...documentSnapshot(props),
223
+ },
224
+ },
225
+ { type: 'text', text: ' ' },
226
+ ])
227
+ .run(),
228
+ render: () => ({
229
+ onStart: onSession,
230
+ onUpdate: onSession,
231
+ onExit: () => onSession(null),
232
+ }),
233
+ }),
234
+ ];
235
+ },
236
+ });
237
+ }
238
+
239
+ export const CommentLink = Node.create({
240
+ name: 'commentLink',
241
+ group: 'inline',
242
+ inline: true,
243
+ atom: true,
244
+ addAttributes: () => ({ url: { default: '' } }),
245
+ parseHTML: () => [],
246
+ renderHTML({ node }) {
247
+ const url = String(node.attrs.url || '');
248
+ return [
249
+ 'a',
250
+ {
251
+ href: safeUrl(url),
252
+ target: '_blank',
253
+ rel: 'noopener noreferrer',
254
+ class: 'break-words text-primary no-underline hover:underline',
255
+ },
256
+ url,
257
+ ];
258
+ },
259
+ renderText: ({ node }) => String(node.attrs.url || ''),
260
+ });
261
+
262
+ export const UnresolvedMention = Node.create({
263
+ name: 'unresolvedMention',
264
+ group: 'inline',
265
+ inline: true,
266
+ atom: true,
267
+ parseHTML: () => [],
268
+ renderHTML: () => [
269
+ 'span',
270
+ {
271
+ class: 'text-subtle-foreground',
272
+ title: '原评论缺少被提及对象的信息',
273
+ },
274
+ '引用信息缺失',
275
+ ],
276
+ renderText: () => '引用信息缺失',
277
+ });
278
+
279
+ interface Session {
280
+ id: number;
281
+ props: EntitySuggestion;
282
+ query: string;
283
+ }
284
+ const MIME = 'application/x-miaoda-comment+json';
285
+ const History = Extension.create({
286
+ name: 'commentHistory',
287
+ addProseMirrorPlugins: () => [history()],
288
+ addKeyboardShortcuts() {
289
+ return {
290
+ 'Mod-z': () => undo(this.editor.state, this.editor.view.dispatch),
291
+ 'Mod-Shift-z': () => redo(this.editor.state, this.editor.view.dispatch),
292
+ 'Mod-y': () => redo(this.editor.state, this.editor.view.dispatch),
293
+ };
294
+ },
295
+ });
296
+
297
+ export default function CommentEditor(props: CommentEditorProps) {
298
+ const { store, strict, currentUserID, highlightUserIDs } =
299
+ useMentionContext();
300
+ const selfUserIDsKey = JSON.stringify(
301
+ [currentUserID, ...(highlightUserIDs || [])].filter(Boolean),
302
+ );
303
+ const latest = useRef(props);
304
+ latest.current = props;
305
+ const publish = useCallback((next: CommentValue) => {
306
+ const value = normalizeValue(next);
307
+ latest.current = { ...latest.current, value };
308
+ latest.current.onChange(value);
309
+ }, []);
310
+ const listId = React.useId();
311
+ const [session, setSession] = useState<Session | null>(null);
312
+ const sessionRef = useRef<Session | null>(null);
313
+ const sequence = useRef(0);
314
+ const [selected, setSelected] = useState(0);
315
+ const search = useMentionSearch(
316
+ store.source,
317
+ session,
318
+ props.enableDocuments ? 'all' : 'user',
319
+ );
320
+ const mentionItems = useMemo(
321
+ () =>
322
+ search.items.filter(
323
+ (item) => props.enableDocuments || item.ref.kind === 'user',
324
+ ),
325
+ [search.items, props.enableDocuments],
326
+ );
327
+ const handlers = useRef<{
328
+ key: (event: KeyboardEvent) => boolean;
329
+ paste: (event: ClipboardEvent) => boolean;
330
+ }>({ key: () => false, paste: () => false });
331
+ const onSession = useCallback((suggestion: EntitySuggestion | null) => {
332
+ const previous = sessionRef.current;
333
+ if (!suggestion || !previous || suggestion.query !== previous.query) {
334
+ setSelected(0);
335
+ }
336
+ const next = suggestion
337
+ ? {
338
+ id: previous?.id ?? ++sequence.current,
339
+ props: suggestion,
340
+ query: suggestion.query,
341
+ }
342
+ : null;
343
+ sessionRef.current = next;
344
+ setSession(next);
345
+ }, []);
346
+ const extensions = useMemo(
347
+ () => [
348
+ Document.extend({ content: 'paragraph' }),
349
+ Paragraph,
350
+ Text,
351
+ HardBreak,
352
+ History,
353
+ CommentLink,
354
+ UnresolvedMention,
355
+ entityMention(store, strict, onSession, JSON.parse(selfUserIDsKey)),
356
+ ],
357
+ [store, strict, onSession, selfUserIDsKey],
358
+ );
359
+ const editor = useEditor(
360
+ {
361
+ extensions,
362
+ content: toEditorDocument(props.value),
363
+ immediatelyRender: false,
364
+ shouldRerenderOnTransaction: false,
365
+ editorProps: {
366
+ attributes: {
367
+ class:
368
+ 'relative z-[1] min-h-[22px] max-h-60 overflow-y-auto whitespace-pre-wrap break-words text-sm leading-[22px] outline-none [&_p]:m-0',
369
+ role: 'textbox',
370
+ 'aria-label': props.placeholder || '输入评论',
371
+ 'aria-multiline': 'true',
372
+ 'aria-autocomplete': 'list',
373
+ 'aria-haspopup': 'listbox',
374
+ },
375
+ handleKeyDown: (_view, event) => handlers.current.key(event),
376
+ handlePaste: (_view, event) => handlers.current.paste(event),
377
+ handleDOMEvents: {
378
+ copy: (view, event) => copySelection(view, event),
379
+ cut: (view, event) => copySelection(view, event, true),
380
+ },
381
+ },
382
+ onUpdate: ({ editor: instance }) => {
383
+ publish(fromEditorDocument(instance.getJSON()));
384
+ },
385
+ },
386
+ [extensions],
387
+ );
388
+
389
+ const value = normalizeValue(props.value);
390
+ const blocked = Boolean(props.disabled);
391
+ useImperativeHandle(
392
+ props.editorRef,
393
+ () => ({
394
+ getContent: () =>
395
+ readApiContent(
396
+ editor ? fromEditorDocument(editor.getJSON()) : latest.current.value,
397
+ ),
398
+ clear() {
399
+ if (!editor) {
400
+ publish(emptyComment());
401
+ return;
402
+ }
403
+ exitSuggestion(editor.view);
404
+ editor
405
+ .chain()
406
+ .command(({ tr }) => {
407
+ closeHistory(tr);
408
+ return true;
409
+ })
410
+ .clearContent()
411
+ .run();
412
+ },
413
+ setEditable: (editable) => editor?.setEditable(editable),
414
+ focus: () => {
415
+ editor?.commands.focus('end');
416
+ },
417
+ }),
418
+ [editor, publish],
419
+ );
420
+ const choose = (item: MentionOption) => {
421
+ const active = sessionRef.current;
422
+ if (!active || blocked || !editor || !editor.isEditable || search.loading)
423
+ return;
424
+ store.seed([item]);
425
+ if (item.ref.kind === 'document') store.ensure([item.ref], true);
426
+ active.props.command(item);
427
+ };
428
+
429
+ handlers.current = {
430
+ key(event) {
431
+ if (blocked) return false;
432
+ if (event.isComposing || event.keyCode === 229 || editor?.view.composing)
433
+ return false;
434
+ if (sessionRef.current && !event.shiftKey) {
435
+ if (
436
+ search.loading &&
437
+ ['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)
438
+ )
439
+ return true;
440
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
441
+ setSelected((index) =>
442
+ mentionItems.length
443
+ ? (index +
444
+ (event.key === 'ArrowDown' ? 1 : -1) +
445
+ mentionItems.length) %
446
+ mentionItems.length
447
+ : 0,
448
+ );
449
+ return true;
450
+ }
451
+ if (event.key === 'Enter') {
452
+ const item = mentionItems[selected];
453
+ if (item) choose(item);
454
+ return true;
455
+ }
456
+ if (event.key === 'Escape') {
457
+ if (editor) exitSuggestion(editor.view);
458
+ return true;
459
+ }
460
+ }
461
+ if (event.key === 'Enter') {
462
+ if (
463
+ !event.shiftKey &&
464
+ (props.submitMode === 'mod-enter') ===
465
+ Boolean(event.metaKey || event.ctrlKey) &&
466
+ props.onSubmitRequest
467
+ )
468
+ props.onSubmitRequest();
469
+ else editor?.commands.setHardBreak();
470
+ return true;
471
+ }
472
+ return false;
473
+ },
474
+ paste(event) {
475
+ if (!editor || blocked || !event.clipboardData) return false;
476
+ const data = event.clipboardData;
477
+ const encoded = data.getData(MIME);
478
+ if (encoded && encoded.length < 200_000) {
479
+ try {
480
+ const pasted = normalizeValue(JSON.parse(encoded));
481
+ if (
482
+ !props.enableDocuments &&
483
+ pasted.content.some(
484
+ (part) => part.type === 'mention' && part.ref.kind === 'document',
485
+ )
486
+ )
487
+ throw new Error('文档提及暂未启用');
488
+ editor.commands.insertContent(
489
+ toEditorDocument(pasted).content?.[0]?.content || [],
490
+ );
491
+ event.preventDefault();
492
+ return true;
493
+ } catch {
494
+ /* Invalid rich clipboard falls back to plain text. */
495
+ }
496
+ }
497
+ const text = clipboardPlainText(data);
498
+ if (text) {
499
+ const pasted: CommentValue =
500
+ /^https?:\/\/\S+$/.test(text) && safeUrl(text)
501
+ ? props.enableDocuments
502
+ ? fromApiContent([{ type: 'link', url: text }])
503
+ : { version: 1, content: [{ type: 'link', url: text }] }
504
+ : { version: 1, content: [{ type: 'text', text }] };
505
+ editor.commands.insertContent(
506
+ toEditorDocument(pasted).content?.[0]?.content || [],
507
+ );
508
+ }
509
+ // Foreign HTML is flattened to text; only our validated MIME restores references.
510
+ event.preventDefault();
511
+ return true;
512
+ },
513
+ };
514
+
515
+ const incoming = JSON.stringify(toEditorDocument(value));
516
+ const incomingRef = useRef(incoming);
517
+ incomingRef.current = incoming;
518
+ useEffect(() => {
519
+ if (!editor?.isInitialized || editor.isDestroyed) return;
520
+ const dom = editor.view.dom;
521
+ const sync = () => {
522
+ if (
523
+ editor.isDestroyed || !editor.isInitialized || editor.view.composing ||
524
+ JSON.stringify(editor.getJSON()) === incomingRef.current
525
+ )
526
+ return;
527
+ const position = editor.state.selection.from;
528
+ editor.commands.setContent(JSON.parse(incomingRef.current), {
529
+ emitUpdate: false,
530
+ });
531
+ editor.commands.setTextSelection(
532
+ Math.min(position, Math.max(1, editor.state.doc.content.size - 1)),
533
+ );
534
+ };
535
+ sync();
536
+ const deferred = () => queueMicrotask(sync);
537
+ dom.addEventListener('compositionend', deferred);
538
+ // Tiptap may destroy its view before React runs this cleanup.
539
+ return () => dom.removeEventListener('compositionend', deferred);
540
+ }, [editor, incoming]);
541
+ useEffect(() => {
542
+ if (editor?.isInitialized && !editor.isDestroyed)
543
+ editor.view.dom.setAttribute(
544
+ 'aria-label',
545
+ props.placeholder || '输入评论',
546
+ );
547
+ }, [editor, props.placeholder]);
548
+ useEffect(() => {
549
+ editor?.setEditable(!blocked);
550
+ }, [editor, blocked]);
551
+ useEffect(() => {
552
+ if (!editor) return;
553
+ editor.commands.focus('end');
554
+ }, [editor]);
555
+ useEffect(() => {
556
+ if (!editor) return;
557
+ const dom = editor.view.dom;
558
+ dom.setAttribute('aria-expanded', String(Boolean(session)));
559
+ if (session) {
560
+ dom.setAttribute('aria-controls', listId);
561
+ dom.setAttribute('aria-activedescendant', `${listId}-${selected}`);
562
+ } else {
563
+ dom.removeAttribute('aria-controls');
564
+ dom.removeAttribute('aria-activedescendant');
565
+ }
566
+ const dismiss = (event: PointerEvent) => {
567
+ const target = event.target as globalThis.Node;
568
+ const ownPanel = document
569
+ .getElementById(listId)
570
+ ?.closest('[data-replica-comment-suggestions]');
571
+ if (!dom.contains(target) && !ownPanel?.contains(target))
572
+ exitSuggestion(editor.view);
573
+ };
574
+ document.addEventListener('pointerdown', dismiss);
575
+ return () => document.removeEventListener('pointerdown', dismiss);
576
+ }, [editor, session, listId, selected]);
577
+
578
+ return (
579
+ <div
580
+ className={cn(
581
+ COMMENT_THEME_CLASS,
582
+ 'replica-input text-foreground',
583
+ props.className,
584
+ )}
585
+ >
586
+ <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">
587
+ <div className="relative w-full cursor-text text-sm">
588
+ <EditorContent editor={editor} />
589
+ {!value.content.length ? (
590
+ <p
591
+ aria-hidden
592
+ className="pointer-events-none absolute inset-x-0 top-0 truncate text-sm leading-[22px] text-muted-foreground"
593
+ >
594
+ {props.placeholder || '输入评论'}
595
+ </p>
596
+ ) : null}
597
+ </div>
598
+ </div>
599
+ {session && editor ? (
600
+ <MentionSuggestions
601
+ container={props.portalContainer}
602
+ anchor={session.props.clientRect || (() => null)}
603
+ editorElement={editor.view.dom}
604
+ id={listId}
605
+ items={mentionItems}
606
+ selected={selected}
607
+ query={session.query}
608
+ onSelect={choose}
609
+ onHover={setSelected}
610
+ loading={search.loading}
611
+ initialLoading={search.initialLoading}
612
+ error={search.error}
613
+ retry={search.retry}
614
+ loadMore={search.loadMore}
615
+ hasMore={Boolean(search.nextCursor)}
616
+ />
617
+ ) : null}
618
+ </div>
619
+ );
620
+ }
621
+
622
+ function clipboardPlainText(data: Pick<DataTransfer, 'getData'>): string {
623
+ const plain = data.getData('text/plain');
624
+ if (plain) return plain;
625
+ const doc = new DOMParser().parseFromString(
626
+ data.getData('text/html'),
627
+ 'text/html',
628
+ );
629
+ let text = '';
630
+ const visit = (node: globalThis.Node) => {
631
+ if (node.nodeType === 3) {
632
+ text += node.textContent || '';
633
+ return;
634
+ }
635
+ if (
636
+ !(node instanceof Element) ||
637
+ ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)
638
+ )
639
+ return;
640
+ if (node.tagName === 'BR') {
641
+ text += '\n';
642
+ return;
643
+ }
644
+ node.childNodes.forEach(visit);
645
+ if (
646
+ ['P', 'DIV', 'LI', 'TR', 'H1', 'H2', 'H3', 'BLOCKQUOTE'].includes(
647
+ node.tagName,
648
+ ) &&
649
+ !text.endsWith('\n')
650
+ )
651
+ text += '\n';
652
+ };
653
+ doc.body.childNodes.forEach(visit);
654
+ return text.replace(/\n$/, '');
655
+ }
656
+
657
+ function copySelection(
658
+ view: EditorView,
659
+ event: ClipboardEvent,
660
+ cut = false,
661
+ ): boolean {
662
+ if (
663
+ view.state.selection.empty ||
664
+ !event.clipboardData ||
665
+ (cut && !view.editable)
666
+ )
667
+ return false;
668
+ const { from, to } = view.state.selection;
669
+ const value = fromEditorDocument(view.state.doc.cut(from, to).toJSON());
670
+ event.clipboardData.setData(MIME, JSON.stringify(value));
671
+ event.clipboardData.setData('text/plain', toPlainText(value));
672
+ event.preventDefault();
673
+ if (cut) view.dispatch(view.state.tr.deleteSelection());
674
+ return true;
675
+ }