@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.
- package/package.json +1 -1
- package/template/client/src/components/business-ui/README.md +0 -2
- package/template/package-lock.json +30 -546
- package/template/package.json +3 -14
- package/template/scripts/build.sh +39 -22
- package/template/scripts/prune-smart.js +20 -1
- package/template/client/src/components/business-ui/comments/README.md +0 -101
- package/template/client/src/components/business-ui/comments/comments.tsx +0 -670
- package/template/client/src/components/business-ui/comments/contracts.ts +0 -256
- package/template/client/src/components/business-ui/comments/index.ts +0 -20
- package/template/client/src/components/business-ui/comments/use-comments.ts +0 -492
- package/template/client/src/components/business-ui/record-comments/README.md +0 -141
- package/template/client/src/components/business-ui/record-comments/app-source.ts +0 -93
- package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
- package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +0 -240
- package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +0 -231
- package/template/client/src/components/business-ui/record-comments/extensions.ts +0 -130
- package/template/client/src/components/business-ui/record-comments/index.ts +0 -9
- package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +0 -531
- package/template/client/src/components/business-ui/record-comments/internal/content.tsx +0 -213
- package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +0 -523
- package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +0 -681
- package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +0 -148
- package/template/client/src/components/business-ui/record-comments/internal/images.tsx +0 -155
- package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +0 -163
- package/template/client/src/components/business-ui/record-comments/internal/model.ts +0 -581
- package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +0 -198
- package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +0 -196
- package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +0 -683
- package/template/client/src/components/business-ui/record-comments/internal/styles.css +0 -1109
- package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +0 -456
- package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +0 -91
- package/template/client/src/components/business-ui/record-comments/types.ts +0 -121
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import type { CommentImageFeature, CommentReactionFeature } from './comment-panel.contract';
|
|
2
|
-
import type { CommentDataSource, CommentDocumentSource, MentionResult } from './types';
|
|
3
|
-
import { mentionKey, documentRefFromUrl } from './internal/model';
|
|
4
|
-
|
|
5
|
-
/** Per-provider capabilities. No global registration, credentials or requests. */
|
|
6
|
-
export type CommentExtension =
|
|
7
|
-
| { readonly type: 'reactions'; readonly options: CommentReactionFeature }
|
|
8
|
-
| { readonly type: 'documents'; readonly source: CommentDocumentSource }
|
|
9
|
-
| { readonly type: 'images'; readonly options: CommentImageFeature };
|
|
10
|
-
|
|
11
|
-
export function commentReactions(options: CommentReactionFeature): CommentExtension {
|
|
12
|
-
return { type: 'reactions', options };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function commentDocuments(source: CommentDocumentSource): CommentExtension {
|
|
16
|
-
return { type: 'documents', source };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Requires the host's upload/persistence/readback implementation; no Bitable image API is supplied. */
|
|
20
|
-
export function commentImages(options: CommentImageFeature): CommentExtension {
|
|
21
|
-
return { type: 'images', options };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Normalize registrations once per array. Never combine two implementations of the same capability. */
|
|
25
|
-
export function commentExtensionFeatures(extensions: readonly CommentExtension[] = []) {
|
|
26
|
-
const result: { reactions?: CommentReactionFeature; images?: CommentImageFeature; documents?: CommentDocumentSource } = {};
|
|
27
|
-
const seen = new Set<string>();
|
|
28
|
-
for (const extension of extensions) {
|
|
29
|
-
if (seen.has(extension.type)) throw new Error(`Duplicate comment extension: ${extension.type}`);
|
|
30
|
-
seen.add(extension.type);
|
|
31
|
-
switch (extension.type) {
|
|
32
|
-
case 'reactions': result.reactions = extension.options; break;
|
|
33
|
-
case 'images': result.images = extension.options; break;
|
|
34
|
-
case 'documents': result.documents = extension.source; break;
|
|
35
|
-
default: throw new Error('Unsupported comment extension');
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return result;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Keep Miaoda people IDs and cloud-document tokens in their own services. */
|
|
42
|
-
export function withDocumentSource(
|
|
43
|
-
people: CommentDataSource,
|
|
44
|
-
documents: CommentDocumentSource,
|
|
45
|
-
): CommentDataSource {
|
|
46
|
-
const load = async (
|
|
47
|
-
args: Parameters<CommentDataSource['search']>[0],
|
|
48
|
-
recommend: boolean,
|
|
49
|
-
) => {
|
|
50
|
-
const fetchSource = (
|
|
51
|
-
source: CommentDataSource | CommentDocumentSource,
|
|
52
|
-
kind: 'user' | 'document',
|
|
53
|
-
cursor?: string,
|
|
54
|
-
) =>
|
|
55
|
-
recommend && source.recommend
|
|
56
|
-
? source.recommend({ ...args, kind, cursor })
|
|
57
|
-
: source.search({ ...args, kind, cursor });
|
|
58
|
-
if (args.kind === 'user') return fetchSource(people, 'user', args.cursor);
|
|
59
|
-
if (args.kind === 'document')
|
|
60
|
-
return fetchSource(documents, 'document', args.cursor);
|
|
61
|
-
const cursor: { user?: string; document?: string } = args.cursor
|
|
62
|
-
? JSON.parse(args.cursor)
|
|
63
|
-
: {};
|
|
64
|
-
if (
|
|
65
|
-
!cursor ||
|
|
66
|
-
typeof cursor !== 'object' ||
|
|
67
|
-
[cursor.user, cursor.document].some(
|
|
68
|
-
(value) => value !== undefined && typeof value !== 'string',
|
|
69
|
-
)
|
|
70
|
-
)
|
|
71
|
-
throw new Error('分页参数无效');
|
|
72
|
-
const exhausted: Awaited<ReturnType<CommentDataSource['search']>> = {
|
|
73
|
-
items: [],
|
|
74
|
-
};
|
|
75
|
-
const requestUsers = !args.cursor || cursor.user !== undefined;
|
|
76
|
-
const requestDocs = !args.cursor || cursor.document !== undefined;
|
|
77
|
-
const pages = await Promise.allSettled([
|
|
78
|
-
requestUsers
|
|
79
|
-
? fetchSource(people, 'user', cursor.user)
|
|
80
|
-
: exhausted,
|
|
81
|
-
requestDocs
|
|
82
|
-
? fetchSource(documents, 'document', cursor.document)
|
|
83
|
-
: exhausted,
|
|
84
|
-
]);
|
|
85
|
-
if ((!requestUsers || pages[0].status === 'rejected') && (!requestDocs || pages[1].status === 'rejected'))
|
|
86
|
-
throw new Error('人员和文档搜索失败,请重试');
|
|
87
|
-
// A disabled/unavailable document capability must not break existing @people.
|
|
88
|
-
const users = pages[0].status === 'fulfilled' ? pages[0].value : exhausted;
|
|
89
|
-
const docs = pages[1].status === 'fulfilled' ? pages[1].value : exhausted;
|
|
90
|
-
return {
|
|
91
|
-
items: [
|
|
92
|
-
...users.items.filter((item) => item.ref.kind === 'user'),
|
|
93
|
-
...docs.items.filter((item) => item.ref.kind === 'document'),
|
|
94
|
-
],
|
|
95
|
-
nextCursor:
|
|
96
|
-
users.nextCursor || docs.nextCursor
|
|
97
|
-
? JSON.stringify({
|
|
98
|
-
user: users.nextCursor,
|
|
99
|
-
document: docs.nextCursor,
|
|
100
|
-
})
|
|
101
|
-
: undefined,
|
|
102
|
-
};
|
|
103
|
-
};
|
|
104
|
-
return {
|
|
105
|
-
...people,
|
|
106
|
-
scopeKey: JSON.stringify([people.scopeKey, documents.scopeKey]),
|
|
107
|
-
parseLink: documents.parseLink ?? ((url) => { const ref = documentRefFromUrl(url); return ref ? { type: 'mention' as const, ref, href: url } : undefined; }),
|
|
108
|
-
search: (args) => load(args, false),
|
|
109
|
-
recommend: (args) => load({ ...args, query: '' }, true),
|
|
110
|
-
async resolve(refs, signal) {
|
|
111
|
-
const users = refs.filter((ref) => ref.kind === 'user');
|
|
112
|
-
const docs = refs.filter((ref) => ref.kind === 'document');
|
|
113
|
-
// One failed service must not hide successful metadata from the other.
|
|
114
|
-
const results = await Promise.allSettled([
|
|
115
|
-
users.length ? people.resolve(users, signal) : [],
|
|
116
|
-
docs.length ? documents.resolve(docs, signal) : [],
|
|
117
|
-
]);
|
|
118
|
-
signal.throwIfAborted();
|
|
119
|
-
const byKey = new Map<string, MentionResult>();
|
|
120
|
-
for (const result of results)
|
|
121
|
-
if (result.status === 'fulfilled')
|
|
122
|
-
for (const item of result.value)
|
|
123
|
-
byKey.set(mentionKey(item.ref), item);
|
|
124
|
-
return refs.map(
|
|
125
|
-
(ref) =>
|
|
126
|
-
byKey.get(mentionKey(ref)) || { ref, status: 'error' as const },
|
|
127
|
-
);
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
export { CommentPanel, CommentsProvider, CommentList, CommentComposer } from './comment-panel';
|
|
2
|
-
export { createAppCommentSource } from './app-source';
|
|
3
|
-
export { commentReactions, commentDocuments, commentImages, withDocumentSource } from './extensions';
|
|
4
|
-
export type { CommentExtension } from './extensions';
|
|
5
|
-
export type * from './comment-panel.contract';
|
|
6
|
-
export type { CommentDataSource, CommentDocumentSource, CommentMentionClickHandler, MentionRef, MentionOption, MentionResult, MentionKind, DocumentMentionMetadata } from './types';
|
|
7
|
-
|
|
8
|
-
// URL helpers for external document adapters; no requests or permissions inside.
|
|
9
|
-
export { documentRefFromUrl, safeUrl } from './internal/model';
|
|
@@ -1,531 +0,0 @@
|
|
|
1
|
-
import { emptyComment, normalizeValue, readApiContent, COMMENT_THEME_CLASS, commentFailureMessage } from './model';
|
|
2
|
-
import type { CommentInputProps, CommentInputHandle, CommentEditorProps, CommentValue } from '../types';
|
|
3
|
-
import { useMentionContext } from './mentions';
|
|
4
|
-
import { CommentContent } from './content';
|
|
5
|
-
import { cn } from '@/lib/utils';
|
|
6
|
-
import { Button } from '@/components/ui/button';
|
|
7
|
-
import { ErrorBoundary } from 'react-error-boundary';
|
|
8
|
-
import * as React from 'react';
|
|
9
|
-
import { useEffect, useImperativeHandle, useCallback, useRef, useState, type ComponentType } from 'react';
|
|
10
|
-
import type { DiscussionDraft, DiscussionImage } from './view-types';
|
|
11
|
-
import { COMMENT_IMAGE_ACCEPT, prepareCommentImage } from './images';
|
|
12
|
-
import { DiscussionIcon } from './icons';
|
|
13
|
-
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
14
|
-
import { LockKeyhole, X } from 'lucide-react';
|
|
15
|
-
|
|
16
|
-
let modulePromise: Promise<typeof import('./editor')> | undefined;
|
|
17
|
-
function loadEditor() {
|
|
18
|
-
// Reset rejected imports so explicit retry actually executes import again.
|
|
19
|
-
return (modulePromise ||= import('./editor').catch((error) => {
|
|
20
|
-
modulePromise = undefined;
|
|
21
|
-
throw error;
|
|
22
|
-
}));
|
|
23
|
-
}
|
|
24
|
-
export function preloadCommentEditor() {
|
|
25
|
-
void loadEditor().catch(() => {});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Public entry contains no static import of Tiptap. A session change resets all work. */
|
|
29
|
-
export function CommentInput(props: CommentInputProps) {
|
|
30
|
-
const { store } = useMentionContext();
|
|
31
|
-
try {
|
|
32
|
-
normalizeValue(props.value);
|
|
33
|
-
} catch {
|
|
34
|
-
return (
|
|
35
|
-
<p role="alert" className="text-sm text-destructive">
|
|
36
|
-
评论内容格式无效,无法编辑
|
|
37
|
-
</p>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
return (
|
|
41
|
-
<ErrorBoundary
|
|
42
|
-
key={JSON.stringify([store.source.scopeKey, props.documentKey])}
|
|
43
|
-
fallbackRender={({ resetErrorBoundary }) => (
|
|
44
|
-
<div
|
|
45
|
-
role="alert"
|
|
46
|
-
className="rounded-lg border p-3 text-sm text-destructive"
|
|
47
|
-
>
|
|
48
|
-
编辑器发生错误,输入内容已保留。
|
|
49
|
-
<Button
|
|
50
|
-
type="button"
|
|
51
|
-
variant="outline"
|
|
52
|
-
size="sm"
|
|
53
|
-
onClick={resetErrorBoundary}
|
|
54
|
-
>
|
|
55
|
-
重新加载
|
|
56
|
-
</Button>
|
|
57
|
-
</div>
|
|
58
|
-
)}
|
|
59
|
-
>
|
|
60
|
-
<InputSession {...props} />
|
|
61
|
-
</ErrorBoundary>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function InputSession(props: CommentInputProps) {
|
|
66
|
-
const [Editor, setEditor] =
|
|
67
|
-
React.useState<ComponentType<CommentEditorProps> | null>(null);
|
|
68
|
-
const latest = useRef(props);
|
|
69
|
-
latest.current = props;
|
|
70
|
-
const editorRef = useRef<CommentInputHandle | null>(null);
|
|
71
|
-
const editable = useRef(true);
|
|
72
|
-
const [enabled, setEnabled] = useState(true);
|
|
73
|
-
const loadingRef = useRef(false);
|
|
74
|
-
const [loading, setLoading] = useState(false);
|
|
75
|
-
const [error, setError] = useState('');
|
|
76
|
-
const alive = useRef(true);
|
|
77
|
-
useEffect(() => {
|
|
78
|
-
alive.current = true;
|
|
79
|
-
return () => {
|
|
80
|
-
alive.current = false;
|
|
81
|
-
};
|
|
82
|
-
}, []);
|
|
83
|
-
const activate = useCallback(async () => {
|
|
84
|
-
if (loadingRef.current || latest.current.disabled || !editable.current)
|
|
85
|
-
return;
|
|
86
|
-
loadingRef.current = true;
|
|
87
|
-
setLoading(true);
|
|
88
|
-
setError('');
|
|
89
|
-
try {
|
|
90
|
-
const module = await loadEditor();
|
|
91
|
-
if (alive.current) setEditor(() => module.default);
|
|
92
|
-
} catch {
|
|
93
|
-
if (alive.current) setError('编辑器加载失败,请重试');
|
|
94
|
-
} finally {
|
|
95
|
-
loadingRef.current = false;
|
|
96
|
-
if (alive.current) setLoading(false);
|
|
97
|
-
}
|
|
98
|
-
}, []);
|
|
99
|
-
useImperativeHandle(
|
|
100
|
-
props.ref,
|
|
101
|
-
() => ({
|
|
102
|
-
getContent: () =>
|
|
103
|
-
editorRef.current?.getContent() ?? readApiContent(latest.current.value),
|
|
104
|
-
clear() {
|
|
105
|
-
if (!alive.current) return;
|
|
106
|
-
if (editorRef.current) editorRef.current.clear();
|
|
107
|
-
else {
|
|
108
|
-
const value = emptyComment();
|
|
109
|
-
latest.current = { ...latest.current, value };
|
|
110
|
-
latest.current.onChange(value);
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
setEditable(next) {
|
|
114
|
-
if (!alive.current) return;
|
|
115
|
-
editable.current = next;
|
|
116
|
-
editorRef.current?.setEditable(next && !latest.current.disabled);
|
|
117
|
-
setEnabled(next);
|
|
118
|
-
},
|
|
119
|
-
focus() {
|
|
120
|
-
if (!alive.current) return;
|
|
121
|
-
if (editorRef.current) editorRef.current.focus();
|
|
122
|
-
else void activate();
|
|
123
|
-
},
|
|
124
|
-
}),
|
|
125
|
-
[activate],
|
|
126
|
-
);
|
|
127
|
-
const { ref: _ref, ...editorProps } = props;
|
|
128
|
-
if (Editor)
|
|
129
|
-
return (
|
|
130
|
-
<Editor
|
|
131
|
-
{...editorProps}
|
|
132
|
-
disabled={props.disabled || !enabled}
|
|
133
|
-
editorRef={editorRef}
|
|
134
|
-
/>
|
|
135
|
-
);
|
|
136
|
-
return (
|
|
137
|
-
<div className={cn(COMMENT_THEME_CLASS, 'replica-input', props.className)}>
|
|
138
|
-
<div className="relative 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">
|
|
139
|
-
<div
|
|
140
|
-
aria-hidden
|
|
141
|
-
className="pointer-events-none w-full text-sm leading-[22px]"
|
|
142
|
-
>
|
|
143
|
-
{loading || error ? (
|
|
144
|
-
<span className="text-muted-foreground">
|
|
145
|
-
{loading ? '正在加载编辑器…' : '重试加载编辑器'}
|
|
146
|
-
</span>
|
|
147
|
-
) : props.value.content.length ? (
|
|
148
|
-
<CommentContent value={props.value} mentionVariant="pill" />
|
|
149
|
-
) : (
|
|
150
|
-
<span className="text-muted-foreground">
|
|
151
|
-
{props.placeholder || '输入评论'}
|
|
152
|
-
</span>
|
|
153
|
-
)}
|
|
154
|
-
</div>
|
|
155
|
-
<Button
|
|
156
|
-
type="button"
|
|
157
|
-
variant="ghost"
|
|
158
|
-
className="no-default-hover-elevate no-default-active-elevate absolute inset-0 h-full min-h-0 w-full rounded-[6px] p-0"
|
|
159
|
-
disabled={props.disabled || !enabled || loading}
|
|
160
|
-
aria-busy={loading}
|
|
161
|
-
onPointerEnter={preloadCommentEditor}
|
|
162
|
-
onFocus={preloadCommentEditor}
|
|
163
|
-
onClick={() => {
|
|
164
|
-
void activate();
|
|
165
|
-
}}
|
|
166
|
-
>
|
|
167
|
-
<span className="sr-only">
|
|
168
|
-
{error ? '重试加载编辑器' : props.placeholder || '输入评论'}
|
|
169
|
-
</span>
|
|
170
|
-
</Button>
|
|
171
|
-
</div>
|
|
172
|
-
{error ? (
|
|
173
|
-
<p role="alert" className="mt-2 text-xs text-destructive">
|
|
174
|
-
{error}
|
|
175
|
-
</p>
|
|
176
|
-
) : null}
|
|
177
|
-
</div>
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function DiscussionComposer({
|
|
182
|
-
sessionKey,
|
|
183
|
-
initialValue,
|
|
184
|
-
initialImages = [],
|
|
185
|
-
placeholder = '添加新的评论...',
|
|
186
|
-
replyName,
|
|
187
|
-
replyMention,
|
|
188
|
-
replyIntentKey,
|
|
189
|
-
commentID,
|
|
190
|
-
replyToMessageId,
|
|
191
|
-
onCancel,
|
|
192
|
-
onSubmit,
|
|
193
|
-
disabled,
|
|
194
|
-
enableDocuments,
|
|
195
|
-
editing = false,
|
|
196
|
-
enableImages = true,
|
|
197
|
-
enablePrivateComments = true,
|
|
198
|
-
}: {
|
|
199
|
-
sessionKey: string;
|
|
200
|
-
initialValue?: CommentValue;
|
|
201
|
-
initialImages?: DiscussionImage[];
|
|
202
|
-
placeholder?: string;
|
|
203
|
-
replyName?: string;
|
|
204
|
-
replyIntentKey?: string;
|
|
205
|
-
commentID?: string;
|
|
206
|
-
replyToMessageId?: string;
|
|
207
|
-
replyMention?: Extract<CommentValue['content'][number], { type: 'mention' }>;
|
|
208
|
-
onCancel?: () => void;
|
|
209
|
-
onSubmit: (draft: DiscussionDraft) => Promise<void> | void;
|
|
210
|
-
disabled?: boolean;
|
|
211
|
-
enableDocuments?: boolean;
|
|
212
|
-
editing?: boolean;
|
|
213
|
-
enableImages?: boolean;
|
|
214
|
-
enablePrivateComments?: boolean;
|
|
215
|
-
}) {
|
|
216
|
-
const [value, setValue] = useState<CommentValue>(
|
|
217
|
-
initialValue || emptyComment,
|
|
218
|
-
);
|
|
219
|
-
const [images, setImages] = useState(initialImages);
|
|
220
|
-
const [privateComment, setPrivate] = useState(false);
|
|
221
|
-
const [sending, setSending] = useState(false);
|
|
222
|
-
const [validating, setValidating] = useState(0);
|
|
223
|
-
const [error, setError] = useState('');
|
|
224
|
-
const input = useRef<CommentInputHandle>(null);
|
|
225
|
-
const fileInput = useRef<HTMLInputElement>(null);
|
|
226
|
-
const busy = useRef(false);
|
|
227
|
-
const pending = useRef(0);
|
|
228
|
-
const alive = useRef(true);
|
|
229
|
-
const ownedUrls = useRef(new Set<string>());
|
|
230
|
-
const ready =
|
|
231
|
-
images.length > 0 ||
|
|
232
|
-
value.content.some(
|
|
233
|
-
(part) =>
|
|
234
|
-
part.type === 'mention' ||
|
|
235
|
-
part.type === 'link' ||
|
|
236
|
-
(part.type === 'text' && part.text.trim()),
|
|
237
|
-
);
|
|
238
|
-
useEffect(() => {
|
|
239
|
-
alive.current = true;
|
|
240
|
-
return () => {
|
|
241
|
-
alive.current = false;
|
|
242
|
-
ownedUrls.current.forEach((url) => URL.revokeObjectURL(url));
|
|
243
|
-
};
|
|
244
|
-
}, []);
|
|
245
|
-
useEffect(() => {
|
|
246
|
-
if (editing || replyName) input.current?.focus();
|
|
247
|
-
}, [editing, replyName]);
|
|
248
|
-
const latestReplyMention = useRef(replyMention);
|
|
249
|
-
latestReplyMention.current = replyMention;
|
|
250
|
-
const latestValue = useRef(value);
|
|
251
|
-
latestValue.current = value;
|
|
252
|
-
const focusAfterReply = useRef(false);
|
|
253
|
-
useEffect(() => {
|
|
254
|
-
if (!replyIntentKey) return;
|
|
255
|
-
const mention = latestReplyMention.current;
|
|
256
|
-
const first = latestValue.current.content[0];
|
|
257
|
-
if (
|
|
258
|
-
mention &&
|
|
259
|
-
!(first?.type === 'mention' && first.ref.id === mention.ref.id)
|
|
260
|
-
) {
|
|
261
|
-
focusAfterReply.current = true;
|
|
262
|
-
setValue((previous) => ({
|
|
263
|
-
version: 1,
|
|
264
|
-
content: [mention, { type: 'text', text: ' ' }, ...previous.content],
|
|
265
|
-
}));
|
|
266
|
-
} else input.current?.focus();
|
|
267
|
-
}, [replyIntentKey]);
|
|
268
|
-
useEffect(() => {
|
|
269
|
-
if (focusAfterReply.current) {
|
|
270
|
-
focusAfterReply.current = false;
|
|
271
|
-
input.current?.focus();
|
|
272
|
-
}
|
|
273
|
-
}, [value]);
|
|
274
|
-
const submit = async () => {
|
|
275
|
-
if (!ready || busy.current || pending.current || disabled) return;
|
|
276
|
-
busy.current = true;
|
|
277
|
-
setSending(true);
|
|
278
|
-
setError('');
|
|
279
|
-
input.current?.setEditable(false);
|
|
280
|
-
// The host owns successful attachments, including when submitting unmounts this editor.
|
|
281
|
-
const transferred = images.filter((image) =>
|
|
282
|
-
ownedUrls.current.delete(image.url),
|
|
283
|
-
);
|
|
284
|
-
try {
|
|
285
|
-
await onSubmit({ value, images, private: privateComment, commentID, replyToMessageId });
|
|
286
|
-
if (!alive.current) return;
|
|
287
|
-
setImages([]);
|
|
288
|
-
setValue(emptyComment());
|
|
289
|
-
input.current?.clear();
|
|
290
|
-
setPrivate(false);
|
|
291
|
-
} catch (cause) {
|
|
292
|
-
transferred.forEach((image) => {
|
|
293
|
-
if (alive.current) ownedUrls.current.add(image.url);
|
|
294
|
-
else URL.revokeObjectURL(image.url);
|
|
295
|
-
});
|
|
296
|
-
if (alive.current)
|
|
297
|
-
setError(commentFailureMessage(cause, '发送失败,请重试'));
|
|
298
|
-
} finally {
|
|
299
|
-
busy.current = false;
|
|
300
|
-
if (alive.current) {
|
|
301
|
-
setSending(false);
|
|
302
|
-
input.current?.setEditable(true);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
};
|
|
306
|
-
const addFiles = async (files: FileList | File[]) => {
|
|
307
|
-
if (!enableImages || disabled || busy.current) return;
|
|
308
|
-
pending.current++;
|
|
309
|
-
setValidating(pending.current);
|
|
310
|
-
setError('');
|
|
311
|
-
const result = await Promise.allSettled(
|
|
312
|
-
Array.from(files).map((file) =>
|
|
313
|
-
prepareCommentImage(file, ownedUrls.current),
|
|
314
|
-
),
|
|
315
|
-
);
|
|
316
|
-
const accepted = result.flatMap((item) =>
|
|
317
|
-
item.status === 'fulfilled' ? [item.value] : [],
|
|
318
|
-
);
|
|
319
|
-
const failures = result.flatMap((item) =>
|
|
320
|
-
item.status === 'rejected'
|
|
321
|
-
? [item.reason instanceof Error ? item.reason.message : '图片无法读取']
|
|
322
|
-
: [],
|
|
323
|
-
);
|
|
324
|
-
pending.current--;
|
|
325
|
-
if (!alive.current) {
|
|
326
|
-
accepted.forEach((image) => {
|
|
327
|
-
ownedUrls.current.delete(image.url);
|
|
328
|
-
URL.revokeObjectURL(image.url);
|
|
329
|
-
});
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
setImages((previous) => [...previous, ...accepted]);
|
|
333
|
-
setError(failures.join(';'));
|
|
334
|
-
setValidating(pending.current);
|
|
335
|
-
input.current?.focus();
|
|
336
|
-
};
|
|
337
|
-
const removeImage = (image: DiscussionImage) => {
|
|
338
|
-
if (ownedUrls.current.delete(image.url)) URL.revokeObjectURL(image.url);
|
|
339
|
-
setImages((previous) => previous.filter((item) => item.id !== image.id));
|
|
340
|
-
};
|
|
341
|
-
const blocked = disabled || sending;
|
|
342
|
-
return (
|
|
343
|
-
<div
|
|
344
|
-
className={`replica-composer-area ${editing ? 'replica-edit-area' : ''}`}
|
|
345
|
-
onKeyDown={(event) => {
|
|
346
|
-
// Escape may dismiss suggestions or a file chooser; it must not detach a reply.
|
|
347
|
-
if (
|
|
348
|
-
event.key === 'Escape' &&
|
|
349
|
-
!event.defaultPrevented &&
|
|
350
|
-
editing &&
|
|
351
|
-
!busy.current
|
|
352
|
-
)
|
|
353
|
-
onCancel?.();
|
|
354
|
-
}}
|
|
355
|
-
>
|
|
356
|
-
<div
|
|
357
|
-
className={`replica-composer ${editing ? 'replica-composer-edit' : ''} ${privateComment ? 'replica-composer-private' : ''}`}
|
|
358
|
-
onPasteCapture={(event) => {
|
|
359
|
-
if (blocked || !enableImages) return;
|
|
360
|
-
if (event.clipboardData.files.length) {
|
|
361
|
-
event.preventDefault();
|
|
362
|
-
event.stopPropagation();
|
|
363
|
-
void addFiles(event.clipboardData.files);
|
|
364
|
-
}
|
|
365
|
-
}}
|
|
366
|
-
onDragOver={(event) => {
|
|
367
|
-
if (enableImages && event.dataTransfer.types.includes('Files'))
|
|
368
|
-
event.preventDefault();
|
|
369
|
-
}}
|
|
370
|
-
onDropCapture={(event) => {
|
|
371
|
-
if (enableImages && event.dataTransfer.files.length) {
|
|
372
|
-
event.preventDefault();
|
|
373
|
-
event.stopPropagation();
|
|
374
|
-
if (!blocked) void addFiles(event.dataTransfer.files);
|
|
375
|
-
}
|
|
376
|
-
}}
|
|
377
|
-
>
|
|
378
|
-
<CommentInput
|
|
379
|
-
ref={input}
|
|
380
|
-
documentKey={sessionKey}
|
|
381
|
-
value={value}
|
|
382
|
-
onChange={setValue}
|
|
383
|
-
placeholder={placeholder}
|
|
384
|
-
disabled={blocked}
|
|
385
|
-
enableDocuments={enableDocuments}
|
|
386
|
-
submitMode="mod-enter"
|
|
387
|
-
onSubmitRequest={() => {
|
|
388
|
-
void submit();
|
|
389
|
-
}}
|
|
390
|
-
/>
|
|
391
|
-
{images.length > 0 && (
|
|
392
|
-
<div className="replica-draft-images">
|
|
393
|
-
{images.map((image) => (
|
|
394
|
-
<div key={image.id} className="replica-draft-image">
|
|
395
|
-
<img src={image.url} alt={image.name} />
|
|
396
|
-
<button
|
|
397
|
-
type="button"
|
|
398
|
-
aria-label={`移除图片 ${image.name}`}
|
|
399
|
-
disabled={sending}
|
|
400
|
-
onClick={() => removeImage(image)}
|
|
401
|
-
>
|
|
402
|
-
<span>
|
|
403
|
-
<X size={13} />
|
|
404
|
-
</span>
|
|
405
|
-
</button>
|
|
406
|
-
</div>
|
|
407
|
-
))}
|
|
408
|
-
</div>
|
|
409
|
-
)}
|
|
410
|
-
<div className="replica-composer-toolbar">
|
|
411
|
-
{privateComment && (
|
|
412
|
-
<span className="replica-private-hint">
|
|
413
|
-
<LockKeyhole size={12} />
|
|
414
|
-
私有评论
|
|
415
|
-
</span>
|
|
416
|
-
)}
|
|
417
|
-
<div className="replica-composer-tools">
|
|
418
|
-
{enableImages && <><button
|
|
419
|
-
type="button"
|
|
420
|
-
className="replica-icon-button"
|
|
421
|
-
title="添加图片"
|
|
422
|
-
aria-label="添加图片"
|
|
423
|
-
disabled={blocked}
|
|
424
|
-
onClick={() => fileInput.current?.click()}
|
|
425
|
-
>
|
|
426
|
-
<DiscussionIcon name="image" width={16} height={16} />
|
|
427
|
-
</button>
|
|
428
|
-
<input
|
|
429
|
-
ref={fileInput}
|
|
430
|
-
type="file"
|
|
431
|
-
hidden
|
|
432
|
-
multiple
|
|
433
|
-
accept={COMMENT_IMAGE_ACCEPT}
|
|
434
|
-
onChange={(event) => {
|
|
435
|
-
if (event.target.files) void addFiles(event.target.files);
|
|
436
|
-
event.target.value = '';
|
|
437
|
-
}}
|
|
438
|
-
/>
|
|
439
|
-
</>}
|
|
440
|
-
{enablePrivateComments && !replyName && !editing && (
|
|
441
|
-
<Popover>
|
|
442
|
-
<PopoverTrigger asChild>
|
|
443
|
-
<button
|
|
444
|
-
type="button"
|
|
445
|
-
className="replica-icon-button"
|
|
446
|
-
title="更多"
|
|
447
|
-
aria-label="评论设置"
|
|
448
|
-
disabled={blocked}
|
|
449
|
-
>
|
|
450
|
-
<DiscussionIcon name="more" />
|
|
451
|
-
</button>
|
|
452
|
-
</PopoverTrigger>
|
|
453
|
-
<PopoverContent
|
|
454
|
-
data-replica-comment-overlay
|
|
455
|
-
side="top"
|
|
456
|
-
align="end"
|
|
457
|
-
sideOffset={8}
|
|
458
|
-
className={`${COMMENT_THEME_CLASS} replica-private-menu`}
|
|
459
|
-
>
|
|
460
|
-
<div className="replica-private-heading">
|
|
461
|
-
<LockKeyhole size={20} />
|
|
462
|
-
<span>私有评论</span>
|
|
463
|
-
<button
|
|
464
|
-
type="button"
|
|
465
|
-
className="replica-switch"
|
|
466
|
-
role="switch"
|
|
467
|
-
aria-label="私有评论"
|
|
468
|
-
aria-checked={privateComment}
|
|
469
|
-
onClick={() => setPrivate((previous) => !previous)}
|
|
470
|
-
>
|
|
471
|
-
<span />
|
|
472
|
-
</button>
|
|
473
|
-
</div>
|
|
474
|
-
<p>
|
|
475
|
-
开启后,仅提及的协作者、多维表格所有者、有多维表格管理权限的用户、记录创建人和最近修改人可见。
|
|
476
|
-
</p>
|
|
477
|
-
</PopoverContent>
|
|
478
|
-
</Popover>
|
|
479
|
-
)}
|
|
480
|
-
{!editing && (
|
|
481
|
-
<>
|
|
482
|
-
{(enableImages || (enablePrivateComments && !replyName)) && (
|
|
483
|
-
<span className="replica-toolbar-divider" />
|
|
484
|
-
)}
|
|
485
|
-
<button
|
|
486
|
-
type="button"
|
|
487
|
-
className="replica-send-button"
|
|
488
|
-
title="发送(⌘/Ctrl+Enter)"
|
|
489
|
-
aria-label="发送评论"
|
|
490
|
-
disabled={!ready || blocked || validating > 0}
|
|
491
|
-
onClick={() => {
|
|
492
|
-
void submit();
|
|
493
|
-
}}
|
|
494
|
-
>
|
|
495
|
-
<DiscussionIcon name="send" width={16} height={16} />
|
|
496
|
-
</button>
|
|
497
|
-
</>
|
|
498
|
-
)}
|
|
499
|
-
</div>
|
|
500
|
-
</div>
|
|
501
|
-
</div>
|
|
502
|
-
{editing && (
|
|
503
|
-
<div className="replica-edit-actions">
|
|
504
|
-
<button type="button" disabled={sending} onClick={onCancel}>
|
|
505
|
-
取消
|
|
506
|
-
</button>
|
|
507
|
-
<button
|
|
508
|
-
type="button"
|
|
509
|
-
aria-label="确认编辑"
|
|
510
|
-
disabled={!ready || blocked || validating > 0}
|
|
511
|
-
onClick={() => {
|
|
512
|
-
void submit();
|
|
513
|
-
}}
|
|
514
|
-
>
|
|
515
|
-
确认
|
|
516
|
-
</button>
|
|
517
|
-
</div>
|
|
518
|
-
)}
|
|
519
|
-
{validating > 0 && (
|
|
520
|
-
<span role="status" className="sr-only">
|
|
521
|
-
正在读取图片
|
|
522
|
-
</span>
|
|
523
|
-
)}
|
|
524
|
-
{error && (
|
|
525
|
-
<p className="replica-error" role="alert">
|
|
526
|
-
{error}
|
|
527
|
-
</p>
|
|
528
|
-
)}
|
|
529
|
-
</div>
|
|
530
|
-
);
|
|
531
|
-
}
|