@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,309 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef } from "react"
4
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
5
+ import { useLexicalEditable } from "@lexical/react/useLexicalEditable"
6
+ import {
7
+ $computeTableMapSkipCellCheck,
8
+ $getTableNodeFromLexicalNodeOrThrow,
9
+ $isTableCellNode,
10
+ TableNode,
11
+ } from "@lexical/table"
12
+ import { $getNearestNodeFromDOMNode, $getNodeByKey, type NodeKey } from "lexical"
13
+
14
+ type ResizeEdge = "left" | "right"
15
+
16
+ type DragState = {
17
+ tableKey: NodeKey
18
+ columnIndex: number
19
+ startX: number
20
+ startWidth: number
21
+ initialColWidths: number[]
22
+ }
23
+
24
+ const EDGE_HITBOX_PX = 12
25
+
26
+ /** Độ rộng tối thiểu khi kéo cột (px) — nhỏ hơn để có thể thu hẹp cột như "TT" */
27
+ const MIN_COLUMN_WIDTH_PX = 15
28
+
29
+ function getCellTarget(target: EventTarget | null): HTMLTableCellElement | null {
30
+ if (!target) return null
31
+ let node = target as Node
32
+ if (node.nodeType === Node.TEXT_NODE) {
33
+ node = node.parentNode as Node
34
+ }
35
+ if (!(node instanceof HTMLElement)) return null
36
+ const cell = node.closest("th, td")
37
+ return cell instanceof HTMLTableCellElement ? cell : null
38
+ }
39
+
40
+ function getResizeEdge(clientX: number, rect: DOMRect): ResizeEdge | null {
41
+ const nearLeft = Math.abs(clientX - rect.left) <= EDGE_HITBOX_PX
42
+ const nearRight = Math.abs(clientX - rect.right) <= EDGE_HITBOX_PX
43
+
44
+ if (!nearLeft && !nearRight) return null
45
+ if (nearLeft && nearRight) return "right"
46
+ return nearLeft ? "left" : "right"
47
+ }
48
+
49
+ function getColumnIndexFromTableMap(
50
+ tableNode: TableNode,
51
+ tableCellNode: ReturnType<typeof $getNearestNodeFromDOMNode>
52
+ ): number | null {
53
+ if (!$isTableCellNode(tableCellNode)) return null
54
+ const [tableMap] = $computeTableMapSkipCellCheck(tableNode, null, null)
55
+
56
+ for (let row = 0; row < tableMap.length; row++) {
57
+ const tableMapRow = tableMap[row]
58
+ if (!tableMapRow) continue
59
+
60
+ for (let column = 0; column < tableMapRow.length; column++) {
61
+ const cell = tableMapRow[column]
62
+ if (cell && cell.cell === tableCellNode) {
63
+ return column
64
+ }
65
+ }
66
+ }
67
+
68
+ return null
69
+ }
70
+
71
+ export function TableColumnResizerPlugin() {
72
+ const [editor] = useLexicalComposerContext()
73
+ const isEditable = useLexicalEditable()
74
+ const dragRef = useRef<DragState | null>(null)
75
+
76
+ useEffect(() => {
77
+ if (!isEditable) return
78
+
79
+ const rootElement = editor.getRootElement()
80
+ if (!rootElement) return
81
+
82
+ const setCursor = (cursor: string) => {
83
+ rootElement.style.cursor = cursor
84
+ }
85
+
86
+ const clearCursor = () => {
87
+ setCursor("")
88
+ }
89
+
90
+ const onPointerMove = (event: PointerEvent) => {
91
+ if (dragRef.current) return
92
+
93
+ const cell = getCellTarget(event.target)
94
+ if (!cell) {
95
+ clearCursor()
96
+ return
97
+ }
98
+
99
+ const edge = getResizeEdge(event.clientX, cell.getBoundingClientRect())
100
+
101
+ // Disable dragging left edge of the first column (visual check)
102
+ if (edge === "left") {
103
+ const table = cell.closest("table")
104
+ if (table) {
105
+ const tableRect = table.getBoundingClientRect()
106
+ const cellRect = cell.getBoundingClientRect()
107
+ // If cell left edge is close to table left edge (within tolerance)
108
+ if (Math.abs(cellRect.left - tableRect.left) < 5) {
109
+ clearCursor()
110
+ return
111
+ }
112
+ }
113
+ }
114
+
115
+ setCursor(edge ? "col-resize" : "")
116
+ }
117
+
118
+ const onPointerDown = (event: PointerEvent) => {
119
+ if (event.button !== 0) return
120
+
121
+ const cell = getCellTarget(event.target)
122
+ if (!cell) return
123
+
124
+ const edge = getResizeEdge(event.clientX, cell.getBoundingClientRect())
125
+ if (!edge) return
126
+
127
+ event.preventDefault()
128
+
129
+ let nextDragState: DragState | null = null
130
+
131
+ editor.read(() => {
132
+ const tableCellNode = $getNearestNodeFromDOMNode(cell)
133
+ if (!$isTableCellNode(tableCellNode)) return
134
+
135
+ const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode)
136
+ const columnIndexFromMap = getColumnIndexFromTableMap(tableNode, tableCellNode)
137
+ if (columnIndexFromMap == null) return
138
+
139
+ // Disable resizing left edge of the first column
140
+ if (edge === "left" && columnIndexFromMap === 0) return
141
+
142
+ const colSpan = tableCellNode.getColSpan()
143
+ const resizeColumnIndex =
144
+ edge === "left" ? columnIndexFromMap - 1 : columnIndexFromMap + colSpan - 1
145
+
146
+ let currentWidths = tableNode.getColWidths()
147
+
148
+ if (!currentWidths) {
149
+ const tableElement = editor.getElementByKey(tableNode.getKey())
150
+ if (tableElement instanceof HTMLTableElement) {
151
+ // Attempt to reconstruct widths from TableMap (handles merged cells better)
152
+ const [tableMap] = $computeTableMapSkipCellCheck(tableNode, null, null)
153
+ const colCount = tableNode.getColumnCount()
154
+ const widths: (number | undefined)[] = new Array(colCount).fill(undefined)
155
+
156
+ for (let r = 0; r < tableMap.length; r++) {
157
+ const row = tableMap[r]
158
+ if (!row) continue
159
+ for (let c = 0; c < row.length; c++) {
160
+ if (widths[c] !== undefined) continue
161
+
162
+ const tableMapCell = row[c]
163
+ if (!tableMapCell) continue
164
+
165
+ const { cell } = tableMapCell
166
+ // Only measure if this cell spans 1 column (accurate width)
167
+ if (cell.getColSpan() === 1) {
168
+ const cellElem = editor.getElementByKey(cell.getKey())
169
+ if (cellElem) {
170
+ widths[c] = Math.round(cellElem.getBoundingClientRect().width)
171
+ }
172
+ }
173
+ }
174
+ // If all widths found, break early
175
+ if (widths.every(w => w !== undefined)) break
176
+ }
177
+
178
+ // Fallback: If still missing widths, try row 0 or distribute evenly
179
+ if (widths.some(w => w === undefined)) {
180
+ if (tableElement.rows.length > 0) {
181
+ const row = tableElement.rows[0]
182
+ if (row && row.cells.length === colCount) {
183
+ // Row 0 is perfect match, use it for missing values or override?
184
+ // Prefer row 0 if it's clean
185
+ const rowWidths = Array.from(row.cells).map(c => Math.round(c.getBoundingClientRect().width))
186
+ for (let i = 0; i < colCount; i++) {
187
+ if (widths[i] === undefined) widths[i] = rowWidths[i]
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ // Final fallback: distribute evenly for any remaining undefined
194
+ if (widths.some(w => w === undefined)) {
195
+ const tableWidth = tableElement.getBoundingClientRect().width
196
+ const defaultWidth = tableWidth / colCount
197
+ for (let i = 0; i < colCount; i++) {
198
+ if (widths[i] === undefined) widths[i] = defaultWidth
199
+ }
200
+ }
201
+
202
+ currentWidths = widths as number[]
203
+ }
204
+ }
205
+
206
+ if (!currentWidths) {
207
+ currentWidths = Array(tableNode.getColumnCount()).fill(MIN_COLUMN_WIDTH_PX)
208
+ }
209
+
210
+ const startWidth =
211
+ currentWidths[resizeColumnIndex] ??
212
+ Math.max(Math.round(cell.getBoundingClientRect().width), MIN_COLUMN_WIDTH_PX)
213
+
214
+ nextDragState = {
215
+ tableKey: tableNode.getKey(),
216
+ columnIndex: resizeColumnIndex,
217
+ startX: event.clientX,
218
+ startWidth,
219
+ initialColWidths: [...currentWidths],
220
+ }
221
+ })
222
+
223
+ if (!nextDragState) return
224
+
225
+ event.preventDefault()
226
+ event.stopPropagation()
227
+ dragRef.current = nextDragState
228
+ setCursor("col-resize")
229
+ }
230
+
231
+ const onPointerMoveDocument = (event: PointerEvent) => {
232
+ const drag = dragRef.current
233
+ if (!drag) return
234
+
235
+ const { initialColWidths, startX, columnIndex } = drag
236
+ const deltaX = event.clientX - startX
237
+
238
+ editor.update(() => {
239
+ const tableNode = $getNodeByKey(drag.tableKey)
240
+ if (!(tableNode instanceof TableNode)) return
241
+
242
+ const colWidths = tableNode.getColWidths() ?? initialColWidths
243
+ const nextColumnIndex = columnIndex + 1
244
+ const hasNeighbor = nextColumnIndex < initialColWidths.length
245
+
246
+ if (hasNeighbor) {
247
+ // Strict "Borrow" Logic:
248
+ // Adjust column width by borrowing from the neighbor.
249
+ // The total width of the two columns remains constant, ensuring the table width doesn't change.
250
+ const currentLeftWidth = initialColWidths[columnIndex]
251
+ const currentRightWidth = initialColWidths[nextColumnIndex]
252
+
253
+ if (currentLeftWidth === undefined || currentRightWidth === undefined) return
254
+
255
+ const maxShrinkLeft = currentLeftWidth - MIN_COLUMN_WIDTH_PX
256
+ const maxGrowLeft = currentRightWidth - MIN_COLUMN_WIDTH_PX
257
+
258
+ // Clamp deltaX to ensure neither column shrinks below MIN_COLUMN_WIDTH_PX
259
+ const constrainedDelta = Math.min(Math.max(deltaX, -maxShrinkLeft), maxGrowLeft)
260
+
261
+ const newLeftWidth = currentLeftWidth + constrainedDelta
262
+ const newRightWidth = currentRightWidth - constrainedDelta
263
+
264
+ if (colWidths[columnIndex] === newLeftWidth && colWidths[nextColumnIndex] === newRightWidth)
265
+ return
266
+
267
+ const nextColWidths = [...colWidths]
268
+ nextColWidths[columnIndex] = newLeftWidth
269
+ nextColWidths[nextColumnIndex] = newRightWidth
270
+ tableNode.setColWidths(nextColWidths)
271
+ } else {
272
+ // Fallback for last column (or if no neighbor exists)
273
+ const currentWidth = initialColWidths[columnIndex]
274
+ if (currentWidth === undefined) return
275
+
276
+ const newWidth = Math.max(MIN_COLUMN_WIDTH_PX, Math.round(currentWidth + deltaX))
277
+
278
+ if (colWidths[columnIndex] === newWidth) return
279
+
280
+ const nextColWidths = [...colWidths]
281
+ nextColWidths[columnIndex] = newWidth
282
+ tableNode.setColWidths(nextColWidths)
283
+ }
284
+ })
285
+ }
286
+
287
+ const onPointerUpDocument = () => {
288
+ if (!dragRef.current) return
289
+ dragRef.current = null
290
+ clearCursor()
291
+ }
292
+
293
+ rootElement.addEventListener("pointermove", onPointerMove)
294
+ rootElement.addEventListener("pointerdown", onPointerDown)
295
+ document.addEventListener("pointermove", onPointerMoveDocument)
296
+ document.addEventListener("pointerup", onPointerUpDocument)
297
+
298
+ return () => {
299
+ rootElement.removeEventListener("pointermove", onPointerMove)
300
+ rootElement.removeEventListener("pointerdown", onPointerDown)
301
+ document.removeEventListener("pointermove", onPointerMoveDocument)
302
+ document.removeEventListener("pointerup", onPointerUpDocument)
303
+ clearCursor()
304
+ dragRef.current = null
305
+ }
306
+ }, [editor, isEditable])
307
+
308
+ return null
309
+ }
@@ -0,0 +1,299 @@
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 {
11
+ createContext,
12
+ JSX,
13
+ useContext,
14
+ useEffect,
15
+ useMemo,
16
+ useState,
17
+ } from "react"
18
+ import * as React from "react"
19
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
20
+ import {
21
+ $createTableNodeWithDimensions,
22
+ TableNode,
23
+ } from "@lexical/table"
24
+ import {
25
+ $insertNodes,
26
+ $getSelection,
27
+ $getRoot,
28
+ $isRangeSelection,
29
+ COMMAND_PRIORITY_EDITOR,
30
+ createCommand,
31
+ EditorThemeClasses,
32
+ Klass,
33
+ LexicalCommand,
34
+ LexicalEditor,
35
+ LexicalNode,
36
+ } from "lexical"
37
+
38
+ import { invariant } from "../shared/invariant"
39
+ import { Button } from "../ui/button"
40
+ import { DialogFooter } from "../ui/dialog"
41
+ import { Flex } from "../ui/flex"
42
+ import { Input } from "../ui/input"
43
+ import { Label } from "../ui/label"
44
+
45
+ export type InsertTableCommandPayload = Readonly<{
46
+ columns: string
47
+ rows: string
48
+ includeHeaders?: boolean
49
+ }>
50
+
51
+ export type CellContextShape = {
52
+ cellEditorConfig: null | CellEditorConfig
53
+ cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
54
+ set: (
55
+ cellEditorConfig: null | CellEditorConfig,
56
+ cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
57
+ ) => void
58
+ }
59
+
60
+ export type CellEditorConfig = Readonly<{
61
+ namespace: string
62
+ nodes?: ReadonlyArray<Klass<LexicalNode>>
63
+ onError: (error: Error, editor: LexicalEditor) => void
64
+ readOnly?: boolean
65
+ theme?: EditorThemeClasses
66
+ }>
67
+
68
+ export const INSERT_NEW_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> =
69
+ createCommand("INSERT_NEW_TABLE_COMMAND")
70
+
71
+ /** Chèn bảng vào editor (dùng trong dialog hoặc command handler) */
72
+ function insertTableIntoEditor(
73
+ editor: LexicalEditor,
74
+ payload: InsertTableCommandPayload
75
+ ): void {
76
+ const rows = Number(payload.rows) || 3
77
+ const cols = Number(payload.columns) || 3
78
+ const includeHeaders = payload.includeHeaders ?? false
79
+ editor.update(
80
+ () => {
81
+ const root = $getRoot()
82
+ const tableNode = $createTableNodeWithDimensions(rows, cols, includeHeaders)
83
+ try {
84
+ const selection = $getSelection()
85
+ let targetBlock: ReturnType<typeof root.getFirstChild> = null
86
+ if (selection !== null && $isRangeSelection(selection)) {
87
+ try {
88
+ targetBlock = selection.anchor.getNode().getTopLevelElementOrThrow()
89
+ } catch {
90
+ targetBlock = root.getLastChild()
91
+ }
92
+ } else {
93
+ targetBlock = root.getLastChild()
94
+ }
95
+ if (targetBlock) {
96
+ targetBlock.insertAfter(tableNode)
97
+ } else {
98
+ root.append(tableNode)
99
+ }
100
+ } catch {
101
+ root.selectEnd()
102
+ $insertNodes([tableNode])
103
+ }
104
+ },
105
+ { tag: "insert-table" }
106
+ )
107
+ }
108
+
109
+ export const CellContext = createContext<CellContextShape>({
110
+ cellEditorConfig: null,
111
+ cellEditorPlugins: null,
112
+ set: () => {
113
+ // Empty
114
+ },
115
+ })
116
+
117
+ export function TableContext({ children }: { children: JSX.Element }) {
118
+ const [contextValue, setContextValue] = useState<{
119
+ cellEditorConfig: null | CellEditorConfig
120
+ cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
121
+ }>({
122
+ cellEditorConfig: null,
123
+ cellEditorPlugins: null,
124
+ })
125
+ return (
126
+ <CellContext.Provider
127
+ value={useMemo(
128
+ () => ({
129
+ cellEditorConfig: contextValue.cellEditorConfig,
130
+ cellEditorPlugins: contextValue.cellEditorPlugins,
131
+ set: (cellEditorConfig, cellEditorPlugins) => {
132
+ setContextValue({ cellEditorConfig, cellEditorPlugins })
133
+ },
134
+ }),
135
+ [contextValue.cellEditorConfig, contextValue.cellEditorPlugins]
136
+ )}
137
+ >
138
+ {children}
139
+ </CellContext.Provider>
140
+ )
141
+ }
142
+
143
+ export function InsertTableDialog({
144
+ activeEditor,
145
+ onClose,
146
+ }: {
147
+ activeEditor: LexicalEditor
148
+ onClose: () => void
149
+ }): JSX.Element {
150
+ const [rows, setRows] = useState("5")
151
+ const [columns, setColumns] = useState("5")
152
+ const [includeHeaders, setIncludeHeaders] = useState(true)
153
+
154
+ // Cho phép tùy chỉnh số cột/dòng; giới hạn 1–100 cột, 1–500 dòng
155
+ const isDisabled = useMemo(() => {
156
+ const row = Number(rows)
157
+ const column = Number(columns)
158
+ return !(row && row > 0 && row <= 500 && column && column > 0 && column <= 100)
159
+ }, [rows, columns])
160
+
161
+ const onClick = () => {
162
+ const payload = { columns, rows, includeHeaders }
163
+ onClose()
164
+ const rootEl = activeEditor.getRootElement()
165
+ rootEl?.focus({ preventScroll: false })
166
+ // Chèn bảng trực tiếp bằng editor.update (không qua command) để luôn hoạt động
167
+ setTimeout(() => {
168
+ insertTableIntoEditor(activeEditor, payload)
169
+ }, 150)
170
+ }
171
+
172
+ return (
173
+ <div className="editor-table-dialog">
174
+ <Flex direction="column" gap={4}>
175
+ <Flex gap={4}>
176
+ <div className="editor-table-dialog__group editor-flex-1">
177
+ <Label htmlFor="rows" className="editor-mb-2">
178
+ Số dòng
179
+ </Label>
180
+ <Input
181
+ id="rows"
182
+ placeholder="Ví dụ: 5"
183
+ onChange={(e) => setRows(e.target.value)}
184
+ value={rows}
185
+ data-test-id="table-modal-rows"
186
+ type="number"
187
+ min={1}
188
+ max={500}
189
+ />
190
+ </div>
191
+ <div className="editor-table-dialog__group editor-flex-1">
192
+ <Label htmlFor="columns" className="editor-mb-2">
193
+ Số cột
194
+ </Label>
195
+ <Input
196
+ id="columns"
197
+ placeholder="Ví dụ: 5"
198
+ onChange={(e) => setColumns(e.target.value)}
199
+ value={columns}
200
+ data-test-id="table-modal-columns"
201
+ type="number"
202
+ min={1}
203
+ max={100}
204
+ />
205
+ </div>
206
+ </Flex>
207
+
208
+ <Flex
209
+ align="center"
210
+ gap={2}
211
+ className="editor-table-dialog__checkbox-group"
212
+ >
213
+ <input
214
+ id="include-headers"
215
+ type="checkbox"
216
+ checked={includeHeaders}
217
+ onChange={(e) => setIncludeHeaders(e.target.checked)}
218
+ className="editor-checkbox"
219
+ />
220
+ <Label htmlFor="include-headers" className="editor-label--normal">
221
+ Dòng đầu làm hàng tiêu đề
222
+ </Label>
223
+ </Flex>
224
+
225
+ <div className="editor-text-xs-muted">
226
+ Sau khi chèn, có thể gộp ô (chuột phải) để tạo tiêu đề phức tạp.
227
+ </div>
228
+
229
+ <DialogFooter data-test-id="table-modal-confirm-insert" className="editor-px-0 editor-mt-2">
230
+ <Button
231
+ disabled={isDisabled}
232
+ onClick={onClick}
233
+ className="editor-w-full"
234
+ >
235
+ Chèn bảng
236
+ </Button>
237
+ </DialogFooter>
238
+ </Flex>
239
+ </div>
240
+ )
241
+ }
242
+
243
+ export function TablePlugin({
244
+ cellEditorConfig,
245
+ children,
246
+ }: {
247
+ cellEditorConfig: CellEditorConfig
248
+ children: JSX.Element | Array<JSX.Element>
249
+ }): JSX.Element | null {
250
+ const [editor] = useLexicalComposerContext()
251
+ const cellContext = useContext(CellContext)
252
+
253
+ useEffect(() => {
254
+ if (!editor.hasNodes([TableNode])) {
255
+ invariant(false, "TablePlugin: TableNode is not registered on editor")
256
+ }
257
+
258
+ cellContext.set(cellEditorConfig, children)
259
+
260
+ return editor.registerCommand<InsertTableCommandPayload>(
261
+ INSERT_NEW_TABLE_COMMAND,
262
+ ({ columns, rows, includeHeaders }) => {
263
+ const tableNode = $createTableNodeWithDimensions(
264
+ Number(rows),
265
+ Number(columns),
266
+ includeHeaders
267
+ )
268
+ $insertNodes([tableNode])
269
+ return true
270
+ },
271
+ COMMAND_PRIORITY_EDITOR
272
+ )
273
+ }, [cellContext, cellEditorConfig, children, editor])
274
+
275
+ return null
276
+ }
277
+
278
+ /**
279
+ * Plugin chỉ đăng ký INSERT_NEW_TABLE_COMMAND để Insert Table tạo đúng node bảng.
280
+ * Dùng thay cho TablePlugin đầy đủ khi không cần cellEditorConfig.
281
+ */
282
+ export function InsertTableCommandPlugin(): JSX.Element | null {
283
+ const [editor] = useLexicalComposerContext()
284
+
285
+ useEffect(() => {
286
+ if (!editor.hasNodes([TableNode])) return
287
+
288
+ return editor.registerCommand<InsertTableCommandPayload>(
289
+ INSERT_NEW_TABLE_COMMAND,
290
+ (payload) => {
291
+ insertTableIntoEditor(editor, payload)
292
+ return true
293
+ },
294
+ COMMAND_PRIORITY_EDITOR
295
+ )
296
+ }, [editor])
297
+
298
+ return null
299
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ CodeIcon,
3
+ Heading1Icon,
4
+ Heading2Icon,
5
+ Heading3Icon,
6
+ ListIcon,
7
+ ListOrderedIcon,
8
+ ListTodoIcon,
9
+ QuoteIcon,
10
+ TextIcon,
11
+ ListMinusIcon,
12
+ ListPlusIcon,
13
+ CaseSensitiveIcon,
14
+ } from "lucide-react"
15
+ import { IconSize } from "../../../ui/typography"
16
+
17
+ export const blockTypeToBlockName: Record<
18
+ string,
19
+ { label: string; icon: React.ReactNode }
20
+ > = {
21
+ paragraph: {
22
+ label: "Paragraph",
23
+ icon: <IconSize size="sm"><TextIcon /></IconSize>,
24
+ },
25
+ h1: {
26
+ label: "Heading 1",
27
+ icon: <IconSize size="sm"><Heading1Icon /></IconSize>,
28
+ },
29
+ h2: {
30
+ label: "Heading 2",
31
+ icon: <IconSize size="sm"><Heading2Icon /></IconSize>,
32
+ },
33
+ h3: {
34
+ label: "Heading 3",
35
+ icon: <IconSize size="sm"><Heading3Icon /></IconSize>,
36
+ },
37
+ number: {
38
+ label: "Numbered List",
39
+ icon: <IconSize size="sm"><ListOrderedIcon /></IconSize>,
40
+ },
41
+ "number-alpha": {
42
+ label: "Alpha List (a, b, c)",
43
+ icon: <IconSize size="sm"><CaseSensitiveIcon /></IconSize>,
44
+ },
45
+ bullet: {
46
+ label: "Bulleted List",
47
+ icon: <IconSize size="sm"><ListIcon /></IconSize>,
48
+ },
49
+ "bullet-dash": {
50
+ label: "Dash List (-)",
51
+ icon: <IconSize size="sm"><ListMinusIcon /></IconSize>,
52
+ },
53
+ "bullet-plus": {
54
+ label: "Plus List (+)",
55
+ icon: <IconSize size="sm"><ListPlusIcon /></IconSize>,
56
+ },
57
+ check: {
58
+ label: "Check List",
59
+ icon: <IconSize size="sm"><ListTodoIcon /></IconSize>,
60
+ },
61
+ code: {
62
+ label: "Code Block",
63
+ icon: <IconSize size="sm"><CodeIcon /></IconSize>,
64
+ },
65
+ quote: {
66
+ label: "Quote",
67
+ icon: <IconSize size="sm"><QuoteIcon /></IconSize>,
68
+ },
69
+ }