@thangph2146/lexical-editor 0.0.1

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 (183) hide show
  1. package/dist/editor-x/editor.cjs +33121 -0
  2. package/dist/editor-x/editor.cjs.map +1 -0
  3. package/dist/editor-x/editor.css +2854 -0
  4. package/dist/editor-x/editor.css.map +1 -0
  5. package/dist/editor-x/editor.d.cts +12 -0
  6. package/dist/editor-x/editor.d.ts +12 -0
  7. package/dist/editor-x/editor.js +33095 -0
  8. package/dist/editor-x/editor.js.map +1 -0
  9. package/dist/index.cjs +33210 -0
  10. package/dist/index.cjs.map +1 -0
  11. package/dist/index.css +2854 -0
  12. package/dist/index.css.map +1 -0
  13. package/dist/index.d.cts +15 -0
  14. package/dist/index.d.ts +15 -0
  15. package/dist/index.js +33183 -0
  16. package/dist/index.js.map +1 -0
  17. package/package.json +84 -0
  18. package/src/components/lexical-editor.tsx +123 -0
  19. package/src/context/editor-container-context.tsx +29 -0
  20. package/src/context/priority-image-context.tsx +7 -0
  21. package/src/context/toolbar-context.tsx +60 -0
  22. package/src/context/uploads-context.tsx +53 -0
  23. package/src/editor-hooks/use-debounce.ts +80 -0
  24. package/src/editor-hooks/use-modal.tsx +64 -0
  25. package/src/editor-hooks/use-report.ts +57 -0
  26. package/src/editor-hooks/use-update-toolbar.ts +41 -0
  27. package/src/editor-ui/broken-image.tsx +18 -0
  28. package/src/editor-ui/caption-composer.tsx +45 -0
  29. package/src/editor-ui/code-button.tsx +75 -0
  30. package/src/editor-ui/color-picker.tsx +2010 -0
  31. package/src/editor-ui/content-editable.tsx +37 -0
  32. package/src/editor-ui/hooks/use-image-caption-controls.ts +118 -0
  33. package/src/editor-ui/hooks/use-image-node-interactions.ts +245 -0
  34. package/src/editor-ui/hooks/use-responsive-image-dimensions.ts +202 -0
  35. package/src/editor-ui/image-component.tsx +321 -0
  36. package/src/editor-ui/image-placeholder.tsx +57 -0
  37. package/src/editor-ui/image-resizer.tsx +499 -0
  38. package/src/editor-ui/image-sizing.ts +120 -0
  39. package/src/editor-ui/lazy-image.tsx +136 -0
  40. package/src/editor-x/editor.tsx +117 -0
  41. package/src/editor-x/nodes.ts +79 -0
  42. package/src/editor-x/plugins.tsx +380 -0
  43. package/src/hooks/use-click-outside.ts +27 -0
  44. package/src/hooks/use-element-size.ts +54 -0
  45. package/src/hooks/use-header-height.ts +95 -0
  46. package/src/hooks/use-isomorphic-layout-effect.ts +4 -0
  47. package/src/index.ts +4 -0
  48. package/src/lib/logger.ts +6 -0
  49. package/src/lib/utils.ts +19 -0
  50. package/src/nodes/autocomplete-node.tsx +94 -0
  51. package/src/nodes/embeds/tweet-node.tsx +224 -0
  52. package/src/nodes/embeds/youtube-node.tsx +519 -0
  53. package/src/nodes/emoji-node.tsx +83 -0
  54. package/src/nodes/image-node.tsx +328 -0
  55. package/src/nodes/keyword-node.tsx +58 -0
  56. package/src/nodes/layout-container-node.tsx +128 -0
  57. package/src/nodes/layout-item-node.tsx +118 -0
  58. package/src/nodes/list-with-color-node.tsx +160 -0
  59. package/src/nodes/mention-node.ts +122 -0
  60. package/src/plugins/actions/actions-plugin.tsx +3 -0
  61. package/src/plugins/actions/character-limit-plugin.tsx +27 -0
  62. package/src/plugins/actions/clear-editor-plugin.tsx +70 -0
  63. package/src/plugins/actions/counter-character-plugin.tsx +80 -0
  64. package/src/plugins/actions/edit-mode-toggle-plugin.tsx +49 -0
  65. package/src/plugins/actions/import-export-plugin.tsx +61 -0
  66. package/src/plugins/actions/markdown-toggle-plugin.tsx +78 -0
  67. package/src/plugins/actions/max-length-plugin.tsx +59 -0
  68. package/src/plugins/actions/share-content-plugin.tsx +72 -0
  69. package/src/plugins/actions/speech-to-text-plugin.tsx +159 -0
  70. package/src/plugins/actions/tree-view-plugin.tsx +63 -0
  71. package/src/plugins/align-plugin.tsx +86 -0
  72. package/src/plugins/auto-link-plugin.tsx +34 -0
  73. package/src/plugins/autocomplete-plugin.tsx +2574 -0
  74. package/src/plugins/code-action-menu-plugin.tsx +240 -0
  75. package/src/plugins/code-highlight-plugin.tsx +22 -0
  76. package/src/plugins/component-picker-menu-plugin.tsx +427 -0
  77. package/src/plugins/context-menu-plugin.tsx +311 -0
  78. package/src/plugins/drag-drop-paste-plugin.tsx +52 -0
  79. package/src/plugins/draggable-block-plugin.tsx +50 -0
  80. package/src/plugins/embeds/auto-embed-plugin.tsx +324 -0
  81. package/src/plugins/embeds/twitter-plugin.tsx +45 -0
  82. package/src/plugins/embeds/youtube-plugin.tsx +84 -0
  83. package/src/plugins/emoji-picker-plugin.tsx +206 -0
  84. package/src/plugins/emojis-plugin.tsx +84 -0
  85. package/src/plugins/floating-link-editor-plugin.tsx +791 -0
  86. package/src/plugins/floating-text-format-plugin.tsx +710 -0
  87. package/src/plugins/images-plugin.tsx +671 -0
  88. package/src/plugins/keywords-plugin.tsx +59 -0
  89. package/src/plugins/layout-plugin.tsx +658 -0
  90. package/src/plugins/link-plugin.tsx +18 -0
  91. package/src/plugins/list-color-plugin.tsx +178 -0
  92. package/src/plugins/list-max-indent-level-plugin.tsx +85 -0
  93. package/src/plugins/mentions-plugin.tsx +714 -0
  94. package/src/plugins/picker/alignment-picker-plugin.tsx +40 -0
  95. package/src/plugins/picker/bulleted-list-picker-plugin.tsx +14 -0
  96. package/src/plugins/picker/check-list-picker-plugin.tsx +14 -0
  97. package/src/plugins/picker/code-picker-plugin.tsx +30 -0
  98. package/src/plugins/picker/columns-layout-picker-plugin.tsx +16 -0
  99. package/src/plugins/picker/component-picker-option.tsx +47 -0
  100. package/src/plugins/picker/divider-picker-plugin.tsx +14 -0
  101. package/src/plugins/picker/embeds-picker-plugin.tsx +24 -0
  102. package/src/plugins/picker/heading-picker-plugin.tsx +32 -0
  103. package/src/plugins/picker/image-picker-plugin.tsx +16 -0
  104. package/src/plugins/picker/numbered-list-picker-plugin.tsx +14 -0
  105. package/src/plugins/picker/paragraph-picker-plugin.tsx +20 -0
  106. package/src/plugins/picker/quote-picker-plugin.tsx +21 -0
  107. package/src/plugins/picker/table-picker-plugin.tsx +56 -0
  108. package/src/plugins/tab-focus-plugin.tsx +66 -0
  109. package/src/plugins/table-column-resizer-plugin.tsx +309 -0
  110. package/src/plugins/table-plugin.tsx +299 -0
  111. package/src/plugins/toolbar/block-format/block-format-data.tsx +69 -0
  112. package/src/plugins/toolbar/block-format/format-bulleted-list.tsx +40 -0
  113. package/src/plugins/toolbar/block-format/format-check-list.tsx +40 -0
  114. package/src/plugins/toolbar/block-format/format-code-block.tsx +45 -0
  115. package/src/plugins/toolbar/block-format/format-heading.tsx +34 -0
  116. package/src/plugins/toolbar/block-format/format-list-with-marker.tsx +74 -0
  117. package/src/plugins/toolbar/block-format/format-numbered-list.tsx +40 -0
  118. package/src/plugins/toolbar/block-format/format-paragraph.tsx +31 -0
  119. package/src/plugins/toolbar/block-format/format-quote.tsx +32 -0
  120. package/src/plugins/toolbar/block-format-toolbar-plugin.tsx +117 -0
  121. package/src/plugins/toolbar/block-insert/insert-columns-layout.tsx +32 -0
  122. package/src/plugins/toolbar/block-insert/insert-embeds.tsx +31 -0
  123. package/src/plugins/toolbar/block-insert/insert-horizontal-rule.tsx +30 -0
  124. package/src/plugins/toolbar/block-insert/insert-image.tsx +32 -0
  125. package/src/plugins/toolbar/block-insert/insert-table.tsx +32 -0
  126. package/src/plugins/toolbar/block-insert-plugin.tsx +30 -0
  127. package/src/plugins/toolbar/clear-formatting-toolbar-plugin.tsx +92 -0
  128. package/src/plugins/toolbar/code-language-toolbar-plugin.tsx +121 -0
  129. package/src/plugins/toolbar/element-format-toolbar-plugin.tsx +251 -0
  130. package/src/plugins/toolbar/font-background-toolbar-plugin.tsx +179 -0
  131. package/src/plugins/toolbar/font-color-toolbar-plugin.tsx +101 -0
  132. package/src/plugins/toolbar/font-family-toolbar-plugin.tsx +91 -0
  133. package/src/plugins/toolbar/font-format-toolbar-plugin.tsx +85 -0
  134. package/src/plugins/toolbar/font-size-toolbar-plugin.tsx +177 -0
  135. package/src/plugins/toolbar/history-toolbar-plugin.tsx +87 -0
  136. package/src/plugins/toolbar/link-toolbar-plugin.tsx +90 -0
  137. package/src/plugins/toolbar/subsuper-toolbar-plugin.tsx +69 -0
  138. package/src/plugins/toolbar/toolbar-plugin.tsx +66 -0
  139. package/src/plugins/typing-pref-plugin.tsx +118 -0
  140. package/src/shared/can-use-dom.ts +4 -0
  141. package/src/shared/environment.ts +47 -0
  142. package/src/shared/invariant.ts +16 -0
  143. package/src/shared/use-layout-effect.ts +12 -0
  144. package/src/themes/_mixins.scss +107 -0
  145. package/src/themes/_variables.scss +33 -0
  146. package/src/themes/editor-theme.scss +622 -0
  147. package/src/themes/editor-theme.ts +118 -0
  148. package/src/themes/plugins.scss +1180 -0
  149. package/src/themes/ui-components.scss +936 -0
  150. package/src/transformers/markdown-emoji-transformer.ts +20 -0
  151. package/src/transformers/markdown-hr-transformer.ts +28 -0
  152. package/src/transformers/markdown-image-transformer.ts +31 -0
  153. package/src/transformers/markdown-list-transformer.ts +51 -0
  154. package/src/transformers/markdown-table-transformer.ts +200 -0
  155. package/src/transformers/markdown-tweet-transformer.ts +26 -0
  156. package/src/ui/button-group.tsx +10 -0
  157. package/src/ui/button.tsx +29 -0
  158. package/src/ui/collapsible.tsx +67 -0
  159. package/src/ui/command.tsx +48 -0
  160. package/src/ui/dialog.tsx +146 -0
  161. package/src/ui/flex.tsx +38 -0
  162. package/src/ui/input.tsx +20 -0
  163. package/src/ui/label.tsx +20 -0
  164. package/src/ui/popover.tsx +128 -0
  165. package/src/ui/scroll-area.tsx +17 -0
  166. package/src/ui/select.tsx +171 -0
  167. package/src/ui/separator.tsx +20 -0
  168. package/src/ui/slider.tsx +14 -0
  169. package/src/ui/slot.tsx +3 -0
  170. package/src/ui/tabs.tsx +87 -0
  171. package/src/ui/toggle-group.tsx +109 -0
  172. package/src/ui/toggle.tsx +28 -0
  173. package/src/ui/tooltip.tsx +28 -0
  174. package/src/ui/typography.tsx +44 -0
  175. package/src/utils/doc-serialization.ts +68 -0
  176. package/src/utils/emoji-list.ts +16604 -0
  177. package/src/utils/get-dom-range-rect.ts +20 -0
  178. package/src/utils/get-selected-node.ts +20 -0
  179. package/src/utils/is-mobile-width.ts +0 -0
  180. package/src/utils/set-floating-elem-position-for-link-editor.ts +39 -0
  181. package/src/utils/set-floating-elem-position.ts +44 -0
  182. package/src/utils/swipe.ts +119 -0
  183. package/src/utils/url.ts +32 -0
@@ -0,0 +1,92 @@
1
+ "use client"
2
+
3
+ import { useCallback } from "react"
4
+ import { $isDecoratorBlockNode } from "@lexical/react/LexicalDecoratorBlockNode"
5
+ import { $isHeadingNode, $isQuoteNode } from "@lexical/rich-text"
6
+ import { $isTableSelection } from "@lexical/table"
7
+ import { $getNearestBlockElementAncestorOrThrow } from "@lexical/utils"
8
+ import {
9
+ $createParagraphNode,
10
+ $getSelection,
11
+ $isRangeSelection,
12
+ $isTextNode,
13
+ } from "lexical"
14
+ import { EraserIcon } from "lucide-react"
15
+
16
+ import { useToolbarContext } from "../../context/toolbar-context"
17
+ import { Button } from "../../ui/button"
18
+ import { IconSize } from "../../ui/typography"
19
+
20
+ export function ClearFormattingToolbarPlugin() {
21
+ const { activeEditor } = useToolbarContext()
22
+
23
+ const clearFormatting = useCallback(() => {
24
+ activeEditor.update(() => {
25
+ const selection = $getSelection()
26
+ if ($isRangeSelection(selection) || $isTableSelection(selection)) {
27
+ const anchor = selection.anchor
28
+ const focus = selection.focus
29
+ const nodes = selection.getNodes()
30
+ const extractedNodes = selection.extract()
31
+
32
+ if (anchor.key === focus.key && anchor.offset === focus.offset) {
33
+ return
34
+ }
35
+
36
+ nodes.forEach((node, idx) => {
37
+ // We split the first and last node by the selection
38
+ // So that we don't format unselected text inside those nodes
39
+ if ($isTextNode(node)) {
40
+ // Use a separate variable to ensure TS does not lose the refinement
41
+ let textNode = node
42
+ if (idx === 0 && anchor.offset !== 0) {
43
+ textNode = textNode.splitText(anchor.offset)[1] || textNode
44
+ }
45
+ if (idx === nodes.length - 1) {
46
+ textNode = textNode.splitText(focus.offset)[0] || textNode
47
+ }
48
+ /**
49
+ * If the selected text has one format applied
50
+ * selecting a portion of the text, could
51
+ * clear the format to the wrong portion of the text.
52
+ *
53
+ * The cleared text is based on the length of the selected text.
54
+ */
55
+ // We need this in case the selected text only has one format
56
+ const extractedTextNode = extractedNodes[0]
57
+ if (nodes.length === 1 && $isTextNode(extractedTextNode)) {
58
+ textNode = extractedTextNode
59
+ }
60
+
61
+ if (textNode.__style !== "") {
62
+ textNode.setStyle("")
63
+ }
64
+ if (textNode.__format !== 0) {
65
+ textNode.setFormat(0)
66
+ $getNearestBlockElementAncestorOrThrow(textNode).setFormat("")
67
+ }
68
+ node = textNode
69
+ } else if ($isHeadingNode(node) || $isQuoteNode(node)) {
70
+ node.replace($createParagraphNode(), true)
71
+ } else if ($isDecoratorBlockNode(node)) {
72
+ node.setFormat("")
73
+ }
74
+ })
75
+ }
76
+ })
77
+ }, [activeEditor])
78
+
79
+ return (
80
+ <Button
81
+ className="editor-toolbar-item"
82
+ aria-label="Clear formatting"
83
+ variant={"outline"}
84
+ size={"icon"}
85
+ onClick={clearFormatting}
86
+ >
87
+ <IconSize size="sm">
88
+ <EraserIcon />
89
+ </IconSize>
90
+ </Button>
91
+ )
92
+ }
@@ -0,0 +1,121 @@
1
+ "use client"
2
+
3
+ import { useCallback, useState } from "react"
4
+ import {
5
+ $isCodeNode,
6
+ CODE_LANGUAGE_FRIENDLY_NAME_MAP,
7
+ CODE_LANGUAGE_MAP,
8
+ getLanguageFriendlyName,
9
+ } from "@lexical/code"
10
+ import { $isListNode } from "@lexical/list"
11
+ import { $findMatchingParent } from "@lexical/utils"
12
+ import {
13
+ $getNodeByKey,
14
+ $isRangeSelection,
15
+ $isRootOrShadowRoot,
16
+ BaseSelection,
17
+ } from "lexical"
18
+
19
+ import { useToolbarContext } from "../../context/toolbar-context"
20
+ import { useUpdateToolbarHandler } from "../../editor-hooks/use-update-toolbar"
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ } from "../../ui/select"
27
+
28
+ function getCodeLanguageOptions(): [string, string][] {
29
+ const options: [string, string][] = []
30
+
31
+ for (const [lang, friendlyName] of Object.entries(
32
+ CODE_LANGUAGE_FRIENDLY_NAME_MAP
33
+ )) {
34
+ options.push([lang, friendlyName])
35
+ }
36
+
37
+ return options
38
+ }
39
+
40
+ const CODE_LANGUAGE_OPTIONS = getCodeLanguageOptions()
41
+
42
+ export function CodeLanguageToolbarPlugin() {
43
+ const { activeEditor } = useToolbarContext()
44
+ const [codeLanguage, setCodeLanguage] = useState<string>("")
45
+ const [selectedElementKey, setSelectedElementKey] = useState<string | null>(
46
+ null
47
+ )
48
+
49
+ const $updateToolbar = (selection: BaseSelection) => {
50
+ if ($isRangeSelection(selection)) {
51
+ const anchorNode = selection.anchor.getNode()
52
+ let element =
53
+ anchorNode.getKey() === "root"
54
+ ? anchorNode
55
+ : $findMatchingParent(anchorNode, (e) => {
56
+ const parent = e.getParent()
57
+ return parent !== null && $isRootOrShadowRoot(parent)
58
+ })
59
+
60
+ if (element === null) {
61
+ element = anchorNode.getTopLevelElementOrThrow()
62
+ }
63
+
64
+ const elementKey = element.getKey()
65
+ const elementDOM = activeEditor.getElementByKey(elementKey)
66
+
67
+ if (elementDOM !== null) {
68
+ setSelectedElementKey(elementKey)
69
+
70
+ if (!$isListNode(element) && $isCodeNode(element)) {
71
+ const language =
72
+ element.getLanguage() as keyof typeof CODE_LANGUAGE_MAP
73
+ setCodeLanguage(
74
+ language ? CODE_LANGUAGE_MAP[language] || language : ""
75
+ )
76
+ return
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ useUpdateToolbarHandler($updateToolbar)
83
+
84
+ const onCodeLanguageSelect = useCallback(
85
+ (value: string) => {
86
+ activeEditor.update(() => {
87
+ if (selectedElementKey !== null) {
88
+ const node = $getNodeByKey(selectedElementKey)
89
+ if ($isCodeNode(node)) {
90
+ node.setLanguage(value)
91
+ }
92
+ }
93
+ })
94
+ },
95
+ [activeEditor, selectedElementKey]
96
+ )
97
+
98
+ return (
99
+ <Select
100
+ modal={false}
101
+ value={codeLanguage}
102
+ onValueChange={onCodeLanguageSelect}
103
+ >
104
+ <SelectTrigger className="editor-toolbar-select-trigger editor-toolbar-select-trigger--w-auto editor-toolbar-item--gap-sm">
105
+ <span>
106
+ {getLanguageFriendlyName(codeLanguage) || "Select Language"}
107
+ </span>
108
+ </SelectTrigger>
109
+ <SelectContent>
110
+ {CODE_LANGUAGE_OPTIONS.map(([value, label]) => (
111
+ <SelectItem
112
+ key={value}
113
+ value={value}
114
+ >
115
+ {label}
116
+ </SelectItem>
117
+ ))}
118
+ </SelectContent>
119
+ </Select>
120
+ )
121
+ }
@@ -0,0 +1,251 @@
1
+ "use client"
2
+
3
+ import { useRef, useState } from "react"
4
+ import { $isLinkNode } from "@lexical/link"
5
+ import { $findMatchingParent } from "@lexical/utils"
6
+ import {
7
+ $getNodeByKey,
8
+ $getSelection,
9
+ $isElementNode,
10
+ $isRangeSelection,
11
+ BaseSelection,
12
+ ElementFormatType,
13
+ FORMAT_ELEMENT_COMMAND,
14
+ INDENT_CONTENT_COMMAND,
15
+ NodeKey,
16
+ OUTDENT_CONTENT_COMMAND,
17
+ } from "lexical"
18
+ import {
19
+ AlignCenterIcon,
20
+ AlignJustifyIcon,
21
+ AlignLeftIcon,
22
+ AlignRightIcon,
23
+ IndentDecreaseIcon,
24
+ IndentIncreaseIcon,
25
+ } from "lucide-react"
26
+
27
+ import { useToolbarContext } from "../../context/toolbar-context"
28
+ import { $isLayoutItemNode } from "../../nodes/layout-item-node"
29
+ import { useUpdateToolbarHandler } from "../../editor-hooks/use-update-toolbar"
30
+ import { getSelectedNode } from "../../utils/get-selected-node"
31
+ import { Separator } from "../../ui/separator"
32
+ import {
33
+ ToggleGroup,
34
+ ToggleGroupItem,
35
+ } from "../../ui/toggle-group"
36
+ import { IconSize } from "../../ui/typography"
37
+
38
+ const ELEMENT_FORMAT_OPTIONS: {
39
+ [key in Exclude<ElementFormatType, "start" | "end" | "">]: {
40
+ icon: React.ReactNode
41
+ iconRTL: string
42
+ name: string
43
+ }
44
+ } = {
45
+ left: {
46
+ icon: <IconSize size="sm"><AlignLeftIcon /></IconSize>,
47
+ iconRTL: "left-align",
48
+ name: "Left Align",
49
+ },
50
+ center: {
51
+ icon: <IconSize size="sm"><AlignCenterIcon /></IconSize>,
52
+ iconRTL: "center-align",
53
+ name: "Center Align",
54
+ },
55
+ right: {
56
+ icon: <IconSize size="sm"><AlignRightIcon /></IconSize>,
57
+ iconRTL: "right-align",
58
+ name: "Right Align",
59
+ },
60
+ justify: {
61
+ icon: <IconSize size="sm"><AlignJustifyIcon /></IconSize>,
62
+ iconRTL: "justify-align",
63
+ name: "Justify Align",
64
+ },
65
+ } as const
66
+
67
+ export function ElementFormatToolbarPlugin({
68
+ separator = true,
69
+ }: {
70
+ separator?: boolean
71
+ }) {
72
+ const { activeEditor } = useToolbarContext()
73
+ const [elementFormat, setElementFormat] = useState<ElementFormatType>("left")
74
+ const activeLayoutItemKeyRef = useRef<NodeKey | null>(null)
75
+
76
+ const extractPadding = (style: string): number | null => {
77
+ const match = style.match(/padding\s*:\s*(\d+)px/i)
78
+ if (!match?.[1]) return null
79
+ const value = Number.parseInt(match[1], 10)
80
+ return Number.isFinite(value) ? value : null
81
+ }
82
+
83
+ const setPadding = (style: string, nextPadding: number): string => {
84
+ if (/padding\s*:\s*\d+px/i.test(style)) {
85
+ return style.replace(/padding\s*:\s*\d+px/i, `padding: ${nextPadding}px`)
86
+ }
87
+ return `${style.trim().replace(/;?$/, ";")} padding: ${nextPadding}px`.trim()
88
+ }
89
+
90
+ const $updateToolbar = (selection: BaseSelection) => {
91
+ if ($isRangeSelection(selection)) {
92
+ const layoutItem = $findMatchingParent(selection.anchor.getNode(), (node) =>
93
+ $isLayoutItemNode(node)
94
+ )
95
+ activeLayoutItemKeyRef.current = $isLayoutItemNode(layoutItem)
96
+ ? layoutItem.getKey()
97
+ : null
98
+
99
+ const node = getSelectedNode(selection)
100
+ const parent = node.getParent()
101
+
102
+ let matchingParent
103
+ if ($isLinkNode(parent)) {
104
+ // If node is a link, we need to fetch the parent paragraph node to set format
105
+ matchingParent = $findMatchingParent(
106
+ node,
107
+ (parentNode) => $isElementNode(parentNode) && !parentNode.isInline()
108
+ )
109
+ }
110
+ setElementFormat(
111
+ $isElementNode(matchingParent)
112
+ ? matchingParent.getFormatType()
113
+ : $isElementNode(node)
114
+ ? node.getFormatType()
115
+ : parent?.getFormatType() || "left"
116
+ )
117
+ }
118
+ }
119
+
120
+ useUpdateToolbarHandler($updateToolbar)
121
+
122
+ const handleValueChange = (value: string) => {
123
+ if (!value) return // Prevent unselecting current value
124
+
125
+ setElementFormat(value as ElementFormatType)
126
+
127
+ if (value === "indent") {
128
+ activeEditor.update(() => {
129
+ const selectedLayoutItemKey = activeLayoutItemKeyRef.current
130
+ if (selectedLayoutItemKey) {
131
+ const node = $getNodeByKey(selectedLayoutItemKey)
132
+ if ($isLayoutItemNode(node)) {
133
+ const currentPadding = extractPadding(node.getStyle()) ?? 0
134
+ const nextPadding = Math.min(currentPadding + 4, 64)
135
+ node.setStyle(setPadding(node.getStyle(), nextPadding))
136
+ return
137
+ }
138
+ }
139
+
140
+ const selection = $getSelection()
141
+ if (!$isRangeSelection(selection)) {
142
+ activeEditor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
143
+ return
144
+ }
145
+ const layoutItem = $findMatchingParent(selection.anchor.getNode(), (node) =>
146
+ $isLayoutItemNode(node)
147
+ )
148
+ if ($isLayoutItemNode(layoutItem)) {
149
+ const currentPadding = extractPadding(layoutItem.getStyle()) ?? 0
150
+ const nextPadding = Math.min(currentPadding + 4, 64)
151
+ layoutItem.setStyle(setPadding(layoutItem.getStyle(), nextPadding))
152
+ return
153
+ }
154
+ activeEditor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
155
+ })
156
+ } else if (value === "outdent") {
157
+ activeEditor.update(() => {
158
+ const selectedLayoutItemKey = activeLayoutItemKeyRef.current
159
+ if (selectedLayoutItemKey) {
160
+ const node = $getNodeByKey(selectedLayoutItemKey)
161
+ if ($isLayoutItemNode(node)) {
162
+ const currentPadding = extractPadding(node.getStyle()) ?? 0
163
+ const nextPadding = Math.max(currentPadding - 4, 0)
164
+ node.setStyle(setPadding(node.getStyle(), nextPadding))
165
+ return
166
+ }
167
+ }
168
+
169
+ const selection = $getSelection()
170
+ if (!$isRangeSelection(selection)) {
171
+ activeEditor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
172
+ return
173
+ }
174
+ const layoutItem = $findMatchingParent(selection.anchor.getNode(), (node) =>
175
+ $isLayoutItemNode(node)
176
+ )
177
+ if ($isLayoutItemNode(layoutItem)) {
178
+ const currentPadding = extractPadding(layoutItem.getStyle()) ?? 0
179
+ const nextPadding = Math.max(currentPadding - 4, 0)
180
+ layoutItem.setStyle(setPadding(layoutItem.getStyle(), nextPadding))
181
+ return
182
+ }
183
+ activeEditor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
184
+ })
185
+ } else {
186
+ activeEditor.dispatchCommand(
187
+ FORMAT_ELEMENT_COMMAND,
188
+ value as ElementFormatType
189
+ )
190
+ }
191
+ }
192
+
193
+ return (
194
+ <>
195
+ <ToggleGroup
196
+ type="single"
197
+ value={elementFormat}
198
+ onValueChange={handleValueChange}
199
+ size="sm"
200
+ variant="outline"
201
+ >
202
+ {/* Alignment toggles */}
203
+ {Object.entries(ELEMENT_FORMAT_OPTIONS).map(([value, option]) => (
204
+ <ToggleGroupItem
205
+ key={value}
206
+ value={value}
207
+ variant={"outline"}
208
+ size="sm"
209
+ aria-label={option.name}
210
+ className="editor-toolbar-item"
211
+ >
212
+ {option.icon}
213
+ </ToggleGroupItem>
214
+ ))}
215
+ </ToggleGroup>
216
+ {separator && <Separator orientation="vertical" className="editor-toolbar-separator" />}
217
+ {/* Indentation toggles */}
218
+ <ToggleGroup
219
+ type="single"
220
+ value={elementFormat}
221
+ onValueChange={handleValueChange}
222
+ size="sm"
223
+ variant="outline"
224
+ >
225
+ <ToggleGroupItem
226
+ value="outdent"
227
+ aria-label="Outdent"
228
+ variant={"outline"}
229
+ size="sm"
230
+ className="editor-toolbar-item"
231
+ >
232
+ <IconSize size="sm">
233
+ <IndentDecreaseIcon />
234
+ </IconSize>
235
+ </ToggleGroupItem>
236
+
237
+ <ToggleGroupItem
238
+ value="indent"
239
+ variant={"outline"}
240
+ aria-label="Indent"
241
+ size="sm"
242
+ className="editor-toolbar-item"
243
+ >
244
+ <IconSize size="sm">
245
+ <IndentIncreaseIcon />
246
+ </IconSize>
247
+ </ToggleGroupItem>
248
+ </ToggleGroup>
249
+ </>
250
+ )
251
+ }
@@ -0,0 +1,179 @@
1
+ "use client"
2
+
3
+ import { useCallback, useRef, useState } from "react"
4
+ import {
5
+ $getSelectionStyleValueForProperty,
6
+ $patchStyleText,
7
+ } from "@lexical/selection"
8
+ import { $findMatchingParent } from "@lexical/utils"
9
+ import {
10
+ $getNodeByKey,
11
+ $getSelection,
12
+ $isRangeSelection,
13
+ BaseSelection,
14
+ NodeKey,
15
+ } from "lexical"
16
+ import { PaintBucketIcon } from "lucide-react"
17
+
18
+ import { $isLayoutItemNode } from "../../nodes/layout-item-node"
19
+ import { useToolbarContext } from "../../context/toolbar-context"
20
+ import { useUpdateToolbarHandler } from "../../editor-hooks/use-update-toolbar"
21
+ import {
22
+ ColorPicker,
23
+ ColorPickerAlphaSlider,
24
+ ColorPickerArea,
25
+ ColorPickerContent,
26
+ ColorPickerEyeDropper,
27
+ ColorPickerFormatSelect,
28
+ ColorPickerHueSlider,
29
+ ColorPickerInput,
30
+ ColorPickerPresets,
31
+ ColorPickerTrigger,
32
+ } from "../../editor-ui/color-picker"
33
+ import { Button } from "../../ui/button"
34
+ import { Flex } from "../../ui/flex"
35
+ import { IconSize } from "../../ui/typography"
36
+
37
+ export function FontBackgroundToolbarPlugin() {
38
+ const { activeEditor } = useToolbarContext()
39
+
40
+ const [bgColor, setBgColor] = useState("#fff")
41
+ const activeLayoutItemKeyRef = useRef<NodeKey | null>(null)
42
+
43
+ const extractStyleValue = (style: string, property: string): string | null => {
44
+ const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
45
+ const match = style.match(new RegExp(`${escapedProperty}\\s*:\\s*([^;]+)`, "i"))
46
+ return match?.[1]?.trim() ?? null
47
+ }
48
+
49
+ const setStyleProperty = (
50
+ style: string,
51
+ property: string,
52
+ value: string
53
+ ): string => {
54
+ const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
55
+ const regex = new RegExp(`${escapedProperty}\\s*:\\s*[^;]+`, "i")
56
+ if (regex.test(style)) {
57
+ return style.replace(regex, `${property}: ${value}`)
58
+ }
59
+ return `${style.trim().replace(/;?$/, ";")} ${property}: ${value}`.trim()
60
+ }
61
+
62
+ const $updateToolbar = (selection: BaseSelection) => {
63
+ if ($isRangeSelection(selection)) {
64
+ const layoutItem = $findMatchingParent(selection.anchor.getNode(), (node) =>
65
+ $isLayoutItemNode(node)
66
+ )
67
+ if ($isLayoutItemNode(layoutItem)) {
68
+ activeLayoutItemKeyRef.current = layoutItem.getKey()
69
+ const layoutStyle = layoutItem.getStyle()
70
+ const layoutBg = extractStyleValue(layoutStyle, "background-color")
71
+ if (layoutBg) {
72
+ setBgColor(layoutBg)
73
+ return
74
+ }
75
+ } else {
76
+ activeLayoutItemKeyRef.current = null
77
+ }
78
+
79
+ setBgColor(
80
+ $getSelectionStyleValueForProperty(
81
+ selection,
82
+ "background-color",
83
+ "#fff"
84
+ )
85
+ )
86
+ }
87
+ }
88
+
89
+ useUpdateToolbarHandler($updateToolbar)
90
+
91
+ const applyStyleText = useCallback(
92
+ (styles: Record<string, string>, skipHistoryStack?: boolean) => {
93
+ void skipHistoryStack
94
+ activeEditor.update(
95
+ () => {
96
+ const selection = $getSelection()
97
+ activeEditor.setEditable(false)
98
+ if (selection !== null) {
99
+ $patchStyleText(selection, styles)
100
+ }
101
+ },
102
+ { tag: "historic" }
103
+ )
104
+ },
105
+ [activeEditor]
106
+ )
107
+
108
+ const onBgColorSelect = useCallback(
109
+ (value: string) => {
110
+ let shouldApplyTextStyle = true
111
+ activeEditor.update(() => {
112
+ const selectedLayoutItemKey = activeLayoutItemKeyRef.current
113
+ if (selectedLayoutItemKey) {
114
+ const node = $getNodeByKey(selectedLayoutItemKey)
115
+ if ($isLayoutItemNode(node)) {
116
+ node.setStyle(setStyleProperty(node.getStyle(), "background-color", value))
117
+ shouldApplyTextStyle = false
118
+ return
119
+ }
120
+ }
121
+
122
+ const selection = $getSelection()
123
+ if (!$isRangeSelection(selection)) return
124
+ const layoutItem = $findMatchingParent(selection.anchor.getNode(), (node) =>
125
+ $isLayoutItemNode(node)
126
+ )
127
+ if ($isLayoutItemNode(layoutItem)) {
128
+ layoutItem.setStyle(
129
+ setStyleProperty(layoutItem.getStyle(), "background-color", value)
130
+ )
131
+ shouldApplyTextStyle = false
132
+ return
133
+ }
134
+ })
135
+ if (shouldApplyTextStyle) {
136
+ applyStyleText({ "background-color": value }, true)
137
+ }
138
+ },
139
+ [activeEditor, applyStyleText]
140
+ )
141
+
142
+ return (
143
+ <ColorPicker
144
+ modal
145
+ defaultFormat="hex"
146
+ defaultValue={bgColor}
147
+ onValueChange={onBgColorSelect}
148
+ onOpenChange={(open) => {
149
+ if (!open) {
150
+ activeEditor.setEditable(true)
151
+ activeEditor.focus()
152
+ }
153
+ }}
154
+ >
155
+ <ColorPickerTrigger asChild>
156
+ <Button variant={"outline"} size={"icon"} className="editor-toolbar-item--lg">
157
+ <IconSize size="sm">
158
+ <PaintBucketIcon />
159
+ </IconSize>
160
+ </Button>
161
+ </ColorPickerTrigger>
162
+ <ColorPickerContent>
163
+ <ColorPickerArea />
164
+ <Flex align="center" gap={2}>
165
+ <ColorPickerEyeDropper />
166
+ <Flex direction="column" gap={2} className="editor-flex-1">
167
+ <ColorPickerHueSlider />
168
+ <ColorPickerAlphaSlider />
169
+ </Flex>
170
+ </Flex>
171
+ <Flex align="center" gap={2}>
172
+ <ColorPickerFormatSelect />
173
+ <ColorPickerInput />
174
+ </Flex>
175
+ <ColorPickerPresets />
176
+ </ColorPickerContent>
177
+ </ColorPicker>
178
+ )
179
+ }