@lark-apaas/coding-template-nestjs-react-fullstack 0.1.34-alpha.20260831015214 → 0.1.34

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/tiptap-editor/README.md +324 -0
  3. package/template/client/src/components/business-ui/tiptap-editor/components/attachment-toolbar-button.tsx +74 -0
  4. package/template/client/src/components/business-ui/tiptap-editor/components/blockquote-toolbar-button.tsx +41 -0
  5. package/template/client/src/components/business-ui/tiptap-editor/components/code-block-toolbar-button.tsx +41 -0
  6. package/template/client/src/components/business-ui/tiptap-editor/components/color-highlight-toolbar-button.tsx +141 -0
  7. package/template/client/src/components/business-ui/tiptap-editor/components/heading-toolbar-button.tsx +99 -0
  8. package/template/client/src/components/business-ui/tiptap-editor/components/horizontal-rule-toolbar-button.tsx +39 -0
  9. package/template/client/src/components/business-ui/tiptap-editor/components/image-upload-toolbar-button.tsx +65 -0
  10. package/template/client/src/components/business-ui/tiptap-editor/components/link-edit-form.tsx +127 -0
  11. package/template/client/src/components/business-ui/tiptap-editor/components/link-hover-toolbar.tsx +380 -0
  12. package/template/client/src/components/business-ui/tiptap-editor/components/link-toolbar-button.tsx +96 -0
  13. package/template/client/src/components/business-ui/tiptap-editor/components/list-toolbar-button.tsx +75 -0
  14. package/template/client/src/components/business-ui/tiptap-editor/components/mark-toolbar-button.tsx +118 -0
  15. package/template/client/src/components/business-ui/tiptap-editor/components/text-align-toolbar-button.tsx +84 -0
  16. package/template/client/src/components/business-ui/tiptap-editor/components/undo-redo-toolbar-button.tsx +54 -0
  17. package/template/client/src/components/business-ui/tiptap-editor/extensions/attachment.tsx +539 -0
  18. package/template/client/src/components/business-ui/tiptap-editor/extensions/code-block-shiki.tsx +236 -0
  19. package/template/client/src/components/business-ui/tiptap-editor/extensions/complete-kit.ts +260 -0
  20. package/template/client/src/components/business-ui/tiptap-editor/extensions/image.tsx +456 -0
  21. package/template/client/src/components/business-ui/tiptap-editor/hooks/use-tiptap-editor.ts +47 -0
  22. package/template/client/src/components/business-ui/tiptap-editor/index.ts +38 -0
  23. package/template/client/src/components/business-ui/tiptap-editor/tiptap-editor-complete.tsx +112 -0
  24. package/template/client/src/components/business-ui/tiptap-editor/tiptap-editor.tsx +169 -0
  25. package/template/client/src/components/ui/streamdown.tsx +186 -0
  26. package/template/client/src/index.css +1 -0
  27. package/template/client/src/lib/shiki.ts +92 -0
  28. package/template/nest-cli.json +9 -10
  29. package/template/package-lock.json +11958 -6326
  30. package/template/package.json +51 -7
  31. package/template/scripts/build.sh +0 -12
  32. package/template/vite.config.ts +1 -1
  33. package/template/client/src/components/ui/markdown.tsx +0 -43
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Check,
5
+ ChevronDown,
6
+ Heading1,
7
+ Heading2,
8
+ Heading3,
9
+ Heading4,
10
+ Heading5,
11
+ Heading6,
12
+ Type,
13
+ } from 'lucide-react';
14
+
15
+ import { useTiptapEditor } from '@/components/business-ui/tiptap-editor/hooks/use-tiptap-editor';
16
+ import { cn } from '@/lib/utils';
17
+ import { Button } from '@/components/ui/button';
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuTrigger,
23
+ } from '@/components/ui/dropdown-menu';
24
+
25
+ const HEADING_OPTIONS = [
26
+ { level: 0, label: '正文', icon: Type },
27
+ { level: 1, label: '一级标题', icon: Heading1 },
28
+ { level: 2, label: '二级标题', icon: Heading2 },
29
+ { level: 3, label: '三级标题', icon: Heading3 },
30
+ { level: 4, label: '四级标题', icon: Heading4 },
31
+ { level: 5, label: '五级标题', icon: Heading5 },
32
+ { level: 6, label: '六级标题', icon: Heading6 },
33
+ ] as const;
34
+
35
+ export function HeadingToolbarButton() {
36
+ const { editor } = useTiptapEditor();
37
+
38
+ if (!editor) return null;
39
+
40
+ const handleSelect = (level: number) => {
41
+ if (level === 0) {
42
+ editor.chain().focus().setParagraph().run();
43
+ } else {
44
+ editor
45
+ .chain()
46
+ .focus()
47
+ .toggleHeading({ level: level as 1 | 2 | 3 | 4 | 5 | 6 })
48
+ .run();
49
+ }
50
+ };
51
+
52
+ const isActive = (level: number) => {
53
+ if (level === 0) return editor.isActive('paragraph');
54
+ return editor.isActive('heading', { level });
55
+ };
56
+
57
+ const activeOption =
58
+ HEADING_OPTIONS.find((option) => isActive(option.level)) ||
59
+ HEADING_OPTIONS[0];
60
+ const ActiveIcon = activeOption.icon;
61
+
62
+ return (
63
+ <DropdownMenu>
64
+ <DropdownMenuTrigger asChild>
65
+ <Button variant="ghost" size="sm" className="h-6 gap-0.5 px-2">
66
+ <ActiveIcon className="size-4" />
67
+ <ChevronDown className="size-3.5 text-muted-foreground" />
68
+ </Button>
69
+ </DropdownMenuTrigger>
70
+ <DropdownMenuContent align="start" className="w-50">
71
+ {HEADING_OPTIONS.map((option) => {
72
+ const Icon = option.icon;
73
+ const active = isActive(option.level);
74
+ const disabled =
75
+ option.level === 0
76
+ ? !editor.can().setParagraph()
77
+ : !editor.can().toggleHeading({
78
+ level: option.level as 1 | 2 | 3 | 4 | 5 | 6,
79
+ });
80
+
81
+ return (
82
+ <DropdownMenuItem
83
+ key={option.level}
84
+ onClick={() => handleSelect(option.level)}
85
+ disabled={disabled}
86
+ className={cn('justify-between', active && 'bg-accent')}
87
+ >
88
+ <span className="flex items-center gap-2">
89
+ <Icon className="size-4" />
90
+ {option.label}
91
+ </span>
92
+ {active && <Check className="size-4" />}
93
+ </DropdownMenuItem>
94
+ );
95
+ })}
96
+ </DropdownMenuContent>
97
+ </DropdownMenu>
98
+ );
99
+ }
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ import { Minus } 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
+ export function HorizontalRuleToolbarButton() {
15
+ const { editor } = useTiptapEditor();
16
+ if (!editor) return null;
17
+
18
+ return (
19
+ <TooltipProvider>
20
+ <Tooltip delayDuration={700}>
21
+ <TooltipTrigger asChild>
22
+ <Button
23
+ variant="ghost"
24
+ size="sm"
25
+ onClick={() => editor.chain().focus().setHorizontalRule().run()}
26
+ disabled={!editor.can().setHorizontalRule()}
27
+ aria-label="分割线"
28
+ className="size-6 px-0"
29
+ >
30
+ <Minus className="size-4" />
31
+ </Button>
32
+ </TooltipTrigger>
33
+ <TooltipContent>
34
+ <p>分割线(⌘+Option+S)</p>
35
+ </TooltipContent>
36
+ </Tooltip>
37
+ </TooltipProvider>
38
+ );
39
+ }
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Image as ImageIcon } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+
7
+ import { useTiptapEditor } from '@/components/business-ui/tiptap-editor/hooks/use-tiptap-editor';
8
+ import { Button } from '@/components/ui/button';
9
+ import {
10
+ Tooltip,
11
+ TooltipContent,
12
+ TooltipProvider,
13
+ TooltipTrigger,
14
+ } from '@/components/ui/tooltip';
15
+
16
+ export function ImageUploadToolbarButton() {
17
+ const { editor } = useTiptapEditor();
18
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
19
+
20
+ if (!editor) return null;
21
+
22
+ const handleFileChange = async (
23
+ event: React.ChangeEvent<HTMLInputElement>,
24
+ ) => {
25
+ const file = event.target.files?.[0];
26
+ if (!file) return;
27
+
28
+ const ok = editor.chain().focus().insertImages([file]).run();
29
+ if (!ok) {
30
+ toast.error('插入图片失败(请确认图片扩展已启用且提供 upload)');
31
+ }
32
+
33
+ if (fileInputRef.current) {
34
+ fileInputRef.current.value = '';
35
+ }
36
+ };
37
+
38
+ return (
39
+ <TooltipProvider>
40
+ <Tooltip delayDuration={700}>
41
+ <TooltipTrigger asChild>
42
+ <Button
43
+ variant="ghost"
44
+ size="sm"
45
+ className="size-6 px-0"
46
+ onClick={() => fileInputRef.current?.click()}
47
+ >
48
+ <ImageIcon className="size-4" />
49
+ <span className="sr-only">上传图片</span>
50
+ </Button>
51
+ </TooltipTrigger>
52
+ <TooltipContent>
53
+ <p>上传图片</p>
54
+ </TooltipContent>
55
+ </Tooltip>
56
+ <input
57
+ type="file"
58
+ ref={fileInputRef}
59
+ className="hidden"
60
+ accept="image/*"
61
+ onChange={handleFileChange}
62
+ />
63
+ </TooltipProvider>
64
+ );
65
+ }
@@ -0,0 +1,127 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import { useTiptapEditor } from '@/components/business-ui/tiptap-editor/hooks/use-tiptap-editor';
6
+ import { cn } from '@/lib/utils';
7
+ import { Button } from '@/components/ui/button';
8
+ import { Input } from '@/components/ui/input';
9
+ import { Label } from '@/components/ui/label';
10
+
11
+ export interface LinkEditFormProps extends React.ComponentProps<'div'> {
12
+ open: boolean;
13
+ initialText?: string;
14
+ initialHref?: string;
15
+ /** 打开时自动聚焦到链接输入框。 */
16
+ autoFocusHref?: boolean;
17
+ onDone?: () => void;
18
+ }
19
+
20
+ export function LinkEditForm({
21
+ open,
22
+ initialText = '',
23
+ initialHref = '',
24
+ autoFocusHref = false,
25
+ onDone,
26
+ className,
27
+ ...props
28
+ }: LinkEditFormProps) {
29
+ const { editor } = useTiptapEditor();
30
+ const [text, setText] = React.useState('');
31
+ const [href, setHref] = React.useState('');
32
+
33
+ const hrefInputRef = React.useRef<HTMLInputElement | null>(null);
34
+
35
+ const textId = React.useId();
36
+ const hrefId = React.useId();
37
+
38
+ React.useEffect(() => {
39
+ if (!open) return;
40
+ setText(initialText);
41
+ setHref(initialHref);
42
+ }, [open, initialHref, initialText]);
43
+
44
+ React.useEffect(() => {
45
+ if (!open) return;
46
+ if (!autoFocusHref) return;
47
+
48
+ const id = window.setTimeout(() => {
49
+ hrefInputRef.current?.focus();
50
+ }, 0);
51
+
52
+ return () => window.clearTimeout(id);
53
+ }, [autoFocusHref, open]);
54
+
55
+ if (!editor) return null;
56
+
57
+ const isInLink = editor.isActive('link');
58
+ const selectionEmpty = editor.state.selection.empty;
59
+
60
+ const hrefTrimmed = href.trim();
61
+ const textTrimmed = text.trim();
62
+
63
+ const canSubmit =
64
+ hrefTrimmed.length > 0 &&
65
+ (!selectionEmpty || isInLink || textTrimmed.length);
66
+
67
+ const applyLink = () => {
68
+ if (!canSubmit) return;
69
+
70
+ if (textTrimmed.length > 0) {
71
+ if (isInLink && editor.state.selection.empty) {
72
+ editor.chain().focus().extendMarkRange('link').run();
73
+ } else {
74
+ editor.chain().focus().run();
75
+ }
76
+
77
+ editor.commands.insertContent({
78
+ type: 'text',
79
+ text: textTrimmed,
80
+ marks: [{ type: 'link', attrs: { href: hrefTrimmed } }],
81
+ });
82
+ } else {
83
+ const chain = editor.chain().focus();
84
+ if (isInLink) chain.extendMarkRange('link');
85
+ chain.setLink({ href: hrefTrimmed }).run();
86
+ }
87
+
88
+ onDone?.();
89
+ };
90
+
91
+ return (
92
+ <div className={cn('flex flex-col gap-4', className)} {...props}>
93
+ <div className="flex items-center gap-4">
94
+ <Label htmlFor={textId} className="w-10 shrink-0">
95
+ 文本
96
+ </Label>
97
+ <Input
98
+ id={textId}
99
+ className="h-8"
100
+ placeholder="请输入文本"
101
+ value={text}
102
+ onChange={(e) => setText(e.target.value)}
103
+ />
104
+ </div>
105
+
106
+ <div className="flex items-center gap-4">
107
+ <Label htmlFor={hrefId} className="w-10 shrink-0">
108
+ 链接
109
+ </Label>
110
+ <Input
111
+ id={hrefId}
112
+ className="h-8"
113
+ placeholder="粘贴或输入链接"
114
+ value={href}
115
+ onChange={(e) => setHref(e.target.value)}
116
+ ref={hrefInputRef}
117
+ />
118
+ </div>
119
+
120
+ <div className="flex justify-end">
121
+ <Button size="sm" disabled={!canSubmit} onClick={applyLink}>
122
+ 确定
123
+ </Button>
124
+ </div>
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,380 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Link2Off, Pencil } from 'lucide-react';
5
+
6
+ import { LinkEditForm } from '@/components/business-ui/tiptap-editor/components/link-edit-form';
7
+ import { useTiptapEditor } from '@/components/business-ui/tiptap-editor/hooks/use-tiptap-editor';
8
+ import { cn } from '@/lib/utils';
9
+ import { Button } from '@/components/ui/button';
10
+ import {
11
+ Popover,
12
+ PopoverAnchor,
13
+ PopoverContent,
14
+ } from '@/components/ui/popover';
15
+ import {
16
+ Tooltip,
17
+ TooltipContent,
18
+ TooltipProvider,
19
+ TooltipTrigger,
20
+ } from '@/components/ui/tooltip';
21
+
22
+ type LinkHoverMode = 'toolbar' | 'edit';
23
+
24
+ export interface LinkHoverToolbarProps extends React.ComponentProps<'div'> {
25
+ /** Hover 链接后延时展示工具栏的时长(ms)。默认 700ms。 */
26
+ openDelay?: number;
27
+ }
28
+
29
+ function getClosestLinkEl(
30
+ target: EventTarget | null,
31
+ ): HTMLAnchorElement | null {
32
+ if (!target || !(target instanceof HTMLElement)) return null;
33
+ const el = target.closest('a[href]');
34
+ if (!el) return null;
35
+ return el as HTMLAnchorElement;
36
+ }
37
+
38
+ function safeTextContent(el: HTMLElement | null) {
39
+ return (el?.textContent || '').trim();
40
+ }
41
+
42
+ export function LinkHoverToolbar({
43
+ openDelay = 700,
44
+ className,
45
+ ...props
46
+ }: LinkHoverToolbarProps) {
47
+ const { editor } = useTiptapEditor();
48
+ const showTimerRef = React.useRef<number | null>(null);
49
+ const closeTimerRef = React.useRef<number | null>(null);
50
+ const afterCloseTimerRef = React.useRef<number | null>(null);
51
+ const linkElRef = React.useRef<HTMLAnchorElement | null>(null);
52
+ const hoveringLinkRef = React.useRef(false);
53
+ const hoveringPopoverRef = React.useRef(false);
54
+
55
+ const [open, setOpen] = React.useState(false);
56
+ const [mode, setMode] = React.useState<LinkHoverMode>('toolbar');
57
+ const [anchor, setAnchor] = React.useState<{
58
+ left: number;
59
+ top: number;
60
+ } | null>(null);
61
+ const [initialText, setInitialText] = React.useState('');
62
+ const [initialHref, setInitialHref] = React.useState('');
63
+
64
+ const clearShowTimer = React.useCallback(() => {
65
+ if (showTimerRef.current) window.clearTimeout(showTimerRef.current);
66
+ showTimerRef.current = null;
67
+ }, []);
68
+
69
+ const clearCloseTimer = React.useCallback(() => {
70
+ if (closeTimerRef.current) window.clearTimeout(closeTimerRef.current);
71
+ closeTimerRef.current = null;
72
+ }, []);
73
+
74
+ const clearAfterCloseTimer = React.useCallback(() => {
75
+ if (afterCloseTimerRef.current) {
76
+ window.clearTimeout(afterCloseTimerRef.current);
77
+ }
78
+ afterCloseTimerRef.current = null;
79
+ }, []);
80
+
81
+ const resetStateAfterClose = React.useCallback(() => {
82
+ setMode('toolbar');
83
+ setAnchor(null);
84
+ setInitialHref('');
85
+ setInitialText('');
86
+ }, []);
87
+
88
+ const scheduleResetAfterClose = React.useCallback(() => {
89
+ // PopoverContent 有关闭动画(animate-out)。关闭时如果立刻清空 anchor/href/text/mode,
90
+ // 会出现:内容先变空(布局抖动)或失去 anchor(跳到左上角)再消失。
91
+ clearAfterCloseTimer();
92
+
93
+ afterCloseTimerRef.current = window.setTimeout(() => {
94
+ resetStateAfterClose();
95
+ afterCloseTimerRef.current = null;
96
+ }, 200);
97
+ }, [clearAfterCloseTimer, resetStateAfterClose]);
98
+
99
+ const resetInteractionState = React.useCallback(() => {
100
+ clearShowTimer();
101
+ clearCloseTimer();
102
+ hoveringLinkRef.current = false;
103
+ hoveringPopoverRef.current = false;
104
+ linkElRef.current = null;
105
+ }, [clearCloseTimer, clearShowTimer]);
106
+
107
+ const closePopover = React.useCallback(() => {
108
+ setOpen(false);
109
+ resetInteractionState();
110
+ scheduleResetAfterClose();
111
+ }, [resetInteractionState, scheduleResetAfterClose]);
112
+
113
+ const computeAnchor = React.useCallback(() => {
114
+ if (!editor) return;
115
+ const linkEl = linkElRef.current;
116
+ if (!linkEl) return;
117
+
118
+ const editorRoot = editor.view.dom.closest(
119
+ "[data-slot='tiptap-editor']",
120
+ ) as HTMLElement | null;
121
+
122
+ const linkRect = linkEl.getBoundingClientRect();
123
+ const rootRect = editorRoot?.getBoundingClientRect();
124
+ if (!rootRect) return;
125
+
126
+ setAnchor({
127
+ left: linkRect.left - rootRect.left,
128
+ top: linkRect.bottom - rootRect.top,
129
+ });
130
+ }, [editor]);
131
+
132
+ const scheduleCloseIfNeeded = React.useCallback(() => {
133
+ if (mode === 'edit') return;
134
+ clearCloseTimer();
135
+
136
+ closeTimerRef.current = window.setTimeout(() => {
137
+ if (hoveringLinkRef.current || hoveringPopoverRef.current) return;
138
+ closePopover();
139
+ }, 120);
140
+ }, [clearCloseTimer, closePopover, mode]);
141
+
142
+ const openForLink = React.useCallback(
143
+ (linkEl: HTMLAnchorElement) => {
144
+ clearAfterCloseTimer();
145
+ linkElRef.current = linkEl;
146
+ setInitialHref(linkEl.getAttribute('href') || '');
147
+ setInitialText(safeTextContent(linkEl));
148
+
149
+ setMode('toolbar');
150
+ computeAnchor();
151
+ setOpen(true);
152
+ },
153
+ [clearAfterCloseTimer, computeAnchor],
154
+ );
155
+
156
+ React.useEffect(() => {
157
+ if (!editor || !editor.view?.dom) return;
158
+ if (!editor.isEditable) return;
159
+
160
+ const dom = editor.view.dom;
161
+
162
+ const handlePointerOver = (event: PointerEvent) => {
163
+ const linkEl = getClosestLinkEl(event.target);
164
+ if (!linkEl) return;
165
+
166
+ if (linkElRef.current !== linkEl) {
167
+ clearShowTimer();
168
+ clearCloseTimer();
169
+ }
170
+
171
+ hoveringLinkRef.current = true;
172
+ linkElRef.current = linkEl;
173
+
174
+ if (open && mode === 'edit') {
175
+ return;
176
+ }
177
+
178
+ if (open && linkElRef.current === linkEl) {
179
+ computeAnchor();
180
+ return;
181
+ }
182
+
183
+ clearShowTimer();
184
+ showTimerRef.current = window.setTimeout(() => {
185
+ if (!hoveringLinkRef.current) return;
186
+ openForLink(linkEl);
187
+ }, openDelay);
188
+ };
189
+
190
+ const handlePointerOut = (event: PointerEvent) => {
191
+ const fromLinkEl = getClosestLinkEl(event.target);
192
+ if (!fromLinkEl) return;
193
+
194
+ const toLinkEl = getClosestLinkEl(event.relatedTarget);
195
+ if (toLinkEl && toLinkEl === fromLinkEl) return;
196
+
197
+ hoveringLinkRef.current = false;
198
+ clearShowTimer();
199
+
200
+ if (open) scheduleCloseIfNeeded();
201
+ };
202
+
203
+ dom.addEventListener('pointerover', handlePointerOver);
204
+ dom.addEventListener('pointerout', handlePointerOut);
205
+
206
+ return () => {
207
+ dom.removeEventListener('pointerover', handlePointerOver);
208
+ dom.removeEventListener('pointerout', handlePointerOut);
209
+ clearShowTimer();
210
+ clearCloseTimer();
211
+ clearAfterCloseTimer();
212
+ };
213
+ }, [
214
+ clearCloseTimer,
215
+ clearAfterCloseTimer,
216
+ clearShowTimer,
217
+ computeAnchor,
218
+ editor,
219
+ mode,
220
+ open,
221
+ openDelay,
222
+ openForLink,
223
+ scheduleCloseIfNeeded,
224
+ ]);
225
+
226
+ React.useEffect(() => {
227
+ if (!open) return;
228
+
229
+ const handleWindow = () => computeAnchor();
230
+ window.addEventListener('scroll', handleWindow, true);
231
+ window.addEventListener('resize', handleWindow);
232
+
233
+ return () => {
234
+ window.removeEventListener('scroll', handleWindow, true);
235
+ window.removeEventListener('resize', handleWindow);
236
+ };
237
+ }, [computeAnchor, open]);
238
+
239
+ if (!editor || !editor.isEditable) return null;
240
+
241
+ const href = initialHref.trim();
242
+
243
+ const selectHoveredLink = (options?: { focus?: boolean }) => {
244
+ const focus = options?.focus ?? false;
245
+ const linkEl = linkElRef.current;
246
+ if (!linkEl) return;
247
+
248
+ try {
249
+ const pos = editor.view.posAtDOM(linkEl, 0);
250
+ if (focus) {
251
+ editor.chain().focus().setTextSelection(pos).run();
252
+ } else {
253
+ editor.commands.setTextSelection(pos);
254
+ }
255
+ } catch {
256
+ if (focus) editor.chain().focus().run();
257
+ }
258
+ };
259
+
260
+ const unlink = () => {
261
+ selectHoveredLink({ focus: true });
262
+ editor.chain().extendMarkRange('link').unsetLink().run();
263
+ closePopover();
264
+ };
265
+
266
+ return (
267
+ <div
268
+ className={cn('pointer-events-none absolute inset-0', className)}
269
+ {...props}
270
+ >
271
+ <Popover
272
+ open={open}
273
+ onOpenChange={(nextOpen) => {
274
+ setOpen(nextOpen);
275
+ if (!nextOpen) {
276
+ resetInteractionState();
277
+ scheduleResetAfterClose();
278
+ } else {
279
+ clearAfterCloseTimer();
280
+ }
281
+ }}
282
+ >
283
+ {anchor && (
284
+ <PopoverAnchor asChild>
285
+ <span
286
+ aria-hidden="true"
287
+ className="absolute"
288
+ style={{
289
+ left: anchor.left,
290
+ top: anchor.top,
291
+ width: 1,
292
+ height: 1,
293
+ }}
294
+ />
295
+ </PopoverAnchor>
296
+ )}
297
+
298
+ <PopoverContent
299
+ align="start"
300
+ sideOffset={6}
301
+ className={cn(
302
+ 'w-105 shadow-lg',
303
+ mode === 'toolbar' ? 'px-4 py-3' : 'p-5',
304
+ )}
305
+ onOpenAutoFocus={(e) => e.preventDefault()}
306
+ onCloseAutoFocus={(e) => e.preventDefault()}
307
+ onMouseEnter={() => {
308
+ hoveringPopoverRef.current = true;
309
+ clearCloseTimer();
310
+ }}
311
+ onMouseLeave={() => {
312
+ hoveringPopoverRef.current = false;
313
+ scheduleCloseIfNeeded();
314
+ }}
315
+ >
316
+ {mode === 'toolbar' ? (
317
+ <div className="flex items-center gap-2">
318
+ <div className="min-w-0 flex-1 truncate text-sm text-foreground">
319
+ {href}
320
+ </div>
321
+
322
+ <TooltipProvider>
323
+ <Tooltip delayDuration={700}>
324
+ <TooltipTrigger asChild>
325
+ <Button
326
+ variant="ghost"
327
+ size="sm"
328
+ className="size-6 px-0"
329
+ onClick={() => {
330
+ // NOTE: 不要在这里 focus 编辑器,否则 Radix Popover 会因为 focus outside 直接 dismiss,
331
+ // 从而出现“偶尔没切到表单而是关闭弹层”的竞态。
332
+ clearCloseTimer();
333
+ selectHoveredLink({ focus: false });
334
+ setMode('edit');
335
+ setOpen(true);
336
+ }}
337
+ >
338
+ <Pencil className="size-4" />
339
+ <span className="sr-only">编辑链接</span>
340
+ </Button>
341
+ </TooltipTrigger>
342
+ <TooltipContent>
343
+ <p>编辑链接</p>
344
+ </TooltipContent>
345
+ </Tooltip>
346
+
347
+ <Tooltip delayDuration={700}>
348
+ <TooltipTrigger asChild>
349
+ <Button
350
+ variant="ghost"
351
+ size="sm"
352
+ className="size-6 px-0"
353
+ onClick={unlink}
354
+ >
355
+ <Link2Off className="size-4" />
356
+ <span className="sr-only">移除链接</span>
357
+ </Button>
358
+ </TooltipTrigger>
359
+ <TooltipContent>
360
+ <p>移除链接</p>
361
+ </TooltipContent>
362
+ </Tooltip>
363
+ </TooltipProvider>
364
+ </div>
365
+ ) : (
366
+ <LinkEditForm
367
+ open={open && mode === 'edit'}
368
+ initialText={initialText}
369
+ initialHref={initialHref}
370
+ autoFocusHref
371
+ onDone={() => {
372
+ closePopover();
373
+ }}
374
+ />
375
+ )}
376
+ </PopoverContent>
377
+ </Popover>
378
+ </div>
379
+ );
380
+ }