@lark-apaas/coding-template-nestjs-react-fullstack 0.1.60-alpha.20261105142153 → 0.1.60-beta.0

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 (33) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +0 -2
  3. package/template/package-lock.json +30 -546
  4. package/template/package.json +3 -14
  5. package/template/scripts/build.sh +39 -22
  6. package/template/scripts/prune-smart.js +20 -1
  7. package/template/client/src/components/business-ui/comments/README.md +0 -101
  8. package/template/client/src/components/business-ui/comments/comments.tsx +0 -670
  9. package/template/client/src/components/business-ui/comments/contracts.ts +0 -256
  10. package/template/client/src/components/business-ui/comments/index.ts +0 -20
  11. package/template/client/src/components/business-ui/comments/use-comments.ts +0 -492
  12. package/template/client/src/components/business-ui/record-comments/README.md +0 -141
  13. package/template/client/src/components/business-ui/record-comments/app-source.ts +0 -93
  14. package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
  15. package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +0 -240
  16. package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +0 -231
  17. package/template/client/src/components/business-ui/record-comments/extensions.ts +0 -130
  18. package/template/client/src/components/business-ui/record-comments/index.ts +0 -9
  19. package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +0 -531
  20. package/template/client/src/components/business-ui/record-comments/internal/content.tsx +0 -213
  21. package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +0 -523
  22. package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +0 -681
  23. package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +0 -148
  24. package/template/client/src/components/business-ui/record-comments/internal/images.tsx +0 -155
  25. package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +0 -163
  26. package/template/client/src/components/business-ui/record-comments/internal/model.ts +0 -581
  27. package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +0 -198
  28. package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +0 -196
  29. package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +0 -683
  30. package/template/client/src/components/business-ui/record-comments/internal/styles.css +0 -1109
  31. package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +0 -456
  32. package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +0 -91
  33. package/template/client/src/components/business-ui/record-comments/types.ts +0 -121
@@ -1,681 +0,0 @@
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, 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
- // A source without recommendations can still implement legacy empty-query
328
- // search. Wait for actual results rather than showing an invented empty menu.
329
- const showSuggestions = Boolean(session && (
330
- session.query || mentionItems.length || search.error || (search.loading && store.source.recommend)
331
- ));
332
- const handlers = useRef<{
333
- key: (event: KeyboardEvent) => boolean;
334
- paste: (event: ClipboardEvent) => boolean;
335
- }>({ key: () => false, paste: () => false });
336
- const onSession = useCallback((suggestion: EntitySuggestion | null) => {
337
- const previous = sessionRef.current;
338
- if (!suggestion || !previous || suggestion.query !== previous.query) {
339
- setSelected(0);
340
- }
341
- const next = suggestion
342
- ? {
343
- id: previous?.id ?? ++sequence.current,
344
- props: suggestion,
345
- query: suggestion.query,
346
- }
347
- : null;
348
- sessionRef.current = next;
349
- setSession(next);
350
- }, []);
351
- const extensions = useMemo(
352
- () => [
353
- Document.extend({ content: 'paragraph' }),
354
- Paragraph,
355
- Text,
356
- HardBreak,
357
- History,
358
- CommentLink,
359
- UnresolvedMention,
360
- entityMention(store, strict, onSession, JSON.parse(selfUserIDsKey)),
361
- ],
362
- [store, strict, onSession, selfUserIDsKey],
363
- );
364
- const editor = useEditor(
365
- {
366
- extensions,
367
- content: toEditorDocument(props.value),
368
- immediatelyRender: false,
369
- shouldRerenderOnTransaction: false,
370
- editorProps: {
371
- attributes: {
372
- class:
373
- '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',
374
- role: 'textbox',
375
- 'aria-label': props.placeholder || '输入评论',
376
- 'aria-multiline': 'true',
377
- 'aria-autocomplete': 'list',
378
- 'aria-haspopup': 'listbox',
379
- },
380
- handleKeyDown: (_view, event) => handlers.current.key(event),
381
- handlePaste: (_view, event) => handlers.current.paste(event),
382
- handleDOMEvents: {
383
- copy: (view, event) => copySelection(view, event),
384
- cut: (view, event) => copySelection(view, event, true),
385
- },
386
- },
387
- onUpdate: ({ editor: instance }) => {
388
- publish(fromEditorDocument(instance.getJSON()));
389
- },
390
- },
391
- [extensions],
392
- );
393
-
394
- const value = normalizeValue(props.value);
395
- const blocked = Boolean(props.disabled);
396
- useImperativeHandle(
397
- props.editorRef,
398
- () => ({
399
- getContent: () =>
400
- readApiContent(
401
- editor ? fromEditorDocument(editor.getJSON()) : latest.current.value,
402
- ),
403
- clear() {
404
- if (!editor) {
405
- publish(emptyComment());
406
- return;
407
- }
408
- exitSuggestion(editor.view);
409
- editor
410
- .chain()
411
- .command(({ tr }) => {
412
- closeHistory(tr);
413
- return true;
414
- })
415
- .clearContent()
416
- .run();
417
- },
418
- setEditable: (editable) => editor?.setEditable(editable),
419
- focus: () => {
420
- editor?.commands.focus('end');
421
- },
422
- }),
423
- [editor, publish],
424
- );
425
- const choose = (item: MentionOption) => {
426
- const active = sessionRef.current;
427
- if (!active || blocked || !editor || !editor.isEditable || search.loading)
428
- return;
429
- store.seed([item]);
430
- if (item.ref.kind === 'document') store.ensure([item.ref], true);
431
- active.props.command(item);
432
- };
433
-
434
- handlers.current = {
435
- key(event) {
436
- if (blocked) return false;
437
- if (event.isComposing || event.keyCode === 229 || editor?.view.composing)
438
- return false;
439
- if (sessionRef.current && !event.shiftKey && (showSuggestions || event.key === 'Escape')) {
440
- if (
441
- search.loading &&
442
- ['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)
443
- )
444
- return true;
445
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
446
- setSelected((index) =>
447
- mentionItems.length
448
- ? (index +
449
- (event.key === 'ArrowDown' ? 1 : -1) +
450
- mentionItems.length) %
451
- mentionItems.length
452
- : 0,
453
- );
454
- return true;
455
- }
456
- if (event.key === 'Enter') {
457
- const item = mentionItems[selected];
458
- if (item) choose(item);
459
- return true;
460
- }
461
- if (event.key === 'Escape') {
462
- if (editor) exitSuggestion(editor.view);
463
- return true;
464
- }
465
- }
466
- if (event.key === 'Enter') {
467
- if (
468
- !event.shiftKey &&
469
- (props.submitMode === 'mod-enter') ===
470
- Boolean(event.metaKey || event.ctrlKey) &&
471
- props.onSubmitRequest
472
- )
473
- props.onSubmitRequest();
474
- else editor?.commands.setHardBreak();
475
- return true;
476
- }
477
- return false;
478
- },
479
- paste(event) {
480
- if (!editor || blocked || !event.clipboardData) return false;
481
- const data = event.clipboardData;
482
- const encoded = data.getData(MIME);
483
- if (encoded && encoded.length < 200_000) {
484
- try {
485
- const pasted = normalizeValue(JSON.parse(encoded));
486
- if (
487
- !props.enableDocuments &&
488
- pasted.content.some(
489
- (part) => part.type === 'mention' && part.ref.kind === 'document',
490
- )
491
- )
492
- throw new Error('文档提及暂未启用');
493
- editor.commands.insertContent(
494
- toEditorDocument(pasted).content?.[0]?.content || [],
495
- );
496
- event.preventDefault();
497
- return true;
498
- } catch {
499
- /* Invalid rich clipboard falls back to plain text. */
500
- }
501
- }
502
- const text = clipboardPlainText(data);
503
- if (text) {
504
- const pasted: CommentValue =
505
- /^https?:\/\/\S+$/.test(text) && safeUrl(text) && store.source.allowLinks !== false
506
- ? props.enableDocuments
507
- ? { version: 1, content: [store.source.parseLink?.(text) || { type: 'link', url: text }] }
508
- : { version: 1, content: [{ type: 'link', url: text }] }
509
- : { version: 1, content: [{ type: 'text', text }] };
510
- editor.commands.insertContent(
511
- toEditorDocument(pasted).content?.[0]?.content || [],
512
- );
513
- }
514
- // Foreign HTML is flattened to text; only our validated MIME restores references.
515
- event.preventDefault();
516
- return true;
517
- },
518
- };
519
-
520
- const incoming = JSON.stringify(toEditorDocument(value));
521
- const incomingRef = useRef(incoming);
522
- incomingRef.current = incoming;
523
- useEffect(() => {
524
- if (!editor?.isInitialized || editor.isDestroyed) return;
525
- const dom = editor.view.dom;
526
- const sync = () => {
527
- if (
528
- editor.isDestroyed || !editor.isInitialized || editor.view.composing ||
529
- JSON.stringify(editor.getJSON()) === incomingRef.current
530
- )
531
- return;
532
- const position = editor.state.selection.from;
533
- editor.commands.setContent(JSON.parse(incomingRef.current), {
534
- emitUpdate: false,
535
- });
536
- editor.commands.setTextSelection(
537
- Math.min(position, Math.max(1, editor.state.doc.content.size - 1)),
538
- );
539
- };
540
- sync();
541
- const deferred = () => queueMicrotask(sync);
542
- dom.addEventListener('compositionend', deferred);
543
- // Tiptap may destroy its view before React runs this cleanup.
544
- return () => dom.removeEventListener('compositionend', deferred);
545
- }, [editor, incoming]);
546
- useEffect(() => {
547
- if (editor?.isInitialized && !editor.isDestroyed)
548
- editor.view.dom.setAttribute(
549
- 'aria-label',
550
- props.placeholder || '输入评论',
551
- );
552
- }, [editor, props.placeholder]);
553
- useEffect(() => {
554
- editor?.setEditable(!blocked);
555
- }, [editor, blocked]);
556
- useEffect(() => {
557
- if (!editor) return;
558
- editor.commands.focus('end');
559
- }, [editor]);
560
- useEffect(() => {
561
- if (!editor) return;
562
- const dom = editor.view.dom;
563
- dom.setAttribute('aria-expanded', String(showSuggestions));
564
- if (showSuggestions) {
565
- dom.setAttribute('aria-controls', listId);
566
- if (mentionItems[selected]) dom.setAttribute('aria-activedescendant', `${listId}-${selected}`);
567
- else dom.removeAttribute('aria-activedescendant');
568
- } else {
569
- dom.removeAttribute('aria-controls');
570
- dom.removeAttribute('aria-activedescendant');
571
- }
572
- const dismiss = (event: PointerEvent) => {
573
- const target = event.target as globalThis.Node;
574
- const ownPanel = document
575
- .getElementById(listId)
576
- ?.closest('[data-replica-comment-suggestions]');
577
- if (!dom.contains(target) && !ownPanel?.contains(target))
578
- exitSuggestion(editor.view);
579
- };
580
- document.addEventListener('pointerdown', dismiss);
581
- return () => document.removeEventListener('pointerdown', dismiss);
582
- }, [editor, session, showSuggestions, listId, selected, mentionItems]);
583
-
584
- return (
585
- <div
586
- className={cn(
587
- COMMENT_THEME_CLASS,
588
- 'replica-input text-foreground',
589
- props.className,
590
- )}
591
- >
592
- <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">
593
- <div className="relative w-full cursor-text text-sm">
594
- <EditorContent editor={editor} />
595
- {!value.content.length ? (
596
- <p
597
- aria-hidden
598
- className="pointer-events-none absolute inset-x-0 top-0 truncate text-sm leading-[22px] text-muted-foreground"
599
- >
600
- {props.placeholder || '输入评论'}
601
- </p>
602
- ) : null}
603
- </div>
604
- </div>
605
- {showSuggestions && session && editor ? (
606
- <MentionSuggestions
607
- container={props.portalContainer}
608
- anchor={session.props.clientRect || (() => null)}
609
- editorElement={editor.view.dom}
610
- id={listId}
611
- items={mentionItems}
612
- selected={selected}
613
- query={session.query}
614
- onSelect={choose}
615
- onHover={setSelected}
616
- loading={search.loading}
617
- initialLoading={search.initialLoading}
618
- error={search.error}
619
- retry={search.retry}
620
- loadMore={search.loadMore}
621
- hasMore={Boolean(search.nextCursor)}
622
- />
623
- ) : null}
624
- </div>
625
- );
626
- }
627
-
628
- function clipboardPlainText(data: Pick<DataTransfer, 'getData'>): string {
629
- const plain = data.getData('text/plain');
630
- if (plain) return plain;
631
- const doc = new DOMParser().parseFromString(
632
- data.getData('text/html'),
633
- 'text/html',
634
- );
635
- let text = '';
636
- const visit = (node: globalThis.Node) => {
637
- if (node.nodeType === 3) {
638
- text += node.textContent || '';
639
- return;
640
- }
641
- if (
642
- !(node instanceof Element) ||
643
- ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)
644
- )
645
- return;
646
- if (node.tagName === 'BR') {
647
- text += '\n';
648
- return;
649
- }
650
- node.childNodes.forEach(visit);
651
- if (
652
- ['P', 'DIV', 'LI', 'TR', 'H1', 'H2', 'H3', 'BLOCKQUOTE'].includes(
653
- node.tagName,
654
- ) &&
655
- !text.endsWith('\n')
656
- )
657
- text += '\n';
658
- };
659
- doc.body.childNodes.forEach(visit);
660
- return text.replace(/\n$/, '');
661
- }
662
-
663
- function copySelection(
664
- view: EditorView,
665
- event: ClipboardEvent,
666
- cut = false,
667
- ): boolean {
668
- if (
669
- view.state.selection.empty ||
670
- !event.clipboardData ||
671
- (cut && !view.editable)
672
- )
673
- return false;
674
- const { from, to } = view.state.selection;
675
- const value = fromEditorDocument(view.state.doc.cut(from, to).toJSON());
676
- event.clipboardData.setData(MIME, JSON.stringify(value));
677
- event.clipboardData.setData('text/plain', toPlainText(value));
678
- event.preventDefault();
679
- if (cut) view.dispatch(view.state.tr.deleteSelection());
680
- return true;
681
- }