@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.
- package/dist/editor-x/editor.cjs +33121 -0
- package/dist/editor-x/editor.cjs.map +1 -0
- package/dist/editor-x/editor.css +2854 -0
- package/dist/editor-x/editor.css.map +1 -0
- package/dist/editor-x/editor.d.cts +12 -0
- package/dist/editor-x/editor.d.ts +12 -0
- package/dist/editor-x/editor.js +33095 -0
- package/dist/editor-x/editor.js.map +1 -0
- package/dist/index.cjs +33210 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +2854 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +33183 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -0
- package/src/components/lexical-editor.tsx +123 -0
- package/src/context/editor-container-context.tsx +29 -0
- package/src/context/priority-image-context.tsx +7 -0
- package/src/context/toolbar-context.tsx +60 -0
- package/src/context/uploads-context.tsx +53 -0
- package/src/editor-hooks/use-debounce.ts +80 -0
- package/src/editor-hooks/use-modal.tsx +64 -0
- package/src/editor-hooks/use-report.ts +57 -0
- package/src/editor-hooks/use-update-toolbar.ts +41 -0
- package/src/editor-ui/broken-image.tsx +18 -0
- package/src/editor-ui/caption-composer.tsx +45 -0
- package/src/editor-ui/code-button.tsx +75 -0
- package/src/editor-ui/color-picker.tsx +2010 -0
- package/src/editor-ui/content-editable.tsx +37 -0
- package/src/editor-ui/hooks/use-image-caption-controls.ts +118 -0
- package/src/editor-ui/hooks/use-image-node-interactions.ts +245 -0
- package/src/editor-ui/hooks/use-responsive-image-dimensions.ts +202 -0
- package/src/editor-ui/image-component.tsx +321 -0
- package/src/editor-ui/image-placeholder.tsx +57 -0
- package/src/editor-ui/image-resizer.tsx +499 -0
- package/src/editor-ui/image-sizing.ts +120 -0
- package/src/editor-ui/lazy-image.tsx +136 -0
- package/src/editor-x/editor.tsx +117 -0
- package/src/editor-x/nodes.ts +79 -0
- package/src/editor-x/plugins.tsx +380 -0
- package/src/hooks/use-click-outside.ts +27 -0
- package/src/hooks/use-element-size.ts +54 -0
- package/src/hooks/use-header-height.ts +95 -0
- package/src/hooks/use-isomorphic-layout-effect.ts +4 -0
- package/src/index.ts +4 -0
- package/src/lib/logger.ts +6 -0
- package/src/lib/utils.ts +19 -0
- package/src/nodes/autocomplete-node.tsx +94 -0
- package/src/nodes/embeds/tweet-node.tsx +224 -0
- package/src/nodes/embeds/youtube-node.tsx +519 -0
- package/src/nodes/emoji-node.tsx +83 -0
- package/src/nodes/image-node.tsx +328 -0
- package/src/nodes/keyword-node.tsx +58 -0
- package/src/nodes/layout-container-node.tsx +128 -0
- package/src/nodes/layout-item-node.tsx +118 -0
- package/src/nodes/list-with-color-node.tsx +160 -0
- package/src/nodes/mention-node.ts +122 -0
- package/src/plugins/actions/actions-plugin.tsx +3 -0
- package/src/plugins/actions/character-limit-plugin.tsx +27 -0
- package/src/plugins/actions/clear-editor-plugin.tsx +70 -0
- package/src/plugins/actions/counter-character-plugin.tsx +80 -0
- package/src/plugins/actions/edit-mode-toggle-plugin.tsx +49 -0
- package/src/plugins/actions/import-export-plugin.tsx +61 -0
- package/src/plugins/actions/markdown-toggle-plugin.tsx +78 -0
- package/src/plugins/actions/max-length-plugin.tsx +59 -0
- package/src/plugins/actions/share-content-plugin.tsx +72 -0
- package/src/plugins/actions/speech-to-text-plugin.tsx +159 -0
- package/src/plugins/actions/tree-view-plugin.tsx +63 -0
- package/src/plugins/align-plugin.tsx +86 -0
- package/src/plugins/auto-link-plugin.tsx +34 -0
- package/src/plugins/autocomplete-plugin.tsx +2574 -0
- package/src/plugins/code-action-menu-plugin.tsx +240 -0
- package/src/plugins/code-highlight-plugin.tsx +22 -0
- package/src/plugins/component-picker-menu-plugin.tsx +427 -0
- package/src/plugins/context-menu-plugin.tsx +311 -0
- package/src/plugins/drag-drop-paste-plugin.tsx +52 -0
- package/src/plugins/draggable-block-plugin.tsx +50 -0
- package/src/plugins/embeds/auto-embed-plugin.tsx +324 -0
- package/src/plugins/embeds/twitter-plugin.tsx +45 -0
- package/src/plugins/embeds/youtube-plugin.tsx +84 -0
- package/src/plugins/emoji-picker-plugin.tsx +206 -0
- package/src/plugins/emojis-plugin.tsx +84 -0
- package/src/plugins/floating-link-editor-plugin.tsx +791 -0
- package/src/plugins/floating-text-format-plugin.tsx +710 -0
- package/src/plugins/images-plugin.tsx +671 -0
- package/src/plugins/keywords-plugin.tsx +59 -0
- package/src/plugins/layout-plugin.tsx +658 -0
- package/src/plugins/link-plugin.tsx +18 -0
- package/src/plugins/list-color-plugin.tsx +178 -0
- package/src/plugins/list-max-indent-level-plugin.tsx +85 -0
- package/src/plugins/mentions-plugin.tsx +714 -0
- package/src/plugins/picker/alignment-picker-plugin.tsx +40 -0
- package/src/plugins/picker/bulleted-list-picker-plugin.tsx +14 -0
- package/src/plugins/picker/check-list-picker-plugin.tsx +14 -0
- package/src/plugins/picker/code-picker-plugin.tsx +30 -0
- package/src/plugins/picker/columns-layout-picker-plugin.tsx +16 -0
- package/src/plugins/picker/component-picker-option.tsx +47 -0
- package/src/plugins/picker/divider-picker-plugin.tsx +14 -0
- package/src/plugins/picker/embeds-picker-plugin.tsx +24 -0
- package/src/plugins/picker/heading-picker-plugin.tsx +32 -0
- package/src/plugins/picker/image-picker-plugin.tsx +16 -0
- package/src/plugins/picker/numbered-list-picker-plugin.tsx +14 -0
- package/src/plugins/picker/paragraph-picker-plugin.tsx +20 -0
- package/src/plugins/picker/quote-picker-plugin.tsx +21 -0
- package/src/plugins/picker/table-picker-plugin.tsx +56 -0
- package/src/plugins/tab-focus-plugin.tsx +66 -0
- package/src/plugins/table-column-resizer-plugin.tsx +309 -0
- package/src/plugins/table-plugin.tsx +299 -0
- package/src/plugins/toolbar/block-format/block-format-data.tsx +69 -0
- package/src/plugins/toolbar/block-format/format-bulleted-list.tsx +40 -0
- package/src/plugins/toolbar/block-format/format-check-list.tsx +40 -0
- package/src/plugins/toolbar/block-format/format-code-block.tsx +45 -0
- package/src/plugins/toolbar/block-format/format-heading.tsx +34 -0
- package/src/plugins/toolbar/block-format/format-list-with-marker.tsx +74 -0
- package/src/plugins/toolbar/block-format/format-numbered-list.tsx +40 -0
- package/src/plugins/toolbar/block-format/format-paragraph.tsx +31 -0
- package/src/plugins/toolbar/block-format/format-quote.tsx +32 -0
- package/src/plugins/toolbar/block-format-toolbar-plugin.tsx +117 -0
- package/src/plugins/toolbar/block-insert/insert-columns-layout.tsx +32 -0
- package/src/plugins/toolbar/block-insert/insert-embeds.tsx +31 -0
- package/src/plugins/toolbar/block-insert/insert-horizontal-rule.tsx +30 -0
- package/src/plugins/toolbar/block-insert/insert-image.tsx +32 -0
- package/src/plugins/toolbar/block-insert/insert-table.tsx +32 -0
- package/src/plugins/toolbar/block-insert-plugin.tsx +30 -0
- package/src/plugins/toolbar/clear-formatting-toolbar-plugin.tsx +92 -0
- package/src/plugins/toolbar/code-language-toolbar-plugin.tsx +121 -0
- package/src/plugins/toolbar/element-format-toolbar-plugin.tsx +251 -0
- package/src/plugins/toolbar/font-background-toolbar-plugin.tsx +179 -0
- package/src/plugins/toolbar/font-color-toolbar-plugin.tsx +101 -0
- package/src/plugins/toolbar/font-family-toolbar-plugin.tsx +91 -0
- package/src/plugins/toolbar/font-format-toolbar-plugin.tsx +85 -0
- package/src/plugins/toolbar/font-size-toolbar-plugin.tsx +177 -0
- package/src/plugins/toolbar/history-toolbar-plugin.tsx +87 -0
- package/src/plugins/toolbar/link-toolbar-plugin.tsx +90 -0
- package/src/plugins/toolbar/subsuper-toolbar-plugin.tsx +69 -0
- package/src/plugins/toolbar/toolbar-plugin.tsx +66 -0
- package/src/plugins/typing-pref-plugin.tsx +118 -0
- package/src/shared/can-use-dom.ts +4 -0
- package/src/shared/environment.ts +47 -0
- package/src/shared/invariant.ts +16 -0
- package/src/shared/use-layout-effect.ts +12 -0
- package/src/themes/_mixins.scss +107 -0
- package/src/themes/_variables.scss +33 -0
- package/src/themes/editor-theme.scss +622 -0
- package/src/themes/editor-theme.ts +118 -0
- package/src/themes/plugins.scss +1180 -0
- package/src/themes/ui-components.scss +936 -0
- package/src/transformers/markdown-emoji-transformer.ts +20 -0
- package/src/transformers/markdown-hr-transformer.ts +28 -0
- package/src/transformers/markdown-image-transformer.ts +31 -0
- package/src/transformers/markdown-list-transformer.ts +51 -0
- package/src/transformers/markdown-table-transformer.ts +200 -0
- package/src/transformers/markdown-tweet-transformer.ts +26 -0
- package/src/ui/button-group.tsx +10 -0
- package/src/ui/button.tsx +29 -0
- package/src/ui/collapsible.tsx +67 -0
- package/src/ui/command.tsx +48 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/flex.tsx +38 -0
- package/src/ui/input.tsx +20 -0
- package/src/ui/label.tsx +20 -0
- package/src/ui/popover.tsx +128 -0
- package/src/ui/scroll-area.tsx +17 -0
- package/src/ui/select.tsx +171 -0
- package/src/ui/separator.tsx +20 -0
- package/src/ui/slider.tsx +14 -0
- package/src/ui/slot.tsx +3 -0
- package/src/ui/tabs.tsx +87 -0
- package/src/ui/toggle-group.tsx +109 -0
- package/src/ui/toggle.tsx +28 -0
- package/src/ui/tooltip.tsx +28 -0
- package/src/ui/typography.tsx +44 -0
- package/src/utils/doc-serialization.ts +68 -0
- package/src/utils/emoji-list.ts +16604 -0
- package/src/utils/get-dom-range-rect.ts +20 -0
- package/src/utils/get-selected-node.ts +20 -0
- package/src/utils/is-mobile-width.ts +0 -0
- package/src/utils/set-floating-elem-position-for-link-editor.ts +39 -0
- package/src/utils/set-floating-elem-position.ts +44 -0
- package/src/utils/swipe.ts +119 -0
- 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
|
+
}
|