@lark-apaas/coding-template-nestjs-react-fullstack 0.1.34 → 0.1.36-alpha.20260901035538
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/ui/markdown.tsx +43 -0
- package/template/client/src/index.css +0 -1
- package/template/nest-cli.json +10 -9
- package/template/package-lock.json +10325 -18965
- package/template/package.json +7 -70
- package/template/scripts/build.sh +12 -0
- package/template/vite.config.ts +1 -1
- package/template/client/src/components/business-ui/tiptap-editor/README.md +0 -324
- package/template/client/src/components/business-ui/tiptap-editor/components/attachment-toolbar-button.tsx +0 -74
- package/template/client/src/components/business-ui/tiptap-editor/components/blockquote-toolbar-button.tsx +0 -41
- package/template/client/src/components/business-ui/tiptap-editor/components/code-block-toolbar-button.tsx +0 -41
- package/template/client/src/components/business-ui/tiptap-editor/components/color-highlight-toolbar-button.tsx +0 -141
- package/template/client/src/components/business-ui/tiptap-editor/components/heading-toolbar-button.tsx +0 -99
- package/template/client/src/components/business-ui/tiptap-editor/components/horizontal-rule-toolbar-button.tsx +0 -39
- package/template/client/src/components/business-ui/tiptap-editor/components/image-upload-toolbar-button.tsx +0 -65
- package/template/client/src/components/business-ui/tiptap-editor/components/link-edit-form.tsx +0 -127
- package/template/client/src/components/business-ui/tiptap-editor/components/link-hover-toolbar.tsx +0 -380
- package/template/client/src/components/business-ui/tiptap-editor/components/link-toolbar-button.tsx +0 -96
- package/template/client/src/components/business-ui/tiptap-editor/components/list-toolbar-button.tsx +0 -75
- package/template/client/src/components/business-ui/tiptap-editor/components/mark-toolbar-button.tsx +0 -118
- package/template/client/src/components/business-ui/tiptap-editor/components/text-align-toolbar-button.tsx +0 -84
- package/template/client/src/components/business-ui/tiptap-editor/components/undo-redo-toolbar-button.tsx +0 -54
- package/template/client/src/components/business-ui/tiptap-editor/extensions/attachment.tsx +0 -539
- package/template/client/src/components/business-ui/tiptap-editor/extensions/code-block-shiki.tsx +0 -236
- package/template/client/src/components/business-ui/tiptap-editor/extensions/complete-kit.ts +0 -260
- package/template/client/src/components/business-ui/tiptap-editor/extensions/image.tsx +0 -456
- package/template/client/src/components/business-ui/tiptap-editor/hooks/use-tiptap-editor.ts +0 -47
- package/template/client/src/components/business-ui/tiptap-editor/index.ts +0 -38
- package/template/client/src/components/business-ui/tiptap-editor/tiptap-editor-complete.tsx +0 -112
- package/template/client/src/components/business-ui/tiptap-editor/tiptap-editor.tsx +0 -169
- package/template/client/src/components/ui/streamdown.tsx +0 -186
- package/template/client/src/lib/shiki.ts +0 -92
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
AlignCenter,
|
|
5
|
-
AlignLeft,
|
|
6
|
-
AlignRight,
|
|
7
|
-
Check,
|
|
8
|
-
ChevronDown,
|
|
9
|
-
} from 'lucide-react';
|
|
10
|
-
|
|
11
|
-
import { useTiptapEditor } from '@/components/business-ui/tiptap-editor/hooks/use-tiptap-editor';
|
|
12
|
-
import { cn } from '@/lib/utils';
|
|
13
|
-
import { Button } from '@/components/ui/button';
|
|
14
|
-
import {
|
|
15
|
-
DropdownMenu,
|
|
16
|
-
DropdownMenuContent,
|
|
17
|
-
DropdownMenuItem,
|
|
18
|
-
DropdownMenuTrigger,
|
|
19
|
-
} from '@/components/ui/dropdown-menu';
|
|
20
|
-
|
|
21
|
-
const ALIGN_OPTIONS = [
|
|
22
|
-
{ value: 'left', label: '左对齐', icon: AlignLeft },
|
|
23
|
-
{ value: 'center', label: '居中对齐', icon: AlignCenter },
|
|
24
|
-
{ value: 'right', label: '右对齐', icon: AlignRight },
|
|
25
|
-
] as const;
|
|
26
|
-
|
|
27
|
-
export function TextAlignToolbarButton() {
|
|
28
|
-
const { editor } = useTiptapEditor();
|
|
29
|
-
|
|
30
|
-
if (!editor) return null;
|
|
31
|
-
|
|
32
|
-
const getCurrentAlignment = () => {
|
|
33
|
-
if (editor.isActive({ textAlign: 'center' })) return 'center';
|
|
34
|
-
if (editor.isActive({ textAlign: 'right' })) return 'right';
|
|
35
|
-
return 'left';
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const isDefaultLeft =
|
|
39
|
-
!editor.isActive({ textAlign: 'center' }) &&
|
|
40
|
-
!editor.isActive({ textAlign: 'right' }) &&
|
|
41
|
-
!editor.isActive({ textAlign: 'justify' });
|
|
42
|
-
|
|
43
|
-
const alignment = getCurrentAlignment();
|
|
44
|
-
const currentOption = ALIGN_OPTIONS.find((opt) => opt.value === alignment);
|
|
45
|
-
const Icon = currentOption?.icon ?? AlignLeft;
|
|
46
|
-
|
|
47
|
-
return (
|
|
48
|
-
<DropdownMenu>
|
|
49
|
-
<DropdownMenuTrigger asChild>
|
|
50
|
-
<Button variant="ghost" size="sm" className="h-6 gap-0.5 px-2">
|
|
51
|
-
<Icon className="size-4" />
|
|
52
|
-
<ChevronDown className="size-3.5 text-muted-foreground" />
|
|
53
|
-
<span className="sr-only">文字对齐</span>
|
|
54
|
-
</Button>
|
|
55
|
-
</DropdownMenuTrigger>
|
|
56
|
-
<DropdownMenuContent align="start" className="w-50">
|
|
57
|
-
{ALIGN_OPTIONS.map((option) => {
|
|
58
|
-
const OptionIcon = option.icon;
|
|
59
|
-
const active =
|
|
60
|
-
option.value === 'left'
|
|
61
|
-
? editor.isActive({ textAlign: 'left' }) || isDefaultLeft
|
|
62
|
-
: editor.isActive({ textAlign: option.value });
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
<DropdownMenuItem
|
|
66
|
-
key={option.value}
|
|
67
|
-
onClick={() =>
|
|
68
|
-
editor.chain().focus().setTextAlign(option.value).run()
|
|
69
|
-
}
|
|
70
|
-
disabled={!editor.can().setTextAlign(option.value)}
|
|
71
|
-
className={cn('justify-between', active && 'bg-accent')}
|
|
72
|
-
>
|
|
73
|
-
<span className="flex items-center gap-2">
|
|
74
|
-
<OptionIcon className="size-4" />
|
|
75
|
-
{option.label}
|
|
76
|
-
</span>
|
|
77
|
-
{active && <Check className="size-4" />}
|
|
78
|
-
</DropdownMenuItem>
|
|
79
|
-
);
|
|
80
|
-
})}
|
|
81
|
-
</DropdownMenuContent>
|
|
82
|
-
</DropdownMenu>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Redo, Undo } from 'lucide-react';
|
|
4
|
-
|
|
5
|
-
import { useTiptapEditor } from '@/components/business-ui/tiptap-editor/hooks/use-tiptap-editor';
|
|
6
|
-
import { Button } from '@/components/ui/button';
|
|
7
|
-
import {
|
|
8
|
-
Tooltip,
|
|
9
|
-
TooltipContent,
|
|
10
|
-
TooltipProvider,
|
|
11
|
-
TooltipTrigger,
|
|
12
|
-
} from '@/components/ui/tooltip';
|
|
13
|
-
|
|
14
|
-
interface UndoRedoToolbarButtonProps {
|
|
15
|
-
action: 'undo' | 'redo';
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function UndoRedoToolbarButton({ action }: UndoRedoToolbarButtonProps) {
|
|
19
|
-
const { editor } = useTiptapEditor();
|
|
20
|
-
|
|
21
|
-
if (!editor) return null;
|
|
22
|
-
|
|
23
|
-
const canExecute =
|
|
24
|
-
action === 'undo' ? editor.can().undo() : editor.can().redo();
|
|
25
|
-
const Icon = action === 'undo' ? Undo : Redo;
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<TooltipProvider>
|
|
29
|
-
<Tooltip delayDuration={700}>
|
|
30
|
-
<TooltipTrigger asChild>
|
|
31
|
-
<Button
|
|
32
|
-
variant="ghost"
|
|
33
|
-
size="sm"
|
|
34
|
-
onClick={() =>
|
|
35
|
-
action === 'undo'
|
|
36
|
-
? editor.chain().focus().undo().run()
|
|
37
|
-
: editor.chain().focus().redo().run()
|
|
38
|
-
}
|
|
39
|
-
disabled={!canExecute}
|
|
40
|
-
className="size-6 px-0"
|
|
41
|
-
>
|
|
42
|
-
<Icon className="size-4" />
|
|
43
|
-
<span className="sr-only">
|
|
44
|
-
{action === 'undo' ? '撤销' : '重做'}
|
|
45
|
-
</span>
|
|
46
|
-
</Button>
|
|
47
|
-
</TooltipTrigger>
|
|
48
|
-
<TooltipContent>
|
|
49
|
-
<p>{action === 'undo' ? '撤销(⌘+Z)' : '重做(⌘+Shift+Z)'}</p>
|
|
50
|
-
</TooltipContent>
|
|
51
|
-
</Tooltip>
|
|
52
|
-
</TooltipProvider>
|
|
53
|
-
);
|
|
54
|
-
}
|
|
@@ -1,539 +0,0 @@
|
|
|
1
|
-
import * as React from 'react';
|
|
2
|
-
import { mergeAttributes, Node, type CommandProps } from '@tiptap/core';
|
|
3
|
-
import { type Node as ProseMirrorNode } from '@tiptap/pm/model';
|
|
4
|
-
import {
|
|
5
|
-
NodeViewWrapper,
|
|
6
|
-
ReactNodeViewRenderer,
|
|
7
|
-
type NodeViewProps,
|
|
8
|
-
} from '@tiptap/react';
|
|
9
|
-
import { CircleAlert, DownloadIcon, EyeIcon, Trash2Icon } from 'lucide-react';
|
|
10
|
-
import { nanoid } from 'nanoid';
|
|
11
|
-
import { toast } from 'sonner';
|
|
12
|
-
|
|
13
|
-
import { uploadFile } from '@/components/business-ui/api/files/service';
|
|
14
|
-
import { cn } from '@/lib/utils';
|
|
15
|
-
import { Button } from '@/components/ui/button';
|
|
16
|
-
import { FileAeColorfulIcon } from '@/components/ui/icons/file-ae-colorful-icon';
|
|
17
|
-
import { FileAiColorfulIcon } from '@/components/ui/icons/file-ai-colorful-icon';
|
|
18
|
-
import { FileAudioColorfulIcon } from '@/components/ui/icons/file-audio-colorful-icon';
|
|
19
|
-
import { FileCodeColorfulIcon } from '@/components/ui/icons/file-code-colorful-icon';
|
|
20
|
-
import { FileCsvColorfulIcon } from '@/components/ui/icons/file-csv-colorful-icon';
|
|
21
|
-
import { FileKeynoteColorfulIcon } from '@/components/ui/icons/file-keynote-colorful-icon';
|
|
22
|
-
import { FilePagesColorfulIcon } from '@/components/ui/icons/file-pages-colorful-icon';
|
|
23
|
-
import { FilePsColorfulIcon } from '@/components/ui/icons/file-ps-colorful-icon';
|
|
24
|
-
import { FileSketchColorfulIcon } from '@/components/ui/icons/file-sketch-colorful-icon';
|
|
25
|
-
import { FileWikiExcelColorfulIcon } from '@/components/ui/icons/file-wiki-excel-colorful-icon';
|
|
26
|
-
import { FileWikiImageColorfulIcon } from '@/components/ui/icons/file-wiki-image-colorful-icon';
|
|
27
|
-
import { FileWikiPdfColorfulIcon } from '@/components/ui/icons/file-wiki-pdf-colorful-icon';
|
|
28
|
-
import { FileWikiPptColorfulIcon } from '@/components/ui/icons/file-wiki-ppt-colorful-icon';
|
|
29
|
-
import { FileWikiTextColorfulIcon } from '@/components/ui/icons/file-wiki-text-colorful-icon';
|
|
30
|
-
import { FileWikiUnknownColorfulIcon } from '@/components/ui/icons/file-wiki-unknown-colorful-icon';
|
|
31
|
-
import { FileWikiVideoColorfulIcon } from '@/components/ui/icons/file-wiki-video-colorful-icon';
|
|
32
|
-
import { FileWikiWordColorfulIcon } from '@/components/ui/icons/file-wiki-word-colorful-icon';
|
|
33
|
-
import { FileWikiZipColorfulIcon } from '@/components/ui/icons/file-wiki-zip-colorful-icon';
|
|
34
|
-
import { Spinner } from '@/components/ui/spinner';
|
|
35
|
-
|
|
36
|
-
export interface AttachmentAttributes {
|
|
37
|
-
url: string;
|
|
38
|
-
fileName?: string;
|
|
39
|
-
fileSize?: string;
|
|
40
|
-
fileType?: string;
|
|
41
|
-
fileExt?: string;
|
|
42
|
-
uploadId?: string | null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export type AttachmentUploadFn = (file: File) => Promise<string>;
|
|
46
|
-
|
|
47
|
-
export interface AttachmentExtensionOptions {
|
|
48
|
-
upload?: AttachmentUploadFn;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
declare module '@tiptap/core' {
|
|
52
|
-
interface Commands<ReturnType> {
|
|
53
|
-
attachment: {
|
|
54
|
-
setAttachment: (attributes: AttachmentAttributes) => ReturnType;
|
|
55
|
-
insertAttachments: (files: File[]) => ReturnType;
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function findAttachmentByUploadId(doc: ProseMirrorNode, uploadId: string) {
|
|
61
|
-
let foundPos: number | null = null;
|
|
62
|
-
doc.descendants((node: ProseMirrorNode, pos: number) => {
|
|
63
|
-
if (foundPos != null) return false;
|
|
64
|
-
if (node.type?.name === 'attachment' && node.attrs?.uploadId === uploadId) {
|
|
65
|
-
foundPos = pos;
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
return true;
|
|
69
|
-
});
|
|
70
|
-
return foundPos;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function fileToAttachmentAttributes(
|
|
74
|
-
file: File,
|
|
75
|
-
uploadId: string,
|
|
76
|
-
): AttachmentAttributes {
|
|
77
|
-
const fileSize = (file.size / 1024).toFixed(2) + ' KB';
|
|
78
|
-
const fileExt = file.name.split('.').pop()?.toLowerCase() || '';
|
|
79
|
-
const fileName = fileExt
|
|
80
|
-
? file.name.slice(0, -(fileExt.length + 1))
|
|
81
|
-
: file.name;
|
|
82
|
-
const fileType = file.type || fileExt || '';
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
url: '',
|
|
86
|
-
fileName,
|
|
87
|
-
fileExt,
|
|
88
|
-
fileSize,
|
|
89
|
-
fileType,
|
|
90
|
-
uploadId,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function withDownloadParam(url: string) {
|
|
95
|
-
// blob URL 追加 search 会导致地址失效
|
|
96
|
-
if (url.startsWith('blob:')) return url;
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const u = new URL(url, window.location.href);
|
|
100
|
-
u.searchParams.set('download', 'true');
|
|
101
|
-
return u.toString();
|
|
102
|
-
} catch {
|
|
103
|
-
return url;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function getFileIcon(fileType?: string) {
|
|
108
|
-
if (!fileType) return FileWikiUnknownColorfulIcon;
|
|
109
|
-
|
|
110
|
-
const type = fileType.toLowerCase();
|
|
111
|
-
|
|
112
|
-
switch (type) {
|
|
113
|
-
case 'jpg':
|
|
114
|
-
case 'jpeg':
|
|
115
|
-
case 'png':
|
|
116
|
-
case 'gif':
|
|
117
|
-
case 'svg':
|
|
118
|
-
case 'webp':
|
|
119
|
-
return FileWikiImageColorfulIcon;
|
|
120
|
-
case 'mp4':
|
|
121
|
-
case 'webm':
|
|
122
|
-
case 'mov':
|
|
123
|
-
return FileWikiVideoColorfulIcon;
|
|
124
|
-
case 'mp3':
|
|
125
|
-
case 'wav':
|
|
126
|
-
case 'ogg':
|
|
127
|
-
return FileAudioColorfulIcon;
|
|
128
|
-
case 'pdf':
|
|
129
|
-
return FileWikiPdfColorfulIcon;
|
|
130
|
-
case 'doc':
|
|
131
|
-
case 'docx':
|
|
132
|
-
case 'rtf':
|
|
133
|
-
return FileWikiWordColorfulIcon;
|
|
134
|
-
case 'xls':
|
|
135
|
-
case 'xlsx':
|
|
136
|
-
return FileWikiExcelColorfulIcon;
|
|
137
|
-
case 'csv':
|
|
138
|
-
return FileCsvColorfulIcon;
|
|
139
|
-
case 'ppt':
|
|
140
|
-
case 'pptx':
|
|
141
|
-
return FileWikiPptColorfulIcon;
|
|
142
|
-
case 'txt':
|
|
143
|
-
case 'md':
|
|
144
|
-
return FileWikiTextColorfulIcon;
|
|
145
|
-
case 'zip':
|
|
146
|
-
case 'rar':
|
|
147
|
-
case '7z':
|
|
148
|
-
case 'tar':
|
|
149
|
-
case 'gz':
|
|
150
|
-
return FileWikiZipColorfulIcon;
|
|
151
|
-
case 'js':
|
|
152
|
-
case 'ts':
|
|
153
|
-
case 'jsx':
|
|
154
|
-
case 'tsx':
|
|
155
|
-
case 'html':
|
|
156
|
-
case 'css':
|
|
157
|
-
case 'json':
|
|
158
|
-
case 'py':
|
|
159
|
-
case 'java':
|
|
160
|
-
case 'c':
|
|
161
|
-
case 'cpp':
|
|
162
|
-
case 'go':
|
|
163
|
-
case 'rs':
|
|
164
|
-
case 'php':
|
|
165
|
-
return FileCodeColorfulIcon;
|
|
166
|
-
case 'ai':
|
|
167
|
-
return FileAiColorfulIcon;
|
|
168
|
-
case 'psd':
|
|
169
|
-
case 'ps':
|
|
170
|
-
return FilePsColorfulIcon;
|
|
171
|
-
case 'ae':
|
|
172
|
-
return FileAeColorfulIcon;
|
|
173
|
-
case 'sketch':
|
|
174
|
-
return FileSketchColorfulIcon;
|
|
175
|
-
case 'key':
|
|
176
|
-
case 'keynote':
|
|
177
|
-
return FileKeynoteColorfulIcon;
|
|
178
|
-
case 'pages':
|
|
179
|
-
return FilePagesColorfulIcon;
|
|
180
|
-
default:
|
|
181
|
-
return FileWikiUnknownColorfulIcon;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function AttachmentNodeView(props: NodeViewProps) {
|
|
186
|
-
const { url, fileName, fileExt, fileSize, fileType, uploadId } = props.node
|
|
187
|
-
.attrs as AttachmentAttributes;
|
|
188
|
-
|
|
189
|
-
const uploading = Boolean(uploadId);
|
|
190
|
-
|
|
191
|
-
const effectiveUrl = url || '';
|
|
192
|
-
const displayName =
|
|
193
|
-
fileName ||
|
|
194
|
-
(effectiveUrl ? effectiveUrl.split('/').pop() : '') ||
|
|
195
|
-
'Untitled';
|
|
196
|
-
const displayExt = fileExt || '';
|
|
197
|
-
|
|
198
|
-
const iconComponent = getFileIcon(displayExt || fileType);
|
|
199
|
-
|
|
200
|
-
const deleteNode = () => {
|
|
201
|
-
props.deleteNode();
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
const handleDownload = (e: React.MouseEvent) => {
|
|
205
|
-
e.preventDefault();
|
|
206
|
-
if (!effectiveUrl || uploading) return;
|
|
207
|
-
|
|
208
|
-
const downloadUrl = withDownloadParam(effectiveUrl);
|
|
209
|
-
|
|
210
|
-
const filename =
|
|
211
|
-
displayName + (displayExt ? `.${displayExt}` : '') || '附件';
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
const a = document.createElement('a');
|
|
215
|
-
a.href = downloadUrl;
|
|
216
|
-
a.download = filename;
|
|
217
|
-
a.rel = 'noopener';
|
|
218
|
-
document.body.appendChild(a);
|
|
219
|
-
a.click();
|
|
220
|
-
a.remove();
|
|
221
|
-
} catch {
|
|
222
|
-
window.open(downloadUrl, '_blank');
|
|
223
|
-
}
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
const handlePreview = (e: React.MouseEvent) => {
|
|
227
|
-
e.preventDefault();
|
|
228
|
-
if (!effectiveUrl || uploading) return;
|
|
229
|
-
window.open(effectiveUrl, '_blank');
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const highlighted = props.selected;
|
|
233
|
-
const error = !uploading && !effectiveUrl;
|
|
234
|
-
|
|
235
|
-
return (
|
|
236
|
-
<NodeViewWrapper className="my-3 select-none">
|
|
237
|
-
<div
|
|
238
|
-
aria-invalid={error || undefined}
|
|
239
|
-
className={cn(
|
|
240
|
-
'relative flex w-full items-center gap-2 rounded-md border bg-card px-3 py-2 text-card-foreground transition-colors',
|
|
241
|
-
uploading && 'opacity-70',
|
|
242
|
-
'hover:border-primary aria-invalid:border-destructive aria-invalid:hover:border-destructive',
|
|
243
|
-
highlighted && 'border-primary',
|
|
244
|
-
)}
|
|
245
|
-
>
|
|
246
|
-
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
|
|
247
|
-
{React.createElement(iconComponent, {
|
|
248
|
-
className: 'size-6',
|
|
249
|
-
strokeWidth: 1.5,
|
|
250
|
-
})}
|
|
251
|
-
</div>
|
|
252
|
-
|
|
253
|
-
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
254
|
-
<div className="truncate text-sm/[22px] font-normal text-foreground">
|
|
255
|
-
{displayName}
|
|
256
|
-
{displayExt ? `.${displayExt}` : ''}
|
|
257
|
-
</div>
|
|
258
|
-
<div className="truncate text-xs text-muted-foreground">
|
|
259
|
-
{fileSize || 'Unknown size'}
|
|
260
|
-
</div>
|
|
261
|
-
</div>
|
|
262
|
-
|
|
263
|
-
<div className="flex items-center gap-2">
|
|
264
|
-
{uploading ? (
|
|
265
|
-
<div className="flex items-center">
|
|
266
|
-
<Spinner className="text-muted-foreground" />
|
|
267
|
-
</div>
|
|
268
|
-
) : (
|
|
269
|
-
<div className="flex items-center gap-2">
|
|
270
|
-
{error ? (
|
|
271
|
-
<Button
|
|
272
|
-
type="button"
|
|
273
|
-
variant="ghost"
|
|
274
|
-
size="icon"
|
|
275
|
-
aria-label="附件不可用"
|
|
276
|
-
title="附件不可用"
|
|
277
|
-
onClick={() => {
|
|
278
|
-
toast.error('该附件链接缺失,请重新上传');
|
|
279
|
-
}}
|
|
280
|
-
>
|
|
281
|
-
<CircleAlert className="size-4 text-destructive" />
|
|
282
|
-
</Button>
|
|
283
|
-
) : (
|
|
284
|
-
<>
|
|
285
|
-
<Button
|
|
286
|
-
type="button"
|
|
287
|
-
variant="ghost"
|
|
288
|
-
size="icon"
|
|
289
|
-
onClick={handlePreview}
|
|
290
|
-
disabled={!effectiveUrl}
|
|
291
|
-
aria-label="预览"
|
|
292
|
-
title="预览"
|
|
293
|
-
>
|
|
294
|
-
<EyeIcon className="size-4" />
|
|
295
|
-
</Button>
|
|
296
|
-
|
|
297
|
-
<Button
|
|
298
|
-
type="button"
|
|
299
|
-
variant="ghost"
|
|
300
|
-
size="icon"
|
|
301
|
-
onClick={handleDownload}
|
|
302
|
-
disabled={!effectiveUrl}
|
|
303
|
-
aria-label="下载"
|
|
304
|
-
title="下载"
|
|
305
|
-
>
|
|
306
|
-
<DownloadIcon className="size-4" />
|
|
307
|
-
</Button>
|
|
308
|
-
</>
|
|
309
|
-
)}
|
|
310
|
-
|
|
311
|
-
{props.editor.isEditable && (
|
|
312
|
-
<Button
|
|
313
|
-
type="button"
|
|
314
|
-
variant="ghost"
|
|
315
|
-
size="icon"
|
|
316
|
-
onClick={deleteNode}
|
|
317
|
-
aria-label="删除"
|
|
318
|
-
title="删除"
|
|
319
|
-
>
|
|
320
|
-
<Trash2Icon className="size-4" />
|
|
321
|
-
</Button>
|
|
322
|
-
)}
|
|
323
|
-
</div>
|
|
324
|
-
)}
|
|
325
|
-
</div>
|
|
326
|
-
</div>
|
|
327
|
-
</NodeViewWrapper>
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
export const Attachment = Node.create<AttachmentExtensionOptions>({
|
|
332
|
-
name: 'attachment',
|
|
333
|
-
group: 'block',
|
|
334
|
-
atom: true,
|
|
335
|
-
|
|
336
|
-
addOptions() {
|
|
337
|
-
return {
|
|
338
|
-
upload: async (file: File) => {
|
|
339
|
-
const data = await uploadFile(file);
|
|
340
|
-
return data.url;
|
|
341
|
-
},
|
|
342
|
-
};
|
|
343
|
-
},
|
|
344
|
-
|
|
345
|
-
addAttributes() {
|
|
346
|
-
return {
|
|
347
|
-
url: {
|
|
348
|
-
default: null,
|
|
349
|
-
parseHTML: (element) => {
|
|
350
|
-
return element.getAttribute('data-url') || null;
|
|
351
|
-
},
|
|
352
|
-
renderHTML: (attributes) => {
|
|
353
|
-
return {
|
|
354
|
-
'data-url': attributes.url || null,
|
|
355
|
-
};
|
|
356
|
-
},
|
|
357
|
-
},
|
|
358
|
-
fileName: {
|
|
359
|
-
default: null,
|
|
360
|
-
parseHTML: (element) => {
|
|
361
|
-
return element.getAttribute('data-file-name') || null;
|
|
362
|
-
},
|
|
363
|
-
renderHTML: (attributes) => {
|
|
364
|
-
return {
|
|
365
|
-
'data-file-name': attributes.fileName || null,
|
|
366
|
-
};
|
|
367
|
-
},
|
|
368
|
-
},
|
|
369
|
-
fileSize: {
|
|
370
|
-
default: null,
|
|
371
|
-
parseHTML: (element) => {
|
|
372
|
-
return element.getAttribute('data-file-size') || null;
|
|
373
|
-
},
|
|
374
|
-
renderHTML: (attributes) => {
|
|
375
|
-
return {
|
|
376
|
-
'data-file-size': attributes.fileSize || null,
|
|
377
|
-
};
|
|
378
|
-
},
|
|
379
|
-
},
|
|
380
|
-
fileType: {
|
|
381
|
-
default: null,
|
|
382
|
-
parseHTML: (element) => {
|
|
383
|
-
return element.getAttribute('data-file-type') || null;
|
|
384
|
-
},
|
|
385
|
-
renderHTML: (attributes) => {
|
|
386
|
-
return {
|
|
387
|
-
'data-file-type': attributes.fileType || null,
|
|
388
|
-
};
|
|
389
|
-
},
|
|
390
|
-
},
|
|
391
|
-
fileExt: {
|
|
392
|
-
default: null,
|
|
393
|
-
parseHTML: (element) => {
|
|
394
|
-
return element.getAttribute('data-file-ext') || null;
|
|
395
|
-
},
|
|
396
|
-
renderHTML: (attributes) => {
|
|
397
|
-
return {
|
|
398
|
-
'data-file-ext': attributes.fileExt || null,
|
|
399
|
-
};
|
|
400
|
-
},
|
|
401
|
-
},
|
|
402
|
-
uploadId: {
|
|
403
|
-
default: null,
|
|
404
|
-
rendered: false,
|
|
405
|
-
},
|
|
406
|
-
};
|
|
407
|
-
},
|
|
408
|
-
|
|
409
|
-
parseHTML() {
|
|
410
|
-
return [{ tag: 'div[data-type="attachment"]' }];
|
|
411
|
-
},
|
|
412
|
-
|
|
413
|
-
renderHTML({ node, HTMLAttributes }) {
|
|
414
|
-
const attrs = node.attrs as AttachmentAttributes;
|
|
415
|
-
const url = attrs.url || '';
|
|
416
|
-
const fileName = attrs.fileName || '';
|
|
417
|
-
const fileExt = attrs.fileExt || '';
|
|
418
|
-
const fileSize = attrs.fileSize || '';
|
|
419
|
-
|
|
420
|
-
const displayTitle =
|
|
421
|
-
fileName + (fileExt ? `.${fileExt}` : '') || 'Attachment';
|
|
422
|
-
|
|
423
|
-
const meta = fileSize;
|
|
424
|
-
|
|
425
|
-
const merged = mergeAttributes(HTMLAttributes, {
|
|
426
|
-
'data-type': 'attachment',
|
|
427
|
-
title: displayTitle,
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
if (!url) {
|
|
431
|
-
return ['div', merged];
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
return [
|
|
435
|
-
'div',
|
|
436
|
-
merged,
|
|
437
|
-
[
|
|
438
|
-
'a',
|
|
439
|
-
{
|
|
440
|
-
href: url,
|
|
441
|
-
target: '_blank',
|
|
442
|
-
rel: 'noopener noreferrer',
|
|
443
|
-
},
|
|
444
|
-
['span', {}, displayTitle],
|
|
445
|
-
meta ? ['span', {}, ` (${meta})`] : '',
|
|
446
|
-
],
|
|
447
|
-
];
|
|
448
|
-
},
|
|
449
|
-
|
|
450
|
-
addNodeView() {
|
|
451
|
-
return ReactNodeViewRenderer(AttachmentNodeView);
|
|
452
|
-
},
|
|
453
|
-
|
|
454
|
-
addCommands() {
|
|
455
|
-
return {
|
|
456
|
-
setAttachment:
|
|
457
|
-
(attributes: AttachmentAttributes) =>
|
|
458
|
-
({ commands }: CommandProps) => {
|
|
459
|
-
return commands.insertContent({
|
|
460
|
-
type: this.name,
|
|
461
|
-
attrs: attributes,
|
|
462
|
-
});
|
|
463
|
-
},
|
|
464
|
-
|
|
465
|
-
insertAttachments:
|
|
466
|
-
(files: File[]) =>
|
|
467
|
-
({ editor, commands }: CommandProps) => {
|
|
468
|
-
if (!files || files.length === 0) return false;
|
|
469
|
-
|
|
470
|
-
const upload = this.options.upload;
|
|
471
|
-
if (!upload) return false;
|
|
472
|
-
|
|
473
|
-
const uploads = files.map((file) => {
|
|
474
|
-
const uploadId = nanoid();
|
|
475
|
-
return {
|
|
476
|
-
file,
|
|
477
|
-
uploadId,
|
|
478
|
-
attrs: fileToAttachmentAttributes(file, uploadId),
|
|
479
|
-
};
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
// 一次性插入多个节点,避免 NodeSelection 导致后续插入替换掉前一个节点
|
|
483
|
-
const inserted = commands.insertContent([
|
|
484
|
-
...uploads.map(({ attrs }) => ({
|
|
485
|
-
type: this.name,
|
|
486
|
-
attrs,
|
|
487
|
-
})),
|
|
488
|
-
{ type: 'paragraph' },
|
|
489
|
-
]);
|
|
490
|
-
|
|
491
|
-
// 并发上传:不阻塞命令返回
|
|
492
|
-
void Promise.all(
|
|
493
|
-
uploads.map(async ({ file, uploadId }) => {
|
|
494
|
-
try {
|
|
495
|
-
const url = await upload(file);
|
|
496
|
-
|
|
497
|
-
// Ensure state is settled.
|
|
498
|
-
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
499
|
-
|
|
500
|
-
const pos = findAttachmentByUploadId(
|
|
501
|
-
editor.state.doc,
|
|
502
|
-
uploadId,
|
|
503
|
-
);
|
|
504
|
-
if (pos == null) return;
|
|
505
|
-
|
|
506
|
-
const node = editor.state.doc.nodeAt(pos);
|
|
507
|
-
if (!node || node.type.name !== this.name) return;
|
|
508
|
-
|
|
509
|
-
editor
|
|
510
|
-
.chain()
|
|
511
|
-
.command(({ tr }) => {
|
|
512
|
-
tr.setNodeMarkup(pos, undefined, {
|
|
513
|
-
...node.attrs,
|
|
514
|
-
url,
|
|
515
|
-
uploadId: null,
|
|
516
|
-
});
|
|
517
|
-
return true;
|
|
518
|
-
})
|
|
519
|
-
.run();
|
|
520
|
-
} catch {
|
|
521
|
-
const pos = findAttachmentByUploadId(
|
|
522
|
-
editor.state.doc,
|
|
523
|
-
uploadId,
|
|
524
|
-
);
|
|
525
|
-
if (pos != null) {
|
|
526
|
-
editor
|
|
527
|
-
.chain()
|
|
528
|
-
.deleteRange({ from: pos, to: pos + 1 })
|
|
529
|
-
.run();
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}),
|
|
533
|
-
);
|
|
534
|
-
|
|
535
|
-
return inserted;
|
|
536
|
-
},
|
|
537
|
-
};
|
|
538
|
-
},
|
|
539
|
-
});
|