@lark-apaas/coding-template-nestjs-react-fullstack 0.1.55-alpha.20261101083729 → 0.1.55
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 +13 -529
- package/template/package.json +4 -15
- package/template/client/src/components/business-ui/record-comments/README.md +0 -83
- package/template/client/src/components/business-ui/record-comments/app-source.ts +0 -182
- 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 -223
- package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +0 -202
- package/template/client/src/components/business-ui/record-comments/index.ts +0 -7
- package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +0 -529
- 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 -535
- package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +0 -675
- 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 -170
- 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 -681
- 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 -82
- package/template/client/src/components/business-ui/record-comments/types.ts +0 -118
|
@@ -1,581 +0,0 @@
|
|
|
1
|
-
import type { CommentPart, CommentApiContent, CommentValue, MentionRef, MentionResult } from '../types';
|
|
2
|
-
import type { CommentImageInput, CommentPanelProps, CommentPagination, CommentTarget, CommentWriteContent } from '../comment-panel.contract';
|
|
3
|
-
import type { DiscussionDraft, DiscussionImage, DiscussionThread } from './view-types';
|
|
4
|
-
|
|
5
|
-
export const emptyComment = (): CommentValue => ({ version: 1, content: [] });
|
|
6
|
-
|
|
7
|
-
export function mentionKey(ref: MentionRef): string {
|
|
8
|
-
return JSON.stringify([
|
|
9
|
-
ref.kind,
|
|
10
|
-
ref.kind === 'user' ? ref.idType : ref.resourceType,
|
|
11
|
-
ref.id,
|
|
12
|
-
]);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function isMentionRef(value: unknown): value is MentionRef {
|
|
16
|
-
if (!value || typeof value !== 'object') return false;
|
|
17
|
-
const ref = value as Record<string, unknown>;
|
|
18
|
-
return (
|
|
19
|
-
typeof ref.id === 'string' &&
|
|
20
|
-
ref.id.length > 0 &&
|
|
21
|
-
((ref.kind === 'user' && ['apaas-user', 'lark-user', 'snapshot'].includes(String(ref.idType))) ||
|
|
22
|
-
(ref.kind === 'document' &&
|
|
23
|
-
typeof ref.resourceType === 'string' &&
|
|
24
|
-
ref.resourceType.length > 0))
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function safeUrl(url?: string): string | undefined {
|
|
29
|
-
if (!url) return undefined;
|
|
30
|
-
if (
|
|
31
|
-
url.startsWith('/') &&
|
|
32
|
-
!url.startsWith('//') &&
|
|
33
|
-
!/[\\\s\u0000-\u001f]/.test(url)
|
|
34
|
-
)
|
|
35
|
-
return url;
|
|
36
|
-
try {
|
|
37
|
-
const parsed = new URL(url);
|
|
38
|
-
return parsed.protocol === 'https:' || parsed.protocol === 'http:'
|
|
39
|
-
? parsed.href
|
|
40
|
-
: undefined;
|
|
41
|
-
} catch {
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function normalizeValue(value: CommentValue): CommentValue {
|
|
47
|
-
if (!value || value.version !== 1 || !Array.isArray(value.content)) {
|
|
48
|
-
throw new Error('不支持的评论内容格式');
|
|
49
|
-
}
|
|
50
|
-
const content: CommentPart[] = [];
|
|
51
|
-
const text = (s: string) => {
|
|
52
|
-
if (!s) return;
|
|
53
|
-
const previous = content[content.length - 1];
|
|
54
|
-
if (previous?.type === 'text') previous.text += s;
|
|
55
|
-
else content.push({ type: 'text', text: s });
|
|
56
|
-
};
|
|
57
|
-
for (const part of value.content) {
|
|
58
|
-
if (part.type === 'text' && typeof part.text === 'string') {
|
|
59
|
-
part.text
|
|
60
|
-
.replace(/\r\n?/g, '\n')
|
|
61
|
-
.split('\n')
|
|
62
|
-
.forEach((line, i) => {
|
|
63
|
-
if (i) content.push({ type: 'break' });
|
|
64
|
-
text(line);
|
|
65
|
-
});
|
|
66
|
-
} else if (part.type === 'break') content.push({ type: 'break' });
|
|
67
|
-
else if (part.type === 'link' && typeof part.url === 'string')
|
|
68
|
-
content.push({ type: 'link', url: part.url });
|
|
69
|
-
else if (part.type === 'unresolved-mention')
|
|
70
|
-
content.push({ type: 'unresolved-mention' });
|
|
71
|
-
else if (part.type === 'mention' && isMentionRef(part.ref)) {
|
|
72
|
-
if (part.label !== undefined && typeof part.label !== 'string')
|
|
73
|
-
throw new Error('评论引用名称无效');
|
|
74
|
-
content.push({
|
|
75
|
-
type: 'mention',
|
|
76
|
-
ref: { ...part.ref },
|
|
77
|
-
...(part.label ? { label: part.label } : {}),
|
|
78
|
-
...(part.ref.kind === 'document' ? documentSnapshot(part) : {}),
|
|
79
|
-
});
|
|
80
|
-
} else throw new Error('评论内容包含无效节点');
|
|
81
|
-
}
|
|
82
|
-
return { version: 1, content };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Preserve document presentation through editing/clipboard without trusting HTML or URLs. */
|
|
86
|
-
export function documentSnapshot(value: {
|
|
87
|
-
href?: unknown;
|
|
88
|
-
document?: unknown;
|
|
89
|
-
}) {
|
|
90
|
-
const href = typeof value.href === 'string' ? safeUrl(value.href) : undefined;
|
|
91
|
-
const meta =
|
|
92
|
-
value.document && typeof value.document === 'object'
|
|
93
|
-
? (value.document as Record<string, unknown>)
|
|
94
|
-
: {};
|
|
95
|
-
const subType =
|
|
96
|
-
typeof meta.subType === 'number' && Number.isSafeInteger(meta.subType)
|
|
97
|
-
? meta.subType
|
|
98
|
-
: undefined;
|
|
99
|
-
const iconInfo =
|
|
100
|
-
typeof meta.iconInfo === 'string' ? meta.iconInfo : undefined;
|
|
101
|
-
return {
|
|
102
|
-
...(href ? { href } : {}),
|
|
103
|
-
...(subType !== undefined || iconInfo
|
|
104
|
-
? {
|
|
105
|
-
document: {
|
|
106
|
-
...(subType !== undefined ? { subType } : {}),
|
|
107
|
-
...(iconInfo ? { iconInfo } : {}),
|
|
108
|
-
},
|
|
109
|
-
}
|
|
110
|
-
: {}),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function toPlainText(value: CommentValue): string {
|
|
115
|
-
return value.content
|
|
116
|
-
.map((part) =>
|
|
117
|
-
part.type === 'text'
|
|
118
|
-
? part.text
|
|
119
|
-
: part.type === 'break'
|
|
120
|
-
? '\n'
|
|
121
|
-
: part.type === 'link'
|
|
122
|
-
? part.url
|
|
123
|
-
: part.type === 'unresolved-mention'
|
|
124
|
-
? '引用信息缺失'
|
|
125
|
-
: part.ref.kind === 'user'
|
|
126
|
-
? `@${part.label || '用户'}`
|
|
127
|
-
: part.label || part.href || '文档',
|
|
128
|
-
)
|
|
129
|
-
.join('');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function collectMentionRefs(value: CommentValue): MentionRef[] {
|
|
133
|
-
const refs = new Map<string, MentionRef>();
|
|
134
|
-
for (const part of value.content)
|
|
135
|
-
if (part.type === 'mention') refs.set(mentionKey(part.ref), part.ref);
|
|
136
|
-
return [...refs.values()];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function mentionLabel(
|
|
140
|
-
ref: MentionRef,
|
|
141
|
-
label: string | undefined,
|
|
142
|
-
result?: MentionResult,
|
|
143
|
-
strict = false,
|
|
144
|
-
): string {
|
|
145
|
-
if (result?.status === 'resolved')
|
|
146
|
-
return result.label || (ref.kind === 'user' ? '用户' : '文档');
|
|
147
|
-
if (result?.status === 'forbidden')
|
|
148
|
-
return ref.kind === 'user' ? '无权查看的用户' : '无权查看的文档';
|
|
149
|
-
if (result?.status === 'not-found')
|
|
150
|
-
return ref.kind === 'user' ? '用户不存在' : '文档已失效';
|
|
151
|
-
return (!strict && label) || (ref.kind === 'user' ? '用户' : '文档');
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Deliberately structural: these utilities have no runtime or type import from Tiptap. */
|
|
155
|
-
export interface EditorDocument {
|
|
156
|
-
type?: string;
|
|
157
|
-
text?: string;
|
|
158
|
-
attrs?: Record<string, unknown>;
|
|
159
|
-
content?: EditorDocument[];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export function toEditorDocument(value: CommentValue): EditorDocument {
|
|
163
|
-
return {
|
|
164
|
-
type: 'doc',
|
|
165
|
-
content: [
|
|
166
|
-
{
|
|
167
|
-
type: 'paragraph',
|
|
168
|
-
content: normalizeValue(value).content.map((part) =>
|
|
169
|
-
part.type === 'text'
|
|
170
|
-
? { type: 'text', text: part.text }
|
|
171
|
-
: part.type === 'break'
|
|
172
|
-
? { type: 'hardBreak' }
|
|
173
|
-
: part.type === 'link'
|
|
174
|
-
? { type: 'commentLink', attrs: { url: part.url } }
|
|
175
|
-
: part.type === 'unresolved-mention'
|
|
176
|
-
? { type: 'unresolvedMention' }
|
|
177
|
-
: {
|
|
178
|
-
type: 'entityMention',
|
|
179
|
-
attrs: {
|
|
180
|
-
ref: part.ref,
|
|
181
|
-
label: part.label || null,
|
|
182
|
-
href: null,
|
|
183
|
-
document: null,
|
|
184
|
-
...documentSnapshot(part),
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
),
|
|
188
|
-
},
|
|
189
|
-
],
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function fromEditorDocument(doc: EditorDocument): CommentValue {
|
|
194
|
-
if (doc.type !== 'doc') throw new Error('无效的评论编辑文档');
|
|
195
|
-
const content: CommentPart[] = [];
|
|
196
|
-
for (const [i, paragraph] of (doc.content || []).entries()) {
|
|
197
|
-
if (paragraph.type !== 'paragraph') throw new Error('无效的评论段落');
|
|
198
|
-
if (i) content.push({ type: 'break' });
|
|
199
|
-
for (const node of paragraph.content || []) {
|
|
200
|
-
if (node.type === 'text')
|
|
201
|
-
content.push({ type: 'text', text: node.text || '' });
|
|
202
|
-
else if (node.type === 'hardBreak') content.push({ type: 'break' });
|
|
203
|
-
else if (
|
|
204
|
-
node.type === 'commentLink' &&
|
|
205
|
-
typeof node.attrs?.url === 'string'
|
|
206
|
-
)
|
|
207
|
-
content.push({ type: 'link', url: node.attrs.url });
|
|
208
|
-
else if (node.type === 'unresolvedMention')
|
|
209
|
-
content.push({ type: 'unresolved-mention' });
|
|
210
|
-
else if (node.type === 'entityMention' && isMentionRef(node.attrs?.ref)) {
|
|
211
|
-
content.push({
|
|
212
|
-
type: 'mention',
|
|
213
|
-
ref: node.attrs.ref,
|
|
214
|
-
label:
|
|
215
|
-
typeof node.attrs.label === 'string' ? node.attrs.label : undefined,
|
|
216
|
-
...documentSnapshot({
|
|
217
|
-
href: node.attrs.href,
|
|
218
|
-
document: node.attrs.document,
|
|
219
|
-
}),
|
|
220
|
-
});
|
|
221
|
-
} else throw new Error('无效的评论编辑节点');
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return normalizeValue({ version: 1, content });
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const types: Record<string, string> = {
|
|
228
|
-
doc: '1',
|
|
229
|
-
docx: '22',
|
|
230
|
-
sheets: '3',
|
|
231
|
-
sheet: '3',
|
|
232
|
-
base: '8',
|
|
233
|
-
bitable: '8',
|
|
234
|
-
wiki: '16',
|
|
235
|
-
mindnotes: '11',
|
|
236
|
-
mindnote: '11',
|
|
237
|
-
slides: '30',
|
|
238
|
-
file: '12',
|
|
239
|
-
whiteboard: '38',
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
/** Only recognised cloud-document hosts/routes become metadata references. Other URLs stay links. */
|
|
243
|
-
export function documentRefFromUrl(url: string): MentionRef | undefined {
|
|
244
|
-
try {
|
|
245
|
-
const parsed = new URL(url);
|
|
246
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
|
|
247
|
-
return undefined;
|
|
248
|
-
if (
|
|
249
|
-
!['feishu.cn', 'feishu-boe.cn', 'larkoffice.com', 'larksuite.com', 'doubao.com'].some(
|
|
250
|
-
(domain) =>
|
|
251
|
-
parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`),
|
|
252
|
-
)
|
|
253
|
-
)
|
|
254
|
-
return undefined;
|
|
255
|
-
const match =
|
|
256
|
-
/^\/(doc|docx|sheets?|base|bitable|wiki|mindnotes?|slides|file|whiteboard)\/([a-zA-Z0-9_-]+)(?:\/|$)/.exec(
|
|
257
|
-
parsed.pathname,
|
|
258
|
-
);
|
|
259
|
-
return match
|
|
260
|
-
? { kind: 'document', id: match[2], resourceType: types[match[1]] }
|
|
261
|
-
: undefined;
|
|
262
|
-
} catch {
|
|
263
|
-
return undefined;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Read the exact text/mention/link protocol without send-state validation. */
|
|
268
|
-
export function readApiContent(value: CommentValue): CommentApiContent[] {
|
|
269
|
-
const content: CommentApiContent[] = [];
|
|
270
|
-
for (const part of normalizeValue(value).content) {
|
|
271
|
-
if (part.type === 'text' || part.type === 'break') {
|
|
272
|
-
const text = part.type === 'text' ? part.text : '\n';
|
|
273
|
-
const previous = content.at(-1);
|
|
274
|
-
if (previous?.type === 'text') previous.text += text;
|
|
275
|
-
else content.push({ type: 'text', text });
|
|
276
|
-
} else if (part.type === 'unresolved-mention')
|
|
277
|
-
content.push({ type: 'mention' });
|
|
278
|
-
else if (part.type === 'link')
|
|
279
|
-
content.push({ type: 'link', url: part.url });
|
|
280
|
-
else if (part.ref.kind === 'document')
|
|
281
|
-
content.push({ type: 'link', url: part.href || '' });
|
|
282
|
-
else {
|
|
283
|
-
if (part.ref.idType !== 'apaas-user') throw new Error('飞书/参考人员标识不能写入妙搭 userID 协议');
|
|
284
|
-
content.push({ type: 'mention', userID: apiUserID(part.ref.id) });
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return content;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function apiUserID(id: string): number {
|
|
291
|
-
const number = Number(id);
|
|
292
|
-
if (
|
|
293
|
-
!/^[1-9]\d*$/.test(id) ||
|
|
294
|
-
!Number.isSafeInteger(number) ||
|
|
295
|
-
String(number) !== id
|
|
296
|
-
)
|
|
297
|
-
throw new Error('人员 userID 必须是可精确表示的正整数');
|
|
298
|
-
return number;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export function fromApiContent(
|
|
302
|
-
content: readonly CommentApiContent[],
|
|
303
|
-
): CommentValue {
|
|
304
|
-
if (!Array.isArray(content)) throw new Error('评论 content 必须是数组');
|
|
305
|
-
const parts = content.map((part): CommentPart => {
|
|
306
|
-
if (part?.type === 'text' && typeof part.text === 'string')
|
|
307
|
-
return { type: 'text', text: part.text };
|
|
308
|
-
if (part?.type === 'mention') {
|
|
309
|
-
if (part.userID == null) return { type: 'unresolved-mention' };
|
|
310
|
-
const id = String(part.userID);
|
|
311
|
-
apiUserID(id);
|
|
312
|
-
return {
|
|
313
|
-
type: 'mention',
|
|
314
|
-
ref: { kind: 'user', id, idType: 'apaas-user' },
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
if (part?.type === 'link' && typeof part.url === 'string') {
|
|
318
|
-
const ref = documentRefFromUrl(part.url);
|
|
319
|
-
return ref
|
|
320
|
-
? { type: 'mention', ref, href: part.url }
|
|
321
|
-
: { type: 'link', url: part.url };
|
|
322
|
-
}
|
|
323
|
-
throw new Error('评论 content 包含未支持的节点');
|
|
324
|
-
});
|
|
325
|
-
return normalizeValue({ version: 1, content: parts });
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const userRef = (id?: number): MentionRef | null =>
|
|
329
|
-
id != null && Number.isSafeInteger(id) && id > 0
|
|
330
|
-
? { kind: 'user', idType: 'apaas-user', id: String(id) } : null;
|
|
331
|
-
|
|
332
|
-
export function buildPanelThreads(props: CommentPanelProps): DiscussionThread[] {
|
|
333
|
-
return props.comments.map((comment) => ({
|
|
334
|
-
commentID: comment.commentID,
|
|
335
|
-
creator: userRef(comment.creator),
|
|
336
|
-
createTime: comment.createTime,
|
|
337
|
-
isSolved: comment.isSolved,
|
|
338
|
-
replies: comment.replies.map((reply, index) => {
|
|
339
|
-
const target = { recordID: props.recordID, commentID: comment.commentID, replyID: reply.replyID };
|
|
340
|
-
const images = props.images?.getImages(target) ?? [];
|
|
341
|
-
const policy = props.getReplyPermissions?.({ ...target, comment, reply, isBody: index === 0 });
|
|
342
|
-
return {
|
|
343
|
-
replyID: reply.replyID,
|
|
344
|
-
author: userRef(reply.creator),
|
|
345
|
-
createTime: reply.createTime,
|
|
346
|
-
updateTime: reply.updateTime && reply.updateTime >= reply.createTime ? reply.updateTime : undefined,
|
|
347
|
-
edited: Boolean(reply.updateTime && reply.updateTime > reply.createTime),
|
|
348
|
-
value: fromApiContent(reply.content.map((part) => part.type === 'mention' &&
|
|
349
|
-
(!Number.isSafeInteger(part.userID) || (part.userID ?? 0) <= 0)
|
|
350
|
-
? { type: 'mention' } : part)),
|
|
351
|
-
images: images.map((image) => ({ ...image, name: image.name ?? '图片' })),
|
|
352
|
-
reactions: (props.reactions?.getReactions(target) ?? []).map((reaction) => ({
|
|
353
|
-
key: reaction.emojiKey, count: reaction.count, mine: reaction.reactedByMe,
|
|
354
|
-
users: (reaction.previewUserIDs ?? []).map(String),
|
|
355
|
-
})),
|
|
356
|
-
permissions: {
|
|
357
|
-
canReact: props.reactions?.canChange?.(target) ?? true,
|
|
358
|
-
canEdit: Boolean(policy?.canEdit && (images.length
|
|
359
|
-
? props.images?.onUpdateComment : props.onUpdateComment || props.images?.onUpdateComment)),
|
|
360
|
-
canDelete: Boolean(policy?.canDelete && props.onDeleteComment),
|
|
361
|
-
canCopyLink: Boolean(props.getCommentLink?.(target)),
|
|
362
|
-
},
|
|
363
|
-
};
|
|
364
|
-
}),
|
|
365
|
-
}));
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function assertID(id: string) {
|
|
369
|
-
if (!/^\d+$/.test(id)) throw new Error('评论或回复 ID 必须是非空数字字符串');
|
|
370
|
-
}
|
|
371
|
-
function assertRecord(props: CommentPanelProps) {
|
|
372
|
-
if (!props.recordID) throw new Error('记录 ID 不能为空');
|
|
373
|
-
}
|
|
374
|
-
function writeContent(draft: DiscussionDraft, allowEmpty: boolean): CommentWriteContent[] {
|
|
375
|
-
const content = readApiContent(draft.value);
|
|
376
|
-
if ((!allowEmpty && !content.length) || content.length > 50)
|
|
377
|
-
throw new Error('评论正文需要包含 1 至 50 个内容元素');
|
|
378
|
-
for (const part of content) {
|
|
379
|
-
if (part.type === 'mention' && (!Number.isSafeInteger(part.userID) || (part.userID ?? 0) <= 0))
|
|
380
|
-
throw new Error('请移除或重新选择无法识别的 @ 人员');
|
|
381
|
-
if ((part.type === 'text' && !part.text) || (part.type === 'link' && !part.url))
|
|
382
|
-
throw new Error('评论文本或链接不能为空');
|
|
383
|
-
}
|
|
384
|
-
return content as CommentWriteContent[];
|
|
385
|
-
}
|
|
386
|
-
function imageInputs(images: DiscussionImage[]): CommentImageInput[] {
|
|
387
|
-
return images.map((image) => image.file
|
|
388
|
-
? { kind: 'local', localID: image.id, file: image.file }
|
|
389
|
-
: { kind: 'existing', image: {
|
|
390
|
-
id: image.id, url: image.url, name: image.name, width: image.width, height: image.height,
|
|
391
|
-
} });
|
|
392
|
-
}
|
|
393
|
-
function releaseSubmittedPreviews(images: DiscussionImage[]) {
|
|
394
|
-
// Public image callbacks receive File, never the editor's temporary object URL.
|
|
395
|
-
for (const image of images)
|
|
396
|
-
if (image.file && image.url.startsWith('blob:')) URL.revokeObjectURL(image.url);
|
|
397
|
-
}
|
|
398
|
-
export function preparePanelComment(props: CommentPanelProps, draft: DiscussionDraft) {
|
|
399
|
-
assertRecord(props);
|
|
400
|
-
if (draft.commentID !== undefined) {
|
|
401
|
-
assertID(draft.commentID);
|
|
402
|
-
if (!props.comments.some((thread) => thread.commentID === draft.commentID))
|
|
403
|
-
throw new Error('原讨论已不存在,请重新选择回复对象');
|
|
404
|
-
}
|
|
405
|
-
const base = {
|
|
406
|
-
recordID: props.recordID,
|
|
407
|
-
...(draft.commentID === undefined ? {} : { commentID: draft.commentID }),
|
|
408
|
-
content: writeContent(draft, draft.images.length > 0),
|
|
409
|
-
};
|
|
410
|
-
const imageCommand = draft.images.length > 0 || !props.onAddComment;
|
|
411
|
-
if (imageCommand) {
|
|
412
|
-
if (!props.images?.onAddComment) throw new Error('未提供图片评论发送能力');
|
|
413
|
-
const command = props.images.onAddComment;
|
|
414
|
-
const input = { ...base, images: imageInputs(draft.images) };
|
|
415
|
-
return () => command(input);
|
|
416
|
-
}
|
|
417
|
-
const command = props.onAddComment!;
|
|
418
|
-
return () => command(base);
|
|
419
|
-
}
|
|
420
|
-
export async function submitPanelComment(props: CommentPanelProps, draft: DiscussionDraft) {
|
|
421
|
-
const result = await preparePanelComment(props, draft)();
|
|
422
|
-
releaseSubmittedPreviews(draft.images);
|
|
423
|
-
return result;
|
|
424
|
-
}
|
|
425
|
-
function findTarget(props: CommentPanelProps, commentID: string, replyID: string) {
|
|
426
|
-
assertRecord(props); assertID(commentID); assertID(replyID);
|
|
427
|
-
const comment = props.comments.find((item) => item.commentID === commentID);
|
|
428
|
-
const reply = comment?.replies.find((item) => item.replyID === replyID);
|
|
429
|
-
if (!comment || !reply) throw new Error('原评论已不存在,请刷新后重试');
|
|
430
|
-
const target = { recordID: props.recordID, commentID, replyID };
|
|
431
|
-
const policy = props.getReplyPermissions?.({ ...target, comment, reply, isBody: comment.replies[0] === reply });
|
|
432
|
-
return { target, comment, reply, policy };
|
|
433
|
-
}
|
|
434
|
-
export function preparePanelEdit(
|
|
435
|
-
props: CommentPanelProps, commentID: string, replyID: string, draft: DiscussionDraft,
|
|
436
|
-
) {
|
|
437
|
-
const { target, policy } = findTarget(props, commentID, replyID);
|
|
438
|
-
if (!policy?.canEdit) throw new Error('没有编辑该评论的权限');
|
|
439
|
-
const hadImages = Boolean(props.images?.getImages(target).length);
|
|
440
|
-
const base = { ...target, content: writeContent(draft, draft.images.length > 0) };
|
|
441
|
-
if (hadImages || draft.images.length || !props.onUpdateComment) {
|
|
442
|
-
if (!props.images?.onUpdateComment) throw new Error('未提供图片评论编辑能力');
|
|
443
|
-
const command = props.images.onUpdateComment;
|
|
444
|
-
const input = { ...base, images: imageInputs(draft.images) };
|
|
445
|
-
return () => command(input);
|
|
446
|
-
}
|
|
447
|
-
const command = props.onUpdateComment;
|
|
448
|
-
return () => command(base);
|
|
449
|
-
}
|
|
450
|
-
export async function editPanelComment(props: CommentPanelProps, commentID: string, replyID: string, draft: DiscussionDraft) {
|
|
451
|
-
const result = await preparePanelEdit(props, commentID, replyID, draft)();
|
|
452
|
-
releaseSubmittedPreviews(draft.images);
|
|
453
|
-
return result;
|
|
454
|
-
}
|
|
455
|
-
export function preparePanelDeletion(props: CommentPanelProps, commentID: string, replyID: string) {
|
|
456
|
-
const { target, policy } = findTarget(props, commentID, replyID);
|
|
457
|
-
if (!policy?.canDelete || !props.onDeleteComment) throw new Error('没有删除该评论的权限');
|
|
458
|
-
const command = props.onDeleteComment;
|
|
459
|
-
const input = { recordID: target.recordID, commentID, replyID };
|
|
460
|
-
return () => command(input);
|
|
461
|
-
}
|
|
462
|
-
export async function deletePanelComment(props: CommentPanelProps, commentID: string, replyID: string) {
|
|
463
|
-
return preparePanelDeletion(props, commentID, replyID)();
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** Shared by intersection, scroll and retry triggers; its lock is synchronous. */
|
|
467
|
-
export class CommentPageLoader {
|
|
468
|
-
pending = false;
|
|
469
|
-
error = '';
|
|
470
|
-
automaticLoads = 0;
|
|
471
|
-
async load(pagination: CommentPagination, manual = false): Promise<boolean> {
|
|
472
|
-
if (this.pending || pagination.loading || !pagination.hasMore) return false;
|
|
473
|
-
if (!manual && (this.error || pagination.error || this.automaticLoads >= 3)) return false;
|
|
474
|
-
if (manual) this.automaticLoads = 0;
|
|
475
|
-
else this.automaticLoads++;
|
|
476
|
-
this.pending = true;
|
|
477
|
-
this.error = '';
|
|
478
|
-
try {
|
|
479
|
-
await pagination.onLoadMore();
|
|
480
|
-
return true;
|
|
481
|
-
} catch (cause) {
|
|
482
|
-
this.error = cause instanceof Error ? cause.message : '加载失败,请重试';
|
|
483
|
-
return false;
|
|
484
|
-
} finally { this.pending = false; }
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
export async function loadPanelReactionDetails(props: CommentPanelProps, target: CommentTarget, signal: AbortSignal) {
|
|
489
|
-
const feature = props.reactions;
|
|
490
|
-
const reactions = feature?.getReactions(target) ?? [];
|
|
491
|
-
return Promise.all(reactions.map(async (reaction) => {
|
|
492
|
-
const users = new Set<number>();
|
|
493
|
-
let cursor: string | undefined;
|
|
494
|
-
const seen = new Set<string>();
|
|
495
|
-
if (!feature?.loadUsers) return {
|
|
496
|
-
key: reaction.emojiKey, count: reaction.count, mine: reaction.reactedByMe,
|
|
497
|
-
users: (reaction.previewUserIDs ?? []).map(String),
|
|
498
|
-
};
|
|
499
|
-
for (let page = 0; page < 100; page++) {
|
|
500
|
-
signal.throwIfAborted();
|
|
501
|
-
const result = await feature.loadUsers({ ...target, emojiKey: reaction.emojiKey, ...(cursor ? { cursor } : {}) }, signal);
|
|
502
|
-
result.userIDs.forEach((id) => users.add(id));
|
|
503
|
-
if (!result.nextCursor) return {
|
|
504
|
-
key: reaction.emojiKey, count: reaction.count, mine: reaction.reactedByMe, users: [...users].map(String),
|
|
505
|
-
};
|
|
506
|
-
if (seen.has(result.nextCursor)) throw new Error('回应人员分页游标未前进');
|
|
507
|
-
seen.add(result.nextCursor); cursor = result.nextCursor;
|
|
508
|
-
}
|
|
509
|
-
throw new Error('回应人员较多,请稍后重试');
|
|
510
|
-
}));
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
export function commentFailureMessage(cause: unknown, fallback: string): string {
|
|
514
|
-
const message = cause instanceof Error ? cause.message : fallback;
|
|
515
|
-
return cause instanceof Error && 'outcome' in cause && cause.outcome === 'unknown'
|
|
516
|
-
? `${message};操作结果未确认,请先刷新核对后再操作` : message;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/** Relative timestamps matching miaoda Comment/comment-time (seconds input). */
|
|
520
|
-
export function formatCommentTime(createTime: number) {
|
|
521
|
-
const date = new Date(createTime * 1000);
|
|
522
|
-
const now = new Date();
|
|
523
|
-
const seconds = Math.max(
|
|
524
|
-
0,
|
|
525
|
-
Math.round((now.getTime() - date.getTime()) / 1000),
|
|
526
|
-
);
|
|
527
|
-
if (date.toDateString() === now.toDateString()) {
|
|
528
|
-
if (seconds <= 44) return '刚刚';
|
|
529
|
-
if (seconds <= 89) return '1 分钟前';
|
|
530
|
-
const minutes = Math.round(seconds / 60);
|
|
531
|
-
if (minutes <= 44) return `${minutes} 分钟前`;
|
|
532
|
-
if (minutes <= 89) return '1 小时前';
|
|
533
|
-
return `${Math.round(minutes / 60)} 小时前`;
|
|
534
|
-
}
|
|
535
|
-
const time = date.toLocaleTimeString('zh-CN', {
|
|
536
|
-
hour: '2-digit',
|
|
537
|
-
minute: '2-digit',
|
|
538
|
-
hour12: false,
|
|
539
|
-
});
|
|
540
|
-
const yesterday = new Date(now);
|
|
541
|
-
yesterday.setDate(now.getDate() - 1);
|
|
542
|
-
if (date.toDateString() === yesterday.toDateString()) return `昨天 ${time}`;
|
|
543
|
-
return date.getFullYear() === now.getFullYear()
|
|
544
|
-
? `${date.getMonth() + 1}月${date.getDate()}日 ${time}`
|
|
545
|
-
: `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/** Values from the reference comment's at-user and light theme tokens. */
|
|
549
|
-
export const COMMENT_THEME_CLASS = [
|
|
550
|
-
'font-sans text-sm leading-[22px]',
|
|
551
|
-
'[--foreground:#1f2329] [--background:#fff] [--card:#fff] [--card-foreground:#1f2329]',
|
|
552
|
-
'[--popover:#fff] [--popover-foreground:#1f2329] [--border:#dee0e3]',
|
|
553
|
-
'[--primary:#1456f0] [--primary-foreground:#fff] [--button-outline:#dee0e3]',
|
|
554
|
-
'[--primary-border:transparent] [--comment-warning:#ff811a] [--muted:#f2f3f5] [--muted-foreground:#8f959e]',
|
|
555
|
-
'[--accent:rgb(31_35_41_/_0.05)] [--accent-foreground:#1f2329] [--subtle-foreground:#646a73]',
|
|
556
|
-
'[--info:#1456f0] [--info-foreground:#fff] [--comment-hover:rgb(31_35_41_/_0.05)]',
|
|
557
|
-
].join(' ');
|
|
558
|
-
|
|
559
|
-
export const USER_MENTION_CLASS = 'inline-block max-w-[207px] px-0.5 align-top';
|
|
560
|
-
export const USER_MENTION_LABEL_CLASS =
|
|
561
|
-
'replica-user-mention-label mt-px block cursor-pointer truncate rounded px-0.5 text-primary no-underline leading-5';
|
|
562
|
-
export const USER_MENTION_SELF_CLASS =
|
|
563
|
-
'replica-user-mention-label replica-user-mention-self mt-px block cursor-pointer truncate rounded-full px-1 bg-[var(--info)] text-[var(--info-foreground)] no-underline leading-5';
|
|
564
|
-
export const DOCUMENT_MENTION_CLASS =
|
|
565
|
-
'mx-0.5 inline cursor-pointer break-words rounded-[2px] p-0.5 text-primary no-underline hover:bg-accent';
|
|
566
|
-
export const DOCUMENT_MENTION_UNREADABLE_CLASS =
|
|
567
|
-
'mx-0.5 inline cursor-pointer break-words rounded-[2px] p-0.5 text-subtle-foreground no-underline hover:bg-muted';
|
|
568
|
-
export function atUserLabel(label: string) {
|
|
569
|
-
return /^\s*@/.test(label) ? label : `@${label}`;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/** Reference editor uses transparent @ links; only the current user is a blue pill. */
|
|
573
|
-
export const INPUT_USER_MENTION_CLASS =
|
|
574
|
-
'replica-input-mention inline-block max-w-[224px] px-0.5 align-top [&.ProseMirror-selectednode]:bg-primary/20';
|
|
575
|
-
export const INPUT_USER_MENTION_LABEL_CLASS = USER_MENTION_LABEL_CLASS;
|
|
576
|
-
|
|
577
|
-
/** The editor node view and rendered comment must follow the same live permissions. */
|
|
578
|
-
export function userMentionLabelClass(self: boolean, disabled = false) {
|
|
579
|
-
const base = self ? USER_MENTION_SELF_CLASS : USER_MENTION_LABEL_CLASS;
|
|
580
|
-
return disabled ? `${base} replica-user-mention-disabled text-muted-foreground` : base;
|
|
581
|
-
}
|