@thangph2146/lexical-editor 0.0.11 → 0.0.13
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/README.md +2 -1
- package/dist/editor-x/editor.cjs +280 -20
- package/dist/editor-x/editor.cjs.map +1 -1
- package/dist/editor-x/editor.css +27 -4
- package/dist/editor-x/editor.css.map +1 -1
- package/dist/editor-x/editor.js +281 -21
- package/dist/editor-x/editor.js.map +1 -1
- package/dist/index.cjs +292 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +27 -4
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +26 -1
- package/dist/index.d.ts +26 -1
- package/dist/index.js +293 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/lexical-editor.tsx +19 -6
- package/src/context/uploads-context.tsx +1 -0
- package/src/editor-ui/content-editable.tsx +18 -2
- package/src/editor-x/nodes.ts +2 -0
- package/src/nodes/download-link-node.tsx +118 -0
- package/src/plugins/floating-link-editor-plugin.tsx +338 -91
- package/src/themes/core/_tables.scss +0 -1
- package/src/themes/plugins/_floating-link-editor.scss +28 -2
- package/src/themes/ui-components/_button.scss +1 -1
- package/src/themes/ui-components/_flex.scss +1 -0
- package/src/ui/button-group.tsx +10 -10
- package/src/ui/button.tsx +38 -38
- package/src/ui/collapsible.tsx +67 -67
- package/src/ui/command.tsx +48 -48
- package/src/ui/dialog.tsx +146 -146
- package/src/ui/flex.tsx +45 -45
- package/src/ui/input.tsx +20 -20
- package/src/ui/label.tsx +20 -20
- package/src/ui/number-input.tsx +104 -104
- package/src/ui/popover.tsx +128 -128
- package/src/ui/scroll-area.tsx +17 -17
- package/src/ui/select.tsx +171 -171
- package/src/ui/separator.tsx +20 -20
- package/src/ui/slider.tsx +14 -14
- package/src/ui/slot.tsx +3 -3
- package/src/ui/tabs.tsx +87 -87
- package/src/ui/toggle-group.tsx +109 -109
- package/src/ui/toggle.tsx +28 -28
- package/src/ui/tooltip.tsx +28 -28
- package/src/ui/typography.tsx +44 -44
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
|
18
18
|
import { $findMatchingParent, $wrapNodeInElement, mergeRegister } from "@lexical/utils"
|
|
19
19
|
import {
|
|
20
|
+
$createTextNode,
|
|
20
21
|
$getSelection,
|
|
21
22
|
$isLineBreakNode,
|
|
22
23
|
$isNodeSelection,
|
|
@@ -31,7 +32,7 @@ import {
|
|
|
31
32
|
LexicalEditor,
|
|
32
33
|
SELECTION_CHANGE_COMMAND,
|
|
33
34
|
} from "lexical"
|
|
34
|
-
import { Check, Pencil, Trash, X } from "lucide-react"
|
|
35
|
+
import { Check, Pencil, Trash, X, Upload, Loader2 } from "lucide-react"
|
|
35
36
|
import { createPortal } from "react-dom"
|
|
36
37
|
|
|
37
38
|
import { getSelectedNode } from "../utils/get-selected-node"
|
|
@@ -42,6 +43,72 @@ import { Input } from "../ui/input"
|
|
|
42
43
|
import { Flex } from "../ui/flex"
|
|
43
44
|
import { TypographyPSmall } from "../ui/typography"
|
|
44
45
|
import { $isImageNode } from "../nodes/image-node"
|
|
46
|
+
import { $createDownloadLinkNode, $isDownloadLinkNode } from "../nodes/download-link-node"
|
|
47
|
+
import { useEditorUploads } from "../context/uploads-context"
|
|
48
|
+
|
|
49
|
+
function shouldTreatUrlAsDownload(url: string): boolean {
|
|
50
|
+
// Handle absolute file URLs and internal uploads.
|
|
51
|
+
if (typeof url !== "string") return false
|
|
52
|
+
const u = url.toLowerCase()
|
|
53
|
+
if (
|
|
54
|
+
u.includes("/api/uploads/") ||
|
|
55
|
+
u.includes("/uploads/") ||
|
|
56
|
+
u.includes("/api/admin/uploads/") ||
|
|
57
|
+
u.includes("/admin/uploads/")
|
|
58
|
+
)
|
|
59
|
+
return true
|
|
60
|
+
|
|
61
|
+
// Common downloadable file extensions.
|
|
62
|
+
return /\.(pdf|doc|docx|xls|xlsx|csv|zip|rar|7z|txt|rtf|png|jpg|jpeg|gif|webp|mp3|wav|mp4|mov|avi)(\?.*)?$/.test(
|
|
63
|
+
u
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function inferDownloadFileName(url: string): string {
|
|
68
|
+
try {
|
|
69
|
+
const path = url.split("?")[0] ?? ""
|
|
70
|
+
const last = path.split("/").filter(Boolean).pop()
|
|
71
|
+
return last ? decodeURIComponent(last) : "download"
|
|
72
|
+
} catch {
|
|
73
|
+
return "download"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getCookieValue(name: string): string | null {
|
|
78
|
+
if (typeof document === "undefined") return null
|
|
79
|
+
const row = document.cookie
|
|
80
|
+
.split("; ")
|
|
81
|
+
.find((item) => item.startsWith(`${name}=`))
|
|
82
|
+
if (!row) return null
|
|
83
|
+
return row.split("=").slice(1).join("=") || null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
function buildHrefFromJsDownloadArg(jsArg: string): string {
|
|
89
|
+
// jsArg is the inner string from javascript:download("...").
|
|
90
|
+
// It might be a relativePath like `files/2026/04/02/foo.pdf`, or an absolute URL.
|
|
91
|
+
const firstSegment = typeof window !== "undefined" ? window.location.pathname.split("/").filter(Boolean)[0] ?? "" : ""
|
|
92
|
+
const serveBase =
|
|
93
|
+
firstSegment === "admin"
|
|
94
|
+
? "/api/admin/uploads/serve"
|
|
95
|
+
: "/api/uploads/serve"
|
|
96
|
+
|
|
97
|
+
const arg = jsArg.trim()
|
|
98
|
+
if (!arg) return "about:blank"
|
|
99
|
+
if (/^https?:\/\//i.test(arg)) return arg
|
|
100
|
+
if (arg.startsWith("/api/")) return arg
|
|
101
|
+
|
|
102
|
+
if (arg.startsWith("images/") || arg.startsWith("files/")) {
|
|
103
|
+
return `${serveBase}/${arg}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const m = arg.match(/(images|files)\/.+/i)
|
|
107
|
+
if (m?.[0]) return `${serveBase}/${m[0]}`
|
|
108
|
+
|
|
109
|
+
// Can't reliably map "filename-only" to a full relativePath.
|
|
110
|
+
return "about:blank"
|
|
111
|
+
}
|
|
45
112
|
|
|
46
113
|
function FloatingLinkEditor({
|
|
47
114
|
editor,
|
|
@@ -63,6 +130,9 @@ function FloatingLinkEditor({
|
|
|
63
130
|
const [linkUrl, setLinkUrl] = useState("")
|
|
64
131
|
const [editedLinkUrl, setEditedLinkUrl] = useState("https://")
|
|
65
132
|
const [lastSelection, setLastSelection] = useState<BaseSelection | null>(null)
|
|
133
|
+
const [isUploadingFile, setIsUploadingFile] = useState(false)
|
|
134
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
135
|
+
const { onUploadFile } = useEditorUploads()
|
|
66
136
|
|
|
67
137
|
const $updateLinkEditor = useCallback(() => {
|
|
68
138
|
const selection = $getSelection()
|
|
@@ -122,8 +192,8 @@ function FloatingLinkEditor({
|
|
|
122
192
|
|
|
123
193
|
// Check if we have a valid selection (with or without link)
|
|
124
194
|
const hasValidSelection = selection !== null
|
|
125
|
-
const hasValidNativeSelection =
|
|
126
|
-
nativeSelection !== null &&
|
|
195
|
+
const hasValidNativeSelection =
|
|
196
|
+
nativeSelection !== null &&
|
|
127
197
|
rootElement !== null &&
|
|
128
198
|
(nativeSelection.anchorNode && rootElement.contains(nativeSelection.anchorNode))
|
|
129
199
|
|
|
@@ -147,12 +217,12 @@ function FloatingLinkEditor({
|
|
|
147
217
|
if (shouldShowEditor) {
|
|
148
218
|
// For node selection (e.g., image), try to get the DOM element
|
|
149
219
|
let domRect: DOMRect | undefined
|
|
150
|
-
|
|
220
|
+
|
|
151
221
|
if ($isNodeSelection(selection) && selectedNode) {
|
|
152
222
|
// Try to get DOM element using node key
|
|
153
223
|
const nodeKey = selectedNode.getKey()
|
|
154
224
|
const nodeElement = editor.getElementByKey(nodeKey)
|
|
155
|
-
|
|
225
|
+
|
|
156
226
|
if (nodeElement) {
|
|
157
227
|
// For image nodes wrapped in links, find the link element
|
|
158
228
|
if ($isImageNode(selectedNode) && linkNode) {
|
|
@@ -168,7 +238,7 @@ function FloatingLinkEditor({
|
|
|
168
238
|
}
|
|
169
239
|
}
|
|
170
240
|
}
|
|
171
|
-
|
|
241
|
+
|
|
172
242
|
// Fallback to native selection if we don't have a specific DOM rect
|
|
173
243
|
if (!domRect && nativeSelection) {
|
|
174
244
|
// Try to find link element in DOM if we have a link node
|
|
@@ -180,7 +250,7 @@ function FloatingLinkEditor({
|
|
|
180
250
|
} else if (nativeSelection.focusNode.parentElement) {
|
|
181
251
|
focusElement = nativeSelection.focusNode.parentElement
|
|
182
252
|
}
|
|
183
|
-
|
|
253
|
+
|
|
184
254
|
if (focusElement) {
|
|
185
255
|
const linkElement = focusElement.closest("a") || focusElement.parentElement?.closest("a")
|
|
186
256
|
if (linkElement) {
|
|
@@ -322,13 +392,16 @@ function FloatingLinkEditor({
|
|
|
322
392
|
}
|
|
323
393
|
}
|
|
324
394
|
|
|
325
|
-
const handleLinkSubmission = () => {
|
|
326
|
-
const
|
|
327
|
-
|
|
395
|
+
const handleLinkSubmission = (submittedUrl?: string, originalFileName?: string) => {
|
|
396
|
+
const rawUrl = typeof submittedUrl === "string" ? submittedUrl : editedLinkUrl
|
|
397
|
+
const url = sanitizeUrl(rawUrl)
|
|
398
|
+
const downloadFileName = originalFileName || (shouldTreatUrlAsDownload(url) ? inferDownloadFileName(url) : null)
|
|
399
|
+
// Block unsafe protocols (e.g. `javascript:`). `sanitizeUrl()` returns `about:blank` for unsupported schemes.
|
|
400
|
+
if (url && url !== "about:blank" && url !== "https://" && url !== "http://") {
|
|
328
401
|
editor.update(() => {
|
|
329
402
|
// Try to get current selection first
|
|
330
403
|
let selection = $getSelection()
|
|
331
|
-
|
|
404
|
+
|
|
332
405
|
// If no current selection, try to restore from lastSelection
|
|
333
406
|
if (!selection && lastSelection !== null) {
|
|
334
407
|
// Clone the selection to avoid frozen object error
|
|
@@ -342,29 +415,34 @@ function FloatingLinkEditor({
|
|
|
342
415
|
selection = $getSelection()
|
|
343
416
|
}
|
|
344
417
|
}
|
|
345
|
-
|
|
418
|
+
|
|
346
419
|
if (!selection) {
|
|
347
420
|
return
|
|
348
421
|
}
|
|
349
|
-
|
|
422
|
+
|
|
350
423
|
// Handle node selection (e.g., image nodes)
|
|
351
424
|
if ($isNodeSelection(selection)) {
|
|
352
425
|
const nodes = selection.getNodes()
|
|
353
426
|
if (nodes.length > 0) {
|
|
354
427
|
const node = nodes[0]
|
|
355
|
-
|
|
428
|
+
|
|
356
429
|
// If it's an image node
|
|
357
430
|
if ($isImageNode(node)) {
|
|
358
431
|
// Check if already wrapped in a link
|
|
359
|
-
const existingLinkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
432
|
+
const existingLinkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
360
433
|
($isLinkNode(node.getParent()) ? node.getParent() : null)
|
|
361
|
-
|
|
434
|
+
|
|
362
435
|
if (existingLinkNode) {
|
|
363
436
|
// Update existing link
|
|
364
437
|
existingLinkNode.setURL(url)
|
|
438
|
+
if (downloadFileName && $isDownloadLinkNode(existingLinkNode)) {
|
|
439
|
+
existingLinkNode.setDownload(downloadFileName)
|
|
440
|
+
}
|
|
365
441
|
} else {
|
|
366
442
|
// Wrap image in link using wrapNodeInElement (safe for all cases including root)
|
|
367
|
-
const linkNode =
|
|
443
|
+
const linkNode = downloadFileName
|
|
444
|
+
? $createDownloadLinkNode(url, downloadFileName)
|
|
445
|
+
: $createLinkNode(url)
|
|
368
446
|
$wrapNodeInElement(node, () => linkNode)
|
|
369
447
|
}
|
|
370
448
|
}
|
|
@@ -372,23 +450,131 @@ function FloatingLinkEditor({
|
|
|
372
450
|
}
|
|
373
451
|
// Handle range selection
|
|
374
452
|
else if ($isRangeSelection(selection)) {
|
|
375
|
-
// Use default TOGGLE_LINK_COMMAND for range selection
|
|
453
|
+
// Use default TOGGLE_LINK_COMMAND for range selection, then post-process to add `download`.
|
|
376
454
|
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url)
|
|
377
455
|
const parent = getSelectedNode(selection).getParent()
|
|
378
456
|
if ($isAutoLinkNode(parent)) {
|
|
379
|
-
const linkNode =
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
457
|
+
const linkNode = downloadFileName
|
|
458
|
+
? $createDownloadLinkNode(parent.getURL(), downloadFileName, {
|
|
459
|
+
rel: parent.__rel,
|
|
460
|
+
target: parent.__target,
|
|
461
|
+
title: parent.__title,
|
|
462
|
+
})
|
|
463
|
+
: $createLinkNode(parent.getURL(), {
|
|
464
|
+
rel: parent.__rel,
|
|
465
|
+
target: parent.__target,
|
|
466
|
+
title: parent.__title,
|
|
467
|
+
})
|
|
384
468
|
parent.replace(linkNode, true)
|
|
385
469
|
}
|
|
470
|
+
|
|
471
|
+
if (downloadFileName) {
|
|
472
|
+
const selectedNode = getSelectedNode(selection)
|
|
473
|
+
// After TOGGLE_LINK_COMMAND, selection's node should be wrapped in a link node.
|
|
474
|
+
const linkNode = $findMatchingParent(selectedNode, $isLinkNode) || ($isLinkNode(selectedNode) ? selectedNode : null)
|
|
475
|
+
if (linkNode) {
|
|
476
|
+
const currentText = linkNode.getTextContent()
|
|
477
|
+
const targetText = originalFileName || downloadFileName
|
|
478
|
+
|
|
479
|
+
const downloadLinkNode = $createDownloadLinkNode(url, downloadFileName, {
|
|
480
|
+
rel: linkNode.getRel(),
|
|
481
|
+
target: linkNode.getTarget(),
|
|
482
|
+
title: linkNode.getTitle(),
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
if (currentText === url && targetText) {
|
|
486
|
+
// If the user didn't have any text selected, Lexical's TOGGLE_LINK inserted the raw URL.
|
|
487
|
+
// Replace that text with our targetFileName.
|
|
488
|
+
downloadLinkNode.append($createTextNode(targetText))
|
|
489
|
+
} else {
|
|
490
|
+
// Otherwise keep the children (user's selection)
|
|
491
|
+
const children = linkNode.getChildren()
|
|
492
|
+
if (children.length > 0) {
|
|
493
|
+
downloadLinkNode.append(...children)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
linkNode.replace(downloadLinkNode)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
386
500
|
}
|
|
387
501
|
})
|
|
388
502
|
setEditedLinkUrl("https://")
|
|
389
503
|
setIsLinkEditMode(false)
|
|
390
504
|
}
|
|
391
505
|
}
|
|
506
|
+
|
|
507
|
+
const handlePickLocalFile = () => {
|
|
508
|
+
if (isUploadingFile) return
|
|
509
|
+
fileInputRef.current?.click()
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const handleUploadLocalFile = async (
|
|
513
|
+
event: React.ChangeEvent<HTMLInputElement>
|
|
514
|
+
) => {
|
|
515
|
+
const file = event.target.files?.[0]
|
|
516
|
+
if (!file) return
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
setIsUploadingFile(true)
|
|
520
|
+
let uploadedUrl: string | undefined = undefined
|
|
521
|
+
|
|
522
|
+
if (onUploadFile) {
|
|
523
|
+
const result = await onUploadFile(file)
|
|
524
|
+
if (result.error) throw new Error(result.error)
|
|
525
|
+
uploadedUrl = result.url
|
|
526
|
+
} else {
|
|
527
|
+
const formData = new FormData()
|
|
528
|
+
formData.append("file", file)
|
|
529
|
+
|
|
530
|
+
const firstSegment = typeof window !== "undefined" ? window.location.pathname.split("/").filter(Boolean)[0] ?? "" : ""
|
|
531
|
+
const pathPart = firstSegment === "admin" ? "/admin/uploads" : "/uploads"
|
|
532
|
+
const endpoint = firstSegment === "admin" ? `/admin/api${pathPart}` : `/api${pathPart}`
|
|
533
|
+
|
|
534
|
+
const userId = getCookieValue("app_user_id")
|
|
535
|
+
const authToken = getCookieValue("auth-token")
|
|
536
|
+
const headers: Record<string, string> = {}
|
|
537
|
+
if (userId) headers["X-User-Id"] = userId
|
|
538
|
+
if (authToken) headers["Authorization"] = `Bearer ${authToken}`
|
|
539
|
+
|
|
540
|
+
const res = await fetch(endpoint, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
credentials: "include",
|
|
543
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
544
|
+
body: formData,
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
if (!res.ok) {
|
|
548
|
+
throw new Error(`Upload failed: HTTP ${res.status}`)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const payload = (await res.json()) as {
|
|
552
|
+
success?: boolean
|
|
553
|
+
data?: { url?: string }
|
|
554
|
+
message?: string
|
|
555
|
+
error?: string
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
uploadedUrl = payload?.data?.url
|
|
559
|
+
if (!payload?.success || !uploadedUrl) {
|
|
560
|
+
throw new Error(payload?.message || payload?.error || "Upload failed")
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (uploadedUrl) {
|
|
565
|
+
setEditedLinkUrl(uploadedUrl)
|
|
566
|
+
handleLinkSubmission(uploadedUrl, file.name)
|
|
567
|
+
}
|
|
568
|
+
} catch (error) {
|
|
569
|
+
console.error("[FloatingLinkEditor] Upload local file failed:", error)
|
|
570
|
+
} finally {
|
|
571
|
+
setIsUploadingFile(false)
|
|
572
|
+
if (fileInputRef.current) {
|
|
573
|
+
fileInputRef.current.value = ""
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
392
578
|
return (
|
|
393
579
|
<div
|
|
394
580
|
ref={editorRef}
|
|
@@ -396,43 +582,104 @@ function FloatingLinkEditor({
|
|
|
396
582
|
>
|
|
397
583
|
{isLinkEditMode || isLink ? (
|
|
398
584
|
isLinkEditMode ? (
|
|
399
|
-
|
|
400
|
-
<
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
585
|
+
<>
|
|
586
|
+
<div className="editor-floating-link-editor__input-container">
|
|
587
|
+
<input
|
|
588
|
+
ref={fileInputRef}
|
|
589
|
+
type="file"
|
|
590
|
+
className="hidden"
|
|
591
|
+
onChange={handleUploadLocalFile}
|
|
592
|
+
accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.rtf,.txt,.zip,.rar,.7z,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.svg,.mp3,.wav,.mp4,.mov,.avi,.webm"
|
|
593
|
+
/>
|
|
594
|
+
<Input
|
|
595
|
+
ref={inputRef}
|
|
596
|
+
value={editedLinkUrl}
|
|
597
|
+
onChange={(event) => setEditedLinkUrl(event.target.value)}
|
|
598
|
+
onKeyDown={monitorInputInteraction}
|
|
599
|
+
className="editor-flex-grow"
|
|
600
|
+
/>
|
|
601
|
+
<Button
|
|
602
|
+
size="icon"
|
|
603
|
+
variant="ghost"
|
|
604
|
+
onClick={handlePickLocalFile}
|
|
605
|
+
className="editor-shrink-0"
|
|
606
|
+
disabled={isUploadingFile}
|
|
607
|
+
title={isUploadingFile ? "Uploading..." : "Upload file từ thiết bị"}
|
|
608
|
+
>
|
|
609
|
+
{isUploadingFile ? (
|
|
610
|
+
<Loader2 className="editor-icon-sm animate-spin" />
|
|
611
|
+
) : (
|
|
612
|
+
<Upload className="editor-icon-sm" />
|
|
613
|
+
)}
|
|
614
|
+
</Button>
|
|
615
|
+
<Button
|
|
616
|
+
size="icon"
|
|
617
|
+
variant="ghost"
|
|
618
|
+
onClick={() => {
|
|
619
|
+
setIsLinkEditMode(false)
|
|
620
|
+
setIsLink(false)
|
|
621
|
+
}}
|
|
622
|
+
className="editor-shrink-0"
|
|
623
|
+
>
|
|
624
|
+
<X className="editor-icon-sm" />
|
|
625
|
+
</Button>
|
|
626
|
+
<Button
|
|
627
|
+
size="icon"
|
|
628
|
+
onClick={() => handleLinkSubmission()}
|
|
629
|
+
className="editor-shrink-0"
|
|
630
|
+
>
|
|
631
|
+
<Check className="editor-icon-sm" />
|
|
632
|
+
</Button>
|
|
633
|
+
</div>
|
|
634
|
+
</>
|
|
426
635
|
) : (
|
|
427
636
|
<div className="editor-floating-link-editor__view-container">
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
637
|
+
{/**
|
|
638
|
+
* UX: show sanitized/derived text instead of raw input (prevents showing `javascript:...`).
|
|
639
|
+
* `sanitizeUrl()` may return `about:blank` for unsupported protocols.
|
|
640
|
+
*/}
|
|
641
|
+
|
|
642
|
+
{(() => {
|
|
643
|
+
let href = sanitizeUrl(linkUrl)
|
|
644
|
+
const jsDownloadMatch =
|
|
645
|
+
typeof linkUrl === "string"
|
|
646
|
+
? linkUrl.match(/^javascript:download\(\s*(['"])(.*?)\1\s*\)\s*$/i)
|
|
647
|
+
: null
|
|
648
|
+
|
|
649
|
+
let downloadAttr: string | undefined
|
|
650
|
+
if (jsDownloadMatch) {
|
|
651
|
+
const jsArg = jsDownloadMatch[2] ?? ""
|
|
652
|
+
href = buildHrefFromJsDownloadArg(jsArg)
|
|
653
|
+
if (href !== "about:blank") {
|
|
654
|
+
downloadAttr = inferDownloadFileName(href)
|
|
655
|
+
}
|
|
656
|
+
} else if (shouldTreatUrlAsDownload(href)) {
|
|
657
|
+
downloadAttr = inferDownloadFileName(href)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const isDownload = typeof downloadAttr === "string" && downloadAttr.length > 0
|
|
661
|
+
const text =
|
|
662
|
+
jsDownloadMatch
|
|
663
|
+
? "Download"
|
|
664
|
+
: shouldTreatUrlAsDownload(href)
|
|
665
|
+
? inferDownloadFileName(href)
|
|
666
|
+
: href === "about:blank"
|
|
667
|
+
? "Invalid URL"
|
|
668
|
+
: linkUrl
|
|
669
|
+
|
|
670
|
+
return (
|
|
671
|
+
<a
|
|
672
|
+
href={href}
|
|
673
|
+
download={downloadAttr}
|
|
674
|
+
// `download` hoạt động tốt hơn nếu không mở tab mới
|
|
675
|
+
target={isDownload ? "_self" : "_blank"}
|
|
676
|
+
rel={isDownload ? undefined : "noopener noreferrer"}
|
|
677
|
+
className="editor-floating-link-editor__link"
|
|
678
|
+
>
|
|
679
|
+
<TypographyPSmall className="editor-truncate">{text}</TypographyPSmall>
|
|
680
|
+
</a>
|
|
681
|
+
)
|
|
682
|
+
})()}
|
|
436
683
|
<Flex gap={0} className="editor-shrink-0">
|
|
437
684
|
<Button
|
|
438
685
|
size="icon"
|
|
@@ -451,12 +698,12 @@ function FloatingLinkEditor({
|
|
|
451
698
|
editor.update(() => {
|
|
452
699
|
const selection = $getSelection()
|
|
453
700
|
// Handle node selection (e.g., image nodes)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
701
|
+
if ($isNodeSelection(selection)) {
|
|
702
|
+
const nodes = selection.getNodes()
|
|
703
|
+
if (nodes.length > 0) {
|
|
704
|
+
const node = nodes[0]
|
|
458
705
|
if ($isImageNode(node)) {
|
|
459
|
-
const linkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
706
|
+
const linkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
460
707
|
($isLinkNode(node.getParent()) ? node.getParent() : null)
|
|
461
708
|
if (linkNode) {
|
|
462
709
|
// Remove link by unwrapping - insert children into parent and remove link
|
|
@@ -470,16 +717,16 @@ function FloatingLinkEditor({
|
|
|
470
717
|
}
|
|
471
718
|
}
|
|
472
719
|
}
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
// Use default TOGGLE_LINK_COMMAND for range selection
|
|
723
|
+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
|
473
724
|
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}}
|
|
480
|
-
>
|
|
481
|
-
<Trash className="editor-icon-sm" />
|
|
482
|
-
</Button>
|
|
725
|
+
})
|
|
726
|
+
}}
|
|
727
|
+
>
|
|
728
|
+
<Trash className="editor-icon-sm" />
|
|
729
|
+
</Button>
|
|
483
730
|
</Flex>
|
|
484
731
|
</div>
|
|
485
732
|
)
|
|
@@ -573,19 +820,19 @@ function useFloatingLinkEditorToolbar(
|
|
|
573
820
|
TOGGLE_LINK_COMMAND,
|
|
574
821
|
(url: string | null) => {
|
|
575
822
|
const selection = $getSelection()
|
|
576
|
-
|
|
823
|
+
|
|
577
824
|
// Handle node selection (e.g., image nodes)
|
|
578
825
|
if ($isNodeSelection(selection)) {
|
|
579
826
|
const nodes = selection.getNodes()
|
|
580
827
|
if (nodes.length > 0) {
|
|
581
828
|
const node = nodes[0]
|
|
582
|
-
|
|
829
|
+
|
|
583
830
|
if ($isImageNode(node)) {
|
|
584
831
|
if (url) {
|
|
585
832
|
// Create or update link
|
|
586
|
-
const existingLinkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
833
|
+
const existingLinkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
587
834
|
($isLinkNode(node.getParent()) ? node.getParent() : null)
|
|
588
|
-
|
|
835
|
+
|
|
589
836
|
if (existingLinkNode) {
|
|
590
837
|
// Update existing link
|
|
591
838
|
existingLinkNode.setURL(url)
|
|
@@ -596,7 +843,7 @@ function useFloatingLinkEditorToolbar(
|
|
|
596
843
|
}
|
|
597
844
|
} else {
|
|
598
845
|
// Remove link
|
|
599
|
-
const linkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
846
|
+
const linkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
600
847
|
($isLinkNode(node.getParent()) ? node.getParent() : null)
|
|
601
848
|
if (linkNode) {
|
|
602
849
|
// Remove link by unwrapping - insert children into parent and remove link
|
|
@@ -614,7 +861,7 @@ function useFloatingLinkEditorToolbar(
|
|
|
614
861
|
}
|
|
615
862
|
}
|
|
616
863
|
}
|
|
617
|
-
|
|
864
|
+
|
|
618
865
|
// Let default handler process range selection
|
|
619
866
|
return false
|
|
620
867
|
},
|
|
@@ -642,7 +889,7 @@ function useFloatingLinkEditorToolbar(
|
|
|
642
889
|
if (nodes.length > 0) {
|
|
643
890
|
const node = nodes[0]
|
|
644
891
|
if ($isImageNode(node)) {
|
|
645
|
-
const linkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
892
|
+
const linkNode = $findMatchingParent(node, $isLinkNode) ||
|
|
646
893
|
($isLinkNode(node.getParent()) ? node.getParent() : null)
|
|
647
894
|
if (linkNode) {
|
|
648
895
|
// Delay to ensure DOM is updated
|
|
@@ -664,10 +911,10 @@ function useFloatingLinkEditorToolbar(
|
|
|
664
911
|
CLICK_COMMAND,
|
|
665
912
|
(payload) => {
|
|
666
913
|
let shouldReturnTrue = false
|
|
667
|
-
|
|
914
|
+
|
|
668
915
|
editor.getEditorState().read(() => {
|
|
669
916
|
const selection = $getSelection()
|
|
670
|
-
|
|
917
|
+
|
|
671
918
|
// Check if we clicked on an image node (with or without link)
|
|
672
919
|
let hasImageNode = false
|
|
673
920
|
if ($isNodeSelection(selection)) {
|
|
@@ -679,7 +926,7 @@ function useFloatingLinkEditorToolbar(
|
|
|
679
926
|
}
|
|
680
927
|
}
|
|
681
928
|
}
|
|
682
|
-
|
|
929
|
+
|
|
683
930
|
// Handle Ctrl/Cmd + click to open link
|
|
684
931
|
if (payload.metaKey || payload.ctrlKey) {
|
|
685
932
|
if ($isRangeSelection(selection)) {
|
|
@@ -705,15 +952,15 @@ function useFloatingLinkEditorToolbar(
|
|
|
705
952
|
linkNode = node
|
|
706
953
|
} else {
|
|
707
954
|
if (node) {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
955
|
+
linkNode = $findMatchingParent(node, $isLinkNode)
|
|
956
|
+
if (!linkNode && $isImageNode(node)) {
|
|
957
|
+
const parent = node.getParent()
|
|
958
|
+
if ($isLinkNode(parent)) {
|
|
959
|
+
linkNode = parent
|
|
960
|
+
}
|
|
713
961
|
}
|
|
714
962
|
}
|
|
715
963
|
}
|
|
716
|
-
}
|
|
717
964
|
if (linkNode) {
|
|
718
965
|
const url = linkNode.getURL()
|
|
719
966
|
if (url && validateUrl(url)) {
|
|
@@ -725,7 +972,7 @@ function useFloatingLinkEditorToolbar(
|
|
|
725
972
|
}
|
|
726
973
|
}
|
|
727
974
|
}
|
|
728
|
-
|
|
975
|
+
|
|
729
976
|
// If we clicked on an image (with or without link), trigger toolbar update
|
|
730
977
|
if (hasImageNode) {
|
|
731
978
|
// Use requestAnimationFrame to ensure selection is updated
|
|
@@ -736,11 +983,11 @@ function useFloatingLinkEditorToolbar(
|
|
|
736
983
|
})
|
|
737
984
|
}
|
|
738
985
|
})
|
|
739
|
-
|
|
986
|
+
|
|
740
987
|
if (shouldReturnTrue) {
|
|
741
988
|
return true
|
|
742
989
|
}
|
|
743
|
-
|
|
990
|
+
|
|
744
991
|
// Trigger toolbar update on click to ensure floating editor shows
|
|
745
992
|
setTimeout(() => {
|
|
746
993
|
editor.getEditorState().read(() => {
|