@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.
Files changed (46) hide show
  1. package/README.md +2 -1
  2. package/dist/editor-x/editor.cjs +280 -20
  3. package/dist/editor-x/editor.cjs.map +1 -1
  4. package/dist/editor-x/editor.css +27 -4
  5. package/dist/editor-x/editor.css.map +1 -1
  6. package/dist/editor-x/editor.js +281 -21
  7. package/dist/editor-x/editor.js.map +1 -1
  8. package/dist/index.cjs +292 -23
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.css +27 -4
  11. package/dist/index.css.map +1 -1
  12. package/dist/index.d.cts +26 -1
  13. package/dist/index.d.ts +26 -1
  14. package/dist/index.js +293 -24
  15. package/dist/index.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/components/lexical-editor.tsx +19 -6
  18. package/src/context/uploads-context.tsx +1 -0
  19. package/src/editor-ui/content-editable.tsx +18 -2
  20. package/src/editor-x/nodes.ts +2 -0
  21. package/src/nodes/download-link-node.tsx +118 -0
  22. package/src/plugins/floating-link-editor-plugin.tsx +338 -91
  23. package/src/themes/core/_tables.scss +0 -1
  24. package/src/themes/plugins/_floating-link-editor.scss +28 -2
  25. package/src/themes/ui-components/_button.scss +1 -1
  26. package/src/themes/ui-components/_flex.scss +1 -0
  27. package/src/ui/button-group.tsx +10 -10
  28. package/src/ui/button.tsx +38 -38
  29. package/src/ui/collapsible.tsx +67 -67
  30. package/src/ui/command.tsx +48 -48
  31. package/src/ui/dialog.tsx +146 -146
  32. package/src/ui/flex.tsx +45 -45
  33. package/src/ui/input.tsx +20 -20
  34. package/src/ui/label.tsx +20 -20
  35. package/src/ui/number-input.tsx +104 -104
  36. package/src/ui/popover.tsx +128 -128
  37. package/src/ui/scroll-area.tsx +17 -17
  38. package/src/ui/select.tsx +171 -171
  39. package/src/ui/separator.tsx +20 -20
  40. package/src/ui/slider.tsx +14 -14
  41. package/src/ui/slot.tsx +3 -3
  42. package/src/ui/tabs.tsx +87 -87
  43. package/src/ui/toggle-group.tsx +109 -109
  44. package/src/ui/toggle.tsx +28 -28
  45. package/src/ui/tooltip.tsx +28 -28
  46. 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 url = sanitizeUrl(editedLinkUrl)
327
- if (url && url !== "https://" && url !== "http://") {
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 = $createLinkNode(url)
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 = $createLinkNode(parent.getURL(), {
380
- rel: parent.__rel,
381
- target: parent.__target,
382
- title: parent.__title,
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
- <div className="editor-floating-link-editor__input-container">
400
- <Input
401
- ref={inputRef}
402
- value={editedLinkUrl}
403
- onChange={(event) => setEditedLinkUrl(event.target.value)}
404
- onKeyDown={monitorInputInteraction}
405
- className="editor-flex-grow"
406
- />
407
- <Button
408
- size="icon"
409
- variant="ghost"
410
- onClick={() => {
411
- setIsLinkEditMode(false)
412
- setIsLink(false)
413
- }}
414
- className="editor-shrink-0"
415
- >
416
- <X className="editor-icon-sm" />
417
- </Button>
418
- <Button
419
- size="icon"
420
- onClick={handleLinkSubmission}
421
- className="editor-shrink-0"
422
- >
423
- <Check className="editor-icon-sm" />
424
- </Button>
425
- </div>
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
- <a
429
- href={sanitizeUrl(linkUrl)}
430
- target="_blank"
431
- rel="noopener noreferrer"
432
- className="editor-floating-link-editor__link"
433
- >
434
- <TypographyPSmall className="editor-truncate">{linkUrl}</TypographyPSmall>
435
- </a>
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
- if ($isNodeSelection(selection)) {
455
- const nodes = selection.getNodes()
456
- if (nodes.length > 0) {
457
- const node = nodes[0]
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
- } else {
475
- // Use default TOGGLE_LINK_COMMAND for range selection
476
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
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
- linkNode = $findMatchingParent(node, $isLinkNode)
709
- if (!linkNode && $isImageNode(node)) {
710
- const parent = node.getParent()
711
- if ($isLinkNode(parent)) {
712
- linkNode = parent
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(() => {
@@ -6,7 +6,6 @@
6
6
  .editor-table {
7
7
  border-collapse: collapse;
8
8
  border-spacing: 0;
9
- width: 100%;
10
9
  table-layout: fixed;
11
10
  margin: $editor-table-margin-vertical 0;
12
11
  border-radius: $editor-border-radius;