@thangph2146/lexical-editor 0.0.7 → 0.0.11

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.
@@ -1,702 +1,716 @@
1
- "use client"
2
-
3
- /**
4
- * Copyright (c) Meta Platforms, Inc. and affiliates.
5
- *
6
- * This source code is licensed under the MIT license found in the
7
- * LICENSE file in the root directory of this source tree.
8
- *
9
- */
10
- import { Dispatch, JSX, useCallback, useEffect, useRef, useState } from "react"
11
- import * as React from "react"
12
- import { $isCodeHighlightNode } from "@lexical/code"
13
- import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"
14
- import {
15
- $getSelectionStyleValueForProperty,
16
- $patchStyleText,
17
- } from "@lexical/selection"
18
- import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
19
- import { mergeRegister } from "@lexical/utils"
20
- import {
21
- $getSelection,
22
- $isParagraphNode,
23
- $isRangeSelection,
24
- $isTextNode,
25
- COMMAND_PRIORITY_LOW,
26
- FORMAT_TEXT_COMMAND,
27
- LexicalEditor,
28
- SELECTION_CHANGE_COMMAND,
29
- } from "lexical"
30
- import {
31
- BoldIcon,
32
- CodeIcon,
33
- ItalicIcon,
34
- LinkIcon,
35
- PaintBucketIcon,
36
- StrikethroughIcon,
37
- SubscriptIcon,
38
- SuperscriptIcon,
39
- UnderlineIcon,
40
- BaselineIcon,
41
- } from "lucide-react"
42
- import { createPortal } from "react-dom"
43
-
44
- import {
45
- ColorPicker,
46
- ColorPickerAlphaSlider,
47
- ColorPickerArea,
48
- ColorPickerContent,
49
- ColorPickerHueSlider,
50
- ColorPickerInput,
51
- ColorPickerPresets,
52
- } from "../editor-ui/color-picker"
53
- import { useEditorModal } from "../editor-hooks/use-modal"
54
- import { getDOMRangeRect } from "../utils/get-dom-range-rect"
55
- import { getSelectedNode } from "../utils/get-selected-node"
56
- import { setFloatingElemPosition } from "../utils/set-floating-elem-position"
57
- import { Button } from "../ui/button"
58
- import { DialogFooter } from "../ui/dialog"
59
- import { Flex } from "../ui/flex"
60
- import { Separator } from "../ui/separator"
61
- import {
62
- IconSize,
63
- } from "../ui/typography"
64
- import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"
65
-
66
- function FontColorModalContent({
67
- initialColor,
68
- onApply,
69
- onClose,
70
- }: {
71
- initialColor: string
72
- onApply: (color: string) => void
73
- onClose: () => void
74
- }) {
75
- const [color, setColor] = useState(initialColor)
76
-
77
- return (
78
- <div className="editor-list-color-dialog">
79
- <Flex direction="column" gap={4}>
80
- <div className="editor-text-xs-muted">Chọn màu cho văn bản đang chọn.</div>
81
-
82
- <ColorPicker inline value={color} onValueChange={setColor}>
83
- <ColorPickerContent className="editor-w-full editor-border-0 editor-shadow-none editor-p-0">
84
- <ColorPickerArea className="editor-h-40 editor-w-full editor-rounded-md" />
85
- <Flex direction="column" gap={3} className="editor-mt-3">
86
- <Flex direction="column" gap={2}>
87
- <ColorPickerHueSlider className="editor-w-full" />
88
- <ColorPickerAlphaSlider className="editor-w-full" />
89
- <ColorPickerInput className="editor-w-full" />
90
- </Flex>
91
- <ColorPickerPresets />
92
- </Flex>
93
- </ColorPickerContent>
94
- </ColorPicker>
95
-
96
- <DialogFooter className="editor-px-0">
97
- <Button
98
- variant="outline"
99
- size="sm"
100
- onClick={() => {
101
- onApply(color)
102
- onClose()
103
- }}
104
- className="editor-w-full"
105
- >
106
- Hoàn tất
107
- </Button>
108
- </DialogFooter>
109
- </Flex>
110
- </div>
111
- )
112
- }
113
-
114
- function BgColorModalContent({
115
- initialColor,
116
- onApply,
117
- onClose,
118
- }: {
119
- initialColor: string
120
- onApply: (color: string) => void
121
- onClose: () => void
122
- }) {
123
- const [color, setColor] = useState(initialColor)
124
-
125
- return (
126
- <div className="editor-list-color-dialog">
127
- <Flex direction="column" gap={4}>
128
- <div className="editor-text-xs-muted">
129
- Chọn màu nền cho văn bản đang chọn.
130
- </div>
131
-
132
- <ColorPicker inline value={color} onValueChange={setColor}>
133
- <ColorPickerContent className="editor-w-full editor-border-0 editor-shadow-none editor-p-0">
134
- <ColorPickerArea className="editor-h-40 editor-w-full editor-rounded-md" />
135
- <Flex direction="column" gap={3} className="editor-mt-3">
136
- <Flex direction="column" gap={2}>
137
- <ColorPickerHueSlider className="editor-w-full" />
138
- <ColorPickerAlphaSlider className="editor-w-full" />
139
- <ColorPickerInput className="editor-w-full" />
140
- </Flex>
141
- <ColorPickerPresets />
142
- </Flex>
143
- </ColorPickerContent>
144
- </ColorPicker>
145
-
146
- <DialogFooter className="editor-px-0">
147
- <Button
148
- variant="outline"
149
- size="sm"
150
- onClick={() => {
151
- onApply(color)
152
- onClose()
153
- }}
154
- className="editor-w-full"
155
- >
156
- Hoàn tất
157
- </Button>
158
- </DialogFooter>
159
- </Flex>
160
- </div>
161
- )
162
- }
163
-
164
- function FloatingTextFormat({
165
- editor,
166
- anchorElem,
167
- isLink,
168
- isBold,
169
- isItalic,
170
- isUnderline,
171
- isCode,
172
- isStrikethrough,
173
- isSubscript,
174
- isSuperscript,
175
- fontColor,
176
- bgColor,
177
- setIsLinkEditMode,
178
- showModal,
179
- isModalOpen,
180
- }: {
181
- editor: LexicalEditor
182
- anchorElem: HTMLElement
183
- isBold: boolean
184
- isCode: boolean
185
- isItalic: boolean
186
- isLink: boolean
187
- isStrikethrough: boolean
188
- isSubscript: boolean
189
- isSuperscript: boolean
190
- isUnderline: boolean
191
- fontColor: string
192
- bgColor: string
193
- setIsLinkEditMode: Dispatch<boolean>
194
- showModal: (
195
- title: string,
196
- content: (onClose: () => void) => JSX.Element
197
- ) => void
198
- isModalOpen: boolean
199
- }): JSX.Element {
200
- const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null)
201
-
202
- const insertLink = useCallback(() => {
203
- if (!isLink) {
204
- setIsLinkEditMode(true)
205
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://")
206
- } else {
207
- setIsLinkEditMode(false)
208
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
209
- }
210
- }, [editor, isLink, setIsLinkEditMode])
211
-
212
- const applyStyleText = useCallback(
213
- (styles: Record<string, string>) => {
214
- editor.update(() => {
215
- const selection = $getSelection()
216
- if ($isRangeSelection(selection)) {
217
- $patchStyleText(selection, styles)
218
- }
219
- })
220
- },
221
- [editor]
222
- )
223
-
224
- const onFontColorSelect = useCallback(
225
- (value: string) => {
226
- if (value !== "inherit") {
227
- applyStyleText({ color: value })
228
- }
229
- },
230
- [applyStyleText]
231
- )
232
-
233
- const onBgColorSelect = useCallback(
234
- (value: string) => {
235
- if (value !== "inherit") {
236
- applyStyleText({ "background-color": value })
237
- }
238
- },
239
- [applyStyleText]
240
- )
241
-
242
- const openFontColorModal = () => {
243
- showModal("Đổi màu chữ", (onClose) => (
244
- <FontColorModalContent
245
- initialColor={fontColor}
246
- onApply={onFontColorSelect}
247
- onClose={onClose}
248
- />
249
- ))
250
- }
251
-
252
- const openBgColorModal = () => {
253
- showModal("Đổi màu nền", (onClose) => (
254
- <BgColorModalContent
255
- initialColor={bgColor}
256
- onApply={onBgColorSelect}
257
- onClose={onClose}
258
- />
259
- ))
260
- }
261
-
262
- useEffect(() => {
263
- function mouseMoveListener(e: MouseEvent) {
264
- if (
265
- popupCharStylesEditorRef?.current &&
266
- (e.buttons === 1 || e.buttons === 3)
267
- ) {
268
- if (popupCharStylesEditorRef.current.style.pointerEvents !== "none") {
269
- const x = e.clientX
270
- const y = e.clientY
271
- const elementUnderMouse = document.elementFromPoint(x, y)
272
-
273
- if (
274
- !popupCharStylesEditorRef.current.contains(elementUnderMouse) &&
275
- !isModalOpen
276
- ) {
277
- // Mouse is not over the target element => not a normal click, but probably a drag
278
- popupCharStylesEditorRef.current.style.pointerEvents = "none"
279
- }
280
- }
281
- }
282
- }
283
- function mouseUpListener(_e: MouseEvent) {
284
- void _e
285
- if (popupCharStylesEditorRef?.current) {
286
- if (popupCharStylesEditorRef.current.style.pointerEvents !== "auto") {
287
- popupCharStylesEditorRef.current.style.pointerEvents = "auto"
288
- }
289
- }
290
- }
291
-
292
- if (popupCharStylesEditorRef?.current) {
293
- document.addEventListener("mousemove", mouseMoveListener)
294
- document.addEventListener("mouseup", mouseUpListener)
295
-
296
- return () => {
297
- document.removeEventListener("mousemove", mouseMoveListener)
298
- document.removeEventListener("mouseup", mouseUpListener)
299
- }
300
- }
301
- }, [popupCharStylesEditorRef, isModalOpen])
302
-
303
- const $updateTextFormatFloatingToolbar = useCallback(() => {
304
- const selection = $getSelection()
305
-
306
- const popupCharStylesEditorElem = popupCharStylesEditorRef.current
307
- const nativeSelection = window.getSelection()
308
-
309
- if (popupCharStylesEditorElem === null) {
310
- return
311
- }
312
-
313
- const rootElement = editor.getRootElement()
314
- if (
315
- selection !== null &&
316
- nativeSelection !== null &&
317
- !nativeSelection.isCollapsed &&
318
- rootElement !== null &&
319
- rootElement.contains(nativeSelection.anchorNode)
320
- ) {
321
- const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
322
-
323
- setFloatingElemPosition(
324
- rangeRect,
325
- popupCharStylesEditorElem,
326
- anchorElem,
327
- isLink
328
- )
329
- popupCharStylesEditorElem.classList.add(
330
- "editor-floating-text-format--visible"
331
- )
332
- } else {
333
- setFloatingElemPosition(null, popupCharStylesEditorElem, anchorElem, isLink)
334
- popupCharStylesEditorElem.classList.remove(
335
- "editor-floating-text-format--visible"
336
- )
337
- }
338
- }, [editor, anchorElem, isLink])
339
-
340
- useEffect(() => {
341
- const scrollerElem = anchorElem.parentElement
342
-
343
- const update = () => {
344
- editor.getEditorState().read(() => {
345
- $updateTextFormatFloatingToolbar()
346
- })
347
- }
348
-
349
- window.addEventListener("resize", update)
350
- if (scrollerElem) {
351
- scrollerElem.addEventListener("scroll", update, { passive: true })
352
- }
353
-
354
- return () => {
355
- window.removeEventListener("resize", update)
356
- if (scrollerElem) {
357
- scrollerElem.removeEventListener("scroll", update)
358
- }
359
- }
360
- }, [editor, $updateTextFormatFloatingToolbar, anchorElem])
361
-
362
- useEffect(() => {
363
- editor.getEditorState().read(() => {
364
- $updateTextFormatFloatingToolbar()
365
- })
366
- return mergeRegister(
367
- editor.registerUpdateListener(({ editorState }) => {
368
- editorState.read(() => {
369
- $updateTextFormatFloatingToolbar()
370
- })
371
- }),
372
-
373
- editor.registerCommand(
374
- SELECTION_CHANGE_COMMAND,
375
- () => {
376
- $updateTextFormatFloatingToolbar()
377
- return false
378
- },
379
- COMMAND_PRIORITY_LOW
380
- )
381
- )
382
- }, [editor, $updateTextFormatFloatingToolbar])
383
-
384
- return (
385
- <div
386
- ref={popupCharStylesEditorRef}
387
- className="editor-floating-text-format"
388
- >
389
- {editor.isEditable() && (
390
- <Flex align="center" gap={1} className="editor-flex-nowrap">
391
- <div className="editor-floating-group editor-flex editor-items-center">
392
- <Button
393
- variant="ghost"
394
- size="sm"
395
- className="editor-toolbar-item"
396
- data-state={isBold ? "on" : "off"}
397
- onClick={() => {
398
- editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")
399
- }}
400
- aria-label="Toggle bold"
401
- >
402
- <IconSize size="sm">
403
- <BoldIcon />
404
- </IconSize>
405
- </Button>
406
- <Button
407
- variant="ghost"
408
- size="sm"
409
- className="editor-toolbar-item"
410
- data-state={isItalic ? "on" : "off"}
411
- onClick={() => {
412
- editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")
413
- }}
414
- aria-label="Toggle italic"
415
- >
416
- <IconSize size="sm">
417
- <ItalicIcon />
418
- </IconSize>
419
- </Button>
420
- <Button
421
- variant="ghost"
422
- size="sm"
423
- className="editor-toolbar-item"
424
- data-state={isUnderline ? "on" : "off"}
425
- onClick={() => {
426
- editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline")
427
- }}
428
- aria-label="Toggle underline"
429
- >
430
- <IconSize size="sm">
431
- <UnderlineIcon />
432
- </IconSize>
433
- </Button>
434
- <Button
435
- variant="ghost"
436
- size="sm"
437
- className="editor-toolbar-item"
438
- data-state={isStrikethrough ? "on" : "off"}
439
- onClick={() => {
440
- editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough")
441
- }}
442
- aria-label="Toggle strikethrough"
443
- >
444
- <IconSize size="sm">
445
- <StrikethroughIcon />
446
- </IconSize>
447
- </Button>
448
- <Button
449
- variant="ghost"
450
- size="sm"
451
- className="editor-toolbar-item"
452
- data-state={isCode ? "on" : "off"}
453
- onClick={() => {
454
- editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code")
455
- }}
456
- aria-label="Toggle code"
457
- >
458
- <IconSize size="sm">
459
- <CodeIcon />
460
- </IconSize>
461
- </Button>
462
- <Button
463
- variant="ghost"
464
- size="sm"
465
- className="editor-toolbar-item"
466
- data-state={isLink ? "on" : "off"}
467
- onClick={insertLink}
468
- aria-label="Toggle link"
469
- >
470
- <IconSize size="sm">
471
- <LinkIcon />
472
- </IconSize>
473
- </Button>
474
- </div>
475
-
476
- <Separator orientation="vertical" className="editor-separator--vertical" />
477
-
478
- <div className="editor-floating-group--lg editor-flex editor-items-center">
479
- <Button
480
- variant="ghost"
481
- size="sm"
482
- className="editor-toolbar-item"
483
- onClick={openFontColorModal}
484
- >
485
- <IconSize size="sm">
486
- <BaselineIcon />
487
- </IconSize>
488
- </Button>
489
-
490
- <Button
491
- variant="ghost"
492
- size="sm"
493
- className="editor-toolbar-item"
494
- onClick={openBgColorModal}
495
- >
496
- <IconSize size="sm">
497
- <PaintBucketIcon />
498
- </IconSize>
499
- </Button>
500
- </div>
501
-
502
- <Separator orientation="vertical" className="editor-separator--vertical" />
503
-
504
- <div className="editor-floating-group editor-flex editor-items-center">
505
- <ToggleGroup
506
- type="single"
507
- className="editor-flex editor-items-center"
508
- value={
509
- isSubscript ? "subscript" : isSuperscript ? "superscript" : ""
510
- }
511
- >
512
- <ToggleGroupItem
513
- value="subscript"
514
- aria-label="Toggle subscript"
515
- className="editor-toolbar-item"
516
- onClick={() => {
517
- editor.dispatchCommand(FORMAT_TEXT_COMMAND, "subscript")
518
- }}
519
- size="sm"
520
- >
521
- <IconSize size="sm">
522
- <SubscriptIcon />
523
- </IconSize>
524
- </ToggleGroupItem>
525
- <ToggleGroupItem
526
- value="superscript"
527
- aria-label="Toggle superscript"
528
- className="editor-toolbar-item"
529
- onClick={() => {
530
- editor.dispatchCommand(FORMAT_TEXT_COMMAND, "superscript")
531
- }}
532
- size="sm"
533
- >
534
- <IconSize size="sm">
535
- <SuperscriptIcon />
536
- </IconSize>
537
- </ToggleGroupItem>
538
- </ToggleGroup>
539
- </div>
540
- </Flex>
541
- )}
542
- </div>
543
- )
544
- }
545
-
546
- function useFloatingTextFormatToolbar(
547
- editor: LexicalEditor,
548
- anchorElem: HTMLDivElement | null,
549
- setIsLinkEditMode: Dispatch<boolean>,
550
- showModal: (
551
- title: string,
552
- content: (onClose: () => void) => JSX.Element
553
- ) => void,
554
- isModalOpen: boolean
555
- ): JSX.Element | null {
556
- const [isText, setIsText] = useState(false)
557
- const [isLink, setIsLink] = useState(false)
558
- const [isBold, setIsBold] = useState(false)
559
- const [isItalic, setIsItalic] = useState(false)
560
- const [isUnderline, setIsUnderline] = useState(false)
561
- const [isStrikethrough, setIsStrikethrough] = useState(false)
562
- const [isSubscript, setIsSubscript] = useState(false)
563
- const [isSuperscript, setIsSuperscript] = useState(false)
564
- const [isCode, setIsCode] = useState(false)
565
- const [fontColor, setFontColor] = useState("inherit")
566
- const [bgColor, setBgColor] = useState("inherit")
567
-
568
- const updatePopup = useCallback(() => {
569
- editor.getEditorState().read(() => {
570
- // Should not to pop up the floating toolbar when using IME input
571
- if (editor.isComposing()) {
572
- return
573
- }
574
- const selection = $getSelection()
575
- const nativeSelection = window.getSelection()
576
- const rootElement = editor.getRootElement()
577
-
578
- if (
579
- nativeSelection !== null &&
580
- (!$isRangeSelection(selection) ||
581
- rootElement === null ||
582
- !rootElement.contains(nativeSelection.anchorNode))
583
- ) {
584
- setIsText(false)
585
- return
586
- }
587
-
588
- if (!$isRangeSelection(selection)) {
589
- return
590
- }
591
-
592
- const node = getSelectedNode(selection)
593
-
594
- // Update text format
595
- setIsBold(selection.hasFormat("bold"))
596
- setIsItalic(selection.hasFormat("italic"))
597
- setIsUnderline(selection.hasFormat("underline"))
598
- setIsStrikethrough(selection.hasFormat("strikethrough"))
599
- setIsSubscript(selection.hasFormat("subscript"))
600
- setIsSuperscript(selection.hasFormat("superscript"))
601
- setIsCode(selection.hasFormat("code"))
602
- setFontColor($getSelectionStyleValueForProperty(selection, "color", "inherit"))
603
- setBgColor(
604
- $getSelectionStyleValueForProperty(selection, "background-color", "inherit")
605
- )
606
-
607
- // Update links
608
- const parent = node.getParent()
609
- if ($isLinkNode(parent) || $isLinkNode(node)) {
610
- setIsLink(true)
611
- } else {
612
- setIsLink(false)
613
- }
614
-
615
- if (
616
- !$isCodeHighlightNode(selection.anchor.getNode()) &&
617
- selection.getTextContent() !== ""
618
- ) {
619
- setIsText($isTextNode(node) || $isParagraphNode(node))
620
- } else {
621
- setIsText(false)
622
- }
623
-
624
- const rawTextContent = selection.getTextContent().replace(/\n/g, "")
625
- if (!selection.isCollapsed() && rawTextContent === "") {
626
- setIsText(false)
627
- return
628
- }
629
- })
630
- }, [editor])
631
-
632
- useEffect(() => {
633
- document.addEventListener("selectionchange", updatePopup)
634
- return () => {
635
- document.removeEventListener("selectionchange", updatePopup)
636
- }
637
- }, [updatePopup])
638
-
639
- useEffect(() => {
640
- return mergeRegister(
641
- editor.registerUpdateListener(() => {
642
- updatePopup()
643
- }),
644
- editor.registerRootListener(() => {
645
- if (editor.getRootElement() === null) {
646
- setIsText(false)
647
- }
648
- })
649
- )
650
- }, [editor, updatePopup])
651
-
652
- if (!isText || !anchorElem) {
653
- return null
654
- }
655
-
656
- return createPortal(
657
- <FloatingTextFormat
658
- editor={editor}
659
- anchorElem={anchorElem}
660
- isLink={isLink}
661
- isBold={isBold}
662
- isItalic={isItalic}
663
- isStrikethrough={isStrikethrough}
664
- isSubscript={isSubscript}
665
- isSuperscript={isSuperscript}
666
- isUnderline={isUnderline}
667
- isCode={isCode}
668
- fontColor={fontColor}
669
- bgColor={bgColor}
670
- setIsLinkEditMode={setIsLinkEditMode}
671
- showModal={showModal}
672
- isModalOpen={isModalOpen}
673
- />,
674
- anchorElem
675
- )
676
- }
677
-
678
- export function FloatingTextFormatToolbarPlugin({
679
- anchorElem,
680
- setIsLinkEditMode,
681
- }: {
682
- anchorElem: HTMLDivElement | null
683
- setIsLinkEditMode: Dispatch<boolean>
684
- }): JSX.Element | null {
685
- const [editor] = useLexicalComposerContext()
686
- const [modal, showModal] = useEditorModal()
687
-
688
- const toolbar = useFloatingTextFormatToolbar(
689
- editor,
690
- anchorElem,
691
- setIsLinkEditMode,
692
- showModal,
693
- modal !== null
694
- )
695
-
696
- return (
697
- <>
698
- {toolbar}
699
- {modal}
700
- </>
701
- )
702
- }
1
+ "use client"
2
+
3
+ /**
4
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
5
+ *
6
+ * This source code is licensed under the MIT license found in the
7
+ * LICENSE file in the root directory of this source tree.
8
+ *
9
+ */
10
+ import { Dispatch, JSX, useCallback, useEffect, useRef, useState } from "react"
11
+ import * as React from "react"
12
+ import { $isCodeHighlightNode } from "@lexical/code"
13
+ import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"
14
+ import {
15
+ $getSelectionStyleValueForProperty,
16
+ $patchStyleText,
17
+ } from "@lexical/selection"
18
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
19
+ import { mergeRegister } from "@lexical/utils"
20
+ import {
21
+ $getSelection,
22
+ $isParagraphNode,
23
+ $isRangeSelection,
24
+ $isTextNode,
25
+ COMMAND_PRIORITY_LOW,
26
+ FORMAT_TEXT_COMMAND,
27
+ LexicalEditor,
28
+ SELECTION_CHANGE_COMMAND,
29
+ } from "lexical"
30
+ import {
31
+ BoldIcon,
32
+ CodeIcon,
33
+ ItalicIcon,
34
+ LinkIcon,
35
+ PaintBucketIcon,
36
+ StrikethroughIcon,
37
+ SubscriptIcon,
38
+ SuperscriptIcon,
39
+ UnderlineIcon,
40
+ BaselineIcon,
41
+ } from "lucide-react"
42
+ import { createPortal } from "react-dom"
43
+
44
+ import {
45
+ ColorPicker,
46
+ ColorPickerAlphaSlider,
47
+ ColorPickerArea,
48
+ ColorPickerContent,
49
+ ColorPickerEyeDropper,
50
+ ColorPickerFormatSelect,
51
+ ColorPickerHueSlider,
52
+ ColorPickerInput,
53
+ ColorPickerPresets,
54
+ } from "../editor-ui/color-picker"
55
+ import { useEditorModal } from "../editor-hooks/use-modal"
56
+ import { getDOMRangeRect } from "../utils/get-dom-range-rect"
57
+ import { getSelectedNode } from "../utils/get-selected-node"
58
+ import { setFloatingElemPosition } from "../utils/set-floating-elem-position"
59
+ import { Button } from "../ui/button"
60
+ import { DialogFooter } from "../ui/dialog"
61
+ import { Flex } from "../ui/flex"
62
+ import { Separator } from "../ui/separator"
63
+ import {
64
+ IconSize,
65
+ } from "../ui/typography"
66
+ import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"
67
+
68
+ function FontColorModalContent({
69
+ initialColor,
70
+ onApply,
71
+ onClose,
72
+ }: {
73
+ initialColor: string
74
+ onApply: (color: string) => void
75
+ onClose: () => void
76
+ }) {
77
+ const [color, setColor] = useState(initialColor)
78
+
79
+ return (
80
+ <div className="editor-list-color-dialog">
81
+ <Flex direction="column" gap={4}>
82
+ <div className="editor-text-xs-muted">Chọn màu cho văn bản đang chọn.</div>
83
+
84
+ <ColorPicker inline value={color} onValueChange={setColor}>
85
+ <ColorPickerContent className="editor-w-full editor-border-0 editor-shadow-none editor-p-0">
86
+ <ColorPickerArea className="editor-h-40 editor-w-full editor-rounded-md" />
87
+ <Flex direction="column" gap={3} className="editor-mt-3">
88
+ <Flex align="center" gap={2}>
89
+ <ColorPickerEyeDropper />
90
+ <Flex direction="column" gap={2} className="editor-flex-1">
91
+ <ColorPickerHueSlider className="editor-w-full" />
92
+ <ColorPickerAlphaSlider className="editor-w-full" />
93
+ </Flex>
94
+ </Flex>
95
+ <Flex align="center" gap={2}>
96
+ <ColorPickerFormatSelect />
97
+ <ColorPickerInput />
98
+ </Flex>
99
+ <ColorPickerPresets />
100
+ </Flex>
101
+ </ColorPickerContent>
102
+ </ColorPicker>
103
+
104
+ <DialogFooter className="editor-px-0">
105
+ <Button
106
+ variant="outline"
107
+ size="sm"
108
+ onClick={() => {
109
+ onApply(color)
110
+ onClose()
111
+ }}
112
+ className="editor-w-full"
113
+ >
114
+ Hoàn tất
115
+ </Button>
116
+ </DialogFooter>
117
+ </Flex>
118
+ </div>
119
+ )
120
+ }
121
+
122
+ function BgColorModalContent({
123
+ initialColor,
124
+ onApply,
125
+ onClose,
126
+ }: {
127
+ initialColor: string
128
+ onApply: (color: string) => void
129
+ onClose: () => void
130
+ }) {
131
+ const [color, setColor] = useState(initialColor)
132
+
133
+ return (
134
+ <div className="editor-list-color-dialog">
135
+ <Flex direction="column" gap={4}>
136
+ <div className="editor-text-xs-muted">
137
+ Chọn màu nền cho văn bản đang chọn.
138
+ </div>
139
+
140
+ <ColorPicker inline value={color} onValueChange={setColor}>
141
+ <ColorPickerContent className="editor-w-full editor-border-0 editor-shadow-none editor-p-0">
142
+ <ColorPickerArea className="editor-h-40 editor-w-full editor-rounded-md" />
143
+ <Flex direction="column" gap={3} className="editor-mt-3">
144
+ <Flex align="center" gap={2}>
145
+ <ColorPickerEyeDropper />
146
+ <Flex direction="column" gap={2} className="editor-flex-1">
147
+ <ColorPickerHueSlider className="editor-w-full" />
148
+ <ColorPickerAlphaSlider className="editor-w-full" />
149
+ </Flex>
150
+ </Flex>
151
+ <Flex align="center" gap={2}>
152
+ <ColorPickerFormatSelect />
153
+ <ColorPickerInput />
154
+ </Flex>
155
+ <ColorPickerPresets />
156
+ </Flex>
157
+ </ColorPickerContent>
158
+ </ColorPicker>
159
+
160
+ <DialogFooter className="editor-px-0">
161
+ <Button
162
+ variant="outline"
163
+ size="sm"
164
+ onClick={() => {
165
+ onApply(color)
166
+ onClose()
167
+ }}
168
+ className="editor-w-full"
169
+ >
170
+ Hoàn tất
171
+ </Button>
172
+ </DialogFooter>
173
+ </Flex>
174
+ </div>
175
+ )
176
+ }
177
+
178
+ function FloatingTextFormat({
179
+ editor,
180
+ anchorElem,
181
+ isLink,
182
+ isBold,
183
+ isItalic,
184
+ isUnderline,
185
+ isCode,
186
+ isStrikethrough,
187
+ isSubscript,
188
+ isSuperscript,
189
+ fontColor,
190
+ bgColor,
191
+ setIsLinkEditMode,
192
+ showModal,
193
+ isModalOpen,
194
+ }: {
195
+ editor: LexicalEditor
196
+ anchorElem: HTMLElement
197
+ isBold: boolean
198
+ isCode: boolean
199
+ isItalic: boolean
200
+ isLink: boolean
201
+ isStrikethrough: boolean
202
+ isSubscript: boolean
203
+ isSuperscript: boolean
204
+ isUnderline: boolean
205
+ fontColor: string
206
+ bgColor: string
207
+ setIsLinkEditMode: Dispatch<boolean>
208
+ showModal: (
209
+ title: string,
210
+ content: (onClose: () => void) => JSX.Element
211
+ ) => void
212
+ isModalOpen: boolean
213
+ }): JSX.Element {
214
+ const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null)
215
+
216
+ const insertLink = useCallback(() => {
217
+ if (!isLink) {
218
+ setIsLinkEditMode(true)
219
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://")
220
+ } else {
221
+ setIsLinkEditMode(false)
222
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
223
+ }
224
+ }, [editor, isLink, setIsLinkEditMode])
225
+
226
+ const applyStyleText = useCallback(
227
+ (styles: Record<string, string>) => {
228
+ editor.update(() => {
229
+ const selection = $getSelection()
230
+ if ($isRangeSelection(selection)) {
231
+ $patchStyleText(selection, styles)
232
+ }
233
+ })
234
+ },
235
+ [editor]
236
+ )
237
+
238
+ const onFontColorSelect = useCallback(
239
+ (value: string) => {
240
+ if (value !== "inherit") {
241
+ applyStyleText({ color: value })
242
+ }
243
+ },
244
+ [applyStyleText]
245
+ )
246
+
247
+ const onBgColorSelect = useCallback(
248
+ (value: string) => {
249
+ if (value !== "inherit") {
250
+ applyStyleText({ "background-color": value })
251
+ }
252
+ },
253
+ [applyStyleText]
254
+ )
255
+
256
+ const openFontColorModal = () => {
257
+ showModal("Đổi màu chữ", (onClose) => (
258
+ <FontColorModalContent
259
+ initialColor={fontColor}
260
+ onApply={onFontColorSelect}
261
+ onClose={onClose}
262
+ />
263
+ ))
264
+ }
265
+
266
+ const openBgColorModal = () => {
267
+ showModal("Đổi màu nền", (onClose) => (
268
+ <BgColorModalContent
269
+ initialColor={bgColor}
270
+ onApply={onBgColorSelect}
271
+ onClose={onClose}
272
+ />
273
+ ))
274
+ }
275
+
276
+ useEffect(() => {
277
+ function mouseMoveListener(e: MouseEvent) {
278
+ if (
279
+ popupCharStylesEditorRef?.current &&
280
+ (e.buttons === 1 || e.buttons === 3)
281
+ ) {
282
+ if (popupCharStylesEditorRef.current.style.pointerEvents !== "none") {
283
+ const x = e.clientX
284
+ const y = e.clientY
285
+ const elementUnderMouse = document.elementFromPoint(x, y)
286
+
287
+ if (
288
+ !popupCharStylesEditorRef.current.contains(elementUnderMouse) &&
289
+ !isModalOpen
290
+ ) {
291
+ // Mouse is not over the target element => not a normal click, but probably a drag
292
+ popupCharStylesEditorRef.current.style.pointerEvents = "none"
293
+ }
294
+ }
295
+ }
296
+ }
297
+ function mouseUpListener(_e: MouseEvent) {
298
+ void _e
299
+ if (popupCharStylesEditorRef?.current) {
300
+ if (popupCharStylesEditorRef.current.style.pointerEvents !== "auto") {
301
+ popupCharStylesEditorRef.current.style.pointerEvents = "auto"
302
+ }
303
+ }
304
+ }
305
+
306
+ if (popupCharStylesEditorRef?.current) {
307
+ document.addEventListener("mousemove", mouseMoveListener)
308
+ document.addEventListener("mouseup", mouseUpListener)
309
+
310
+ return () => {
311
+ document.removeEventListener("mousemove", mouseMoveListener)
312
+ document.removeEventListener("mouseup", mouseUpListener)
313
+ }
314
+ }
315
+ }, [popupCharStylesEditorRef, isModalOpen])
316
+
317
+ const $updateTextFormatFloatingToolbar = useCallback(() => {
318
+ const selection = $getSelection()
319
+
320
+ const popupCharStylesEditorElem = popupCharStylesEditorRef.current
321
+ const nativeSelection = window.getSelection()
322
+
323
+ if (popupCharStylesEditorElem === null) {
324
+ return
325
+ }
326
+
327
+ const rootElement = editor.getRootElement()
328
+ if (
329
+ selection !== null &&
330
+ nativeSelection !== null &&
331
+ !nativeSelection.isCollapsed &&
332
+ rootElement !== null &&
333
+ rootElement.contains(nativeSelection.anchorNode)
334
+ ) {
335
+ const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
336
+
337
+ setFloatingElemPosition(
338
+ rangeRect,
339
+ popupCharStylesEditorElem,
340
+ anchorElem,
341
+ isLink
342
+ )
343
+ popupCharStylesEditorElem.classList.add(
344
+ "editor-floating-text-format--visible"
345
+ )
346
+ } else {
347
+ setFloatingElemPosition(null, popupCharStylesEditorElem, anchorElem, isLink)
348
+ popupCharStylesEditorElem.classList.remove(
349
+ "editor-floating-text-format--visible"
350
+ )
351
+ }
352
+ }, [editor, anchorElem, isLink])
353
+
354
+ useEffect(() => {
355
+ const scrollerElem = anchorElem.parentElement
356
+
357
+ const update = () => {
358
+ editor.getEditorState().read(() => {
359
+ $updateTextFormatFloatingToolbar()
360
+ })
361
+ }
362
+
363
+ window.addEventListener("resize", update)
364
+ if (scrollerElem) {
365
+ scrollerElem.addEventListener("scroll", update, { passive: true })
366
+ }
367
+
368
+ return () => {
369
+ window.removeEventListener("resize", update)
370
+ if (scrollerElem) {
371
+ scrollerElem.removeEventListener("scroll", update)
372
+ }
373
+ }
374
+ }, [editor, $updateTextFormatFloatingToolbar, anchorElem])
375
+
376
+ useEffect(() => {
377
+ editor.getEditorState().read(() => {
378
+ $updateTextFormatFloatingToolbar()
379
+ })
380
+ return mergeRegister(
381
+ editor.registerUpdateListener(({ editorState }) => {
382
+ editorState.read(() => {
383
+ $updateTextFormatFloatingToolbar()
384
+ })
385
+ }),
386
+
387
+ editor.registerCommand(
388
+ SELECTION_CHANGE_COMMAND,
389
+ () => {
390
+ $updateTextFormatFloatingToolbar()
391
+ return false
392
+ },
393
+ COMMAND_PRIORITY_LOW
394
+ )
395
+ )
396
+ }, [editor, $updateTextFormatFloatingToolbar])
397
+
398
+ return (
399
+ <div
400
+ ref={popupCharStylesEditorRef}
401
+ className="editor-floating-text-format"
402
+ >
403
+ {editor.isEditable() && (
404
+ <Flex align="center" gap={1} className="editor-flex-nowrap">
405
+ <div className="editor-floating-group editor-flex editor-items-center">
406
+ <Button
407
+ variant="ghost"
408
+ size="sm"
409
+ className="editor-toolbar-item"
410
+ data-state={isBold ? "on" : "off"}
411
+ onClick={() => {
412
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")
413
+ }}
414
+ aria-label="Toggle bold"
415
+ >
416
+ <IconSize size="sm">
417
+ <BoldIcon />
418
+ </IconSize>
419
+ </Button>
420
+ <Button
421
+ variant="ghost"
422
+ size="sm"
423
+ className="editor-toolbar-item"
424
+ data-state={isItalic ? "on" : "off"}
425
+ onClick={() => {
426
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")
427
+ }}
428
+ aria-label="Toggle italic"
429
+ >
430
+ <IconSize size="sm">
431
+ <ItalicIcon />
432
+ </IconSize>
433
+ </Button>
434
+ <Button
435
+ variant="ghost"
436
+ size="sm"
437
+ className="editor-toolbar-item"
438
+ data-state={isUnderline ? "on" : "off"}
439
+ onClick={() => {
440
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline")
441
+ }}
442
+ aria-label="Toggle underline"
443
+ >
444
+ <IconSize size="sm">
445
+ <UnderlineIcon />
446
+ </IconSize>
447
+ </Button>
448
+ <Button
449
+ variant="ghost"
450
+ size="sm"
451
+ className="editor-toolbar-item"
452
+ data-state={isStrikethrough ? "on" : "off"}
453
+ onClick={() => {
454
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough")
455
+ }}
456
+ aria-label="Toggle strikethrough"
457
+ >
458
+ <IconSize size="sm">
459
+ <StrikethroughIcon />
460
+ </IconSize>
461
+ </Button>
462
+ <Button
463
+ variant="ghost"
464
+ size="sm"
465
+ className="editor-toolbar-item"
466
+ data-state={isCode ? "on" : "off"}
467
+ onClick={() => {
468
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code")
469
+ }}
470
+ aria-label="Toggle code"
471
+ >
472
+ <IconSize size="sm">
473
+ <CodeIcon />
474
+ </IconSize>
475
+ </Button>
476
+ <Button
477
+ variant="ghost"
478
+ size="sm"
479
+ className="editor-toolbar-item"
480
+ data-state={isLink ? "on" : "off"}
481
+ onClick={insertLink}
482
+ aria-label="Toggle link"
483
+ >
484
+ <IconSize size="sm">
485
+ <LinkIcon />
486
+ </IconSize>
487
+ </Button>
488
+ </div>
489
+
490
+ <Separator orientation="vertical" className="editor-separator--vertical" />
491
+
492
+ <div className="editor-floating-group--lg editor-flex editor-items-center">
493
+ <Button
494
+ variant="ghost"
495
+ size="sm"
496
+ className="editor-toolbar-item"
497
+ onClick={openFontColorModal}
498
+ >
499
+ <IconSize size="sm">
500
+ <BaselineIcon />
501
+ </IconSize>
502
+ </Button>
503
+
504
+ <Button
505
+ variant="ghost"
506
+ size="sm"
507
+ className="editor-toolbar-item"
508
+ onClick={openBgColorModal}
509
+ >
510
+ <IconSize size="sm">
511
+ <PaintBucketIcon />
512
+ </IconSize>
513
+ </Button>
514
+ </div>
515
+
516
+ <Separator orientation="vertical" className="editor-separator--vertical" />
517
+
518
+ <div className="editor-floating-group editor-flex editor-items-center">
519
+ <ToggleGroup
520
+ type="single"
521
+ className="editor-flex editor-items-center"
522
+ value={
523
+ isSubscript ? "subscript" : isSuperscript ? "superscript" : ""
524
+ }
525
+ >
526
+ <ToggleGroupItem
527
+ value="subscript"
528
+ aria-label="Toggle subscript"
529
+ className="editor-toolbar-item"
530
+ onClick={() => {
531
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "subscript")
532
+ }}
533
+ size="sm"
534
+ >
535
+ <IconSize size="sm">
536
+ <SubscriptIcon />
537
+ </IconSize>
538
+ </ToggleGroupItem>
539
+ <ToggleGroupItem
540
+ value="superscript"
541
+ aria-label="Toggle superscript"
542
+ className="editor-toolbar-item"
543
+ onClick={() => {
544
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "superscript")
545
+ }}
546
+ size="sm"
547
+ >
548
+ <IconSize size="sm">
549
+ <SuperscriptIcon />
550
+ </IconSize>
551
+ </ToggleGroupItem>
552
+ </ToggleGroup>
553
+ </div>
554
+ </Flex>
555
+ )}
556
+ </div>
557
+ )
558
+ }
559
+
560
+ function useFloatingTextFormatToolbar(
561
+ editor: LexicalEditor,
562
+ anchorElem: HTMLDivElement | null,
563
+ setIsLinkEditMode: Dispatch<boolean>,
564
+ showModal: (
565
+ title: string,
566
+ content: (onClose: () => void) => JSX.Element
567
+ ) => void,
568
+ isModalOpen: boolean
569
+ ): JSX.Element | null {
570
+ const [isText, setIsText] = useState(false)
571
+ const [isLink, setIsLink] = useState(false)
572
+ const [isBold, setIsBold] = useState(false)
573
+ const [isItalic, setIsItalic] = useState(false)
574
+ const [isUnderline, setIsUnderline] = useState(false)
575
+ const [isStrikethrough, setIsStrikethrough] = useState(false)
576
+ const [isSubscript, setIsSubscript] = useState(false)
577
+ const [isSuperscript, setIsSuperscript] = useState(false)
578
+ const [isCode, setIsCode] = useState(false)
579
+ const [fontColor, setFontColor] = useState("inherit")
580
+ const [bgColor, setBgColor] = useState("inherit")
581
+
582
+ const updatePopup = useCallback(() => {
583
+ editor.getEditorState().read(() => {
584
+ // Should not to pop up the floating toolbar when using IME input
585
+ if (editor.isComposing()) {
586
+ return
587
+ }
588
+ const selection = $getSelection()
589
+ const nativeSelection = window.getSelection()
590
+ const rootElement = editor.getRootElement()
591
+
592
+ if (
593
+ nativeSelection !== null &&
594
+ (!$isRangeSelection(selection) ||
595
+ rootElement === null ||
596
+ !rootElement.contains(nativeSelection.anchorNode))
597
+ ) {
598
+ setIsText(false)
599
+ return
600
+ }
601
+
602
+ if (!$isRangeSelection(selection)) {
603
+ return
604
+ }
605
+
606
+ const node = getSelectedNode(selection)
607
+
608
+ // Update text format
609
+ setIsBold(selection.hasFormat("bold"))
610
+ setIsItalic(selection.hasFormat("italic"))
611
+ setIsUnderline(selection.hasFormat("underline"))
612
+ setIsStrikethrough(selection.hasFormat("strikethrough"))
613
+ setIsSubscript(selection.hasFormat("subscript"))
614
+ setIsSuperscript(selection.hasFormat("superscript"))
615
+ setIsCode(selection.hasFormat("code"))
616
+ setFontColor($getSelectionStyleValueForProperty(selection, "color", "inherit"))
617
+ setBgColor(
618
+ $getSelectionStyleValueForProperty(selection, "background-color", "inherit")
619
+ )
620
+
621
+ // Update links
622
+ const parent = node.getParent()
623
+ if ($isLinkNode(parent) || $isLinkNode(node)) {
624
+ setIsLink(true)
625
+ } else {
626
+ setIsLink(false)
627
+ }
628
+
629
+ if (
630
+ !$isCodeHighlightNode(selection.anchor.getNode()) &&
631
+ selection.getTextContent() !== ""
632
+ ) {
633
+ setIsText($isTextNode(node) || $isParagraphNode(node))
634
+ } else {
635
+ setIsText(false)
636
+ }
637
+
638
+ const rawTextContent = selection.getTextContent().replace(/\n/g, "")
639
+ if (!selection.isCollapsed() && rawTextContent === "") {
640
+ setIsText(false)
641
+ return
642
+ }
643
+ })
644
+ }, [editor])
645
+
646
+ useEffect(() => {
647
+ document.addEventListener("selectionchange", updatePopup)
648
+ return () => {
649
+ document.removeEventListener("selectionchange", updatePopup)
650
+ }
651
+ }, [updatePopup])
652
+
653
+ useEffect(() => {
654
+ return mergeRegister(
655
+ editor.registerUpdateListener(() => {
656
+ updatePopup()
657
+ }),
658
+ editor.registerRootListener(() => {
659
+ if (editor.getRootElement() === null) {
660
+ setIsText(false)
661
+ }
662
+ })
663
+ )
664
+ }, [editor, updatePopup])
665
+
666
+ if (!isText || !anchorElem) {
667
+ return null
668
+ }
669
+
670
+ return createPortal(
671
+ <FloatingTextFormat
672
+ editor={editor}
673
+ anchorElem={anchorElem}
674
+ isLink={isLink}
675
+ isBold={isBold}
676
+ isItalic={isItalic}
677
+ isStrikethrough={isStrikethrough}
678
+ isSubscript={isSubscript}
679
+ isSuperscript={isSuperscript}
680
+ isUnderline={isUnderline}
681
+ isCode={isCode}
682
+ fontColor={fontColor}
683
+ bgColor={bgColor}
684
+ setIsLinkEditMode={setIsLinkEditMode}
685
+ showModal={showModal}
686
+ isModalOpen={isModalOpen}
687
+ />,
688
+ anchorElem
689
+ )
690
+ }
691
+
692
+ export function FloatingTextFormatToolbarPlugin({
693
+ anchorElem,
694
+ setIsLinkEditMode,
695
+ }: {
696
+ anchorElem: HTMLDivElement | null
697
+ setIsLinkEditMode: Dispatch<boolean>
698
+ }): JSX.Element | null {
699
+ const [editor] = useLexicalComposerContext()
700
+ const [modal, showModal] = useEditorModal()
701
+
702
+ const toolbar = useFloatingTextFormatToolbar(
703
+ editor,
704
+ anchorElem,
705
+ setIsLinkEditMode,
706
+ showModal,
707
+ modal !== null
708
+ )
709
+
710
+ return (
711
+ <>
712
+ {toolbar}
713
+ {modal}
714
+ </>
715
+ )
716
+ }