'use client' import * as React from 'react' import type { ContentEditorTableAlignment } from '@admin/utils/editor/content-editor-table-alignment' import type { Editor } from '@tiptap/core' import { Button } from '@admin/components/ui/button' import { Separator } from '@admin/components/ui/separator' import { useTiptapEditor } from '@admin/hooks/content-editor/use-tiptap-editor' import { clearTablePartPreview } from '@admin/utils/editor/clear-table-part-preview' import { getMountedEditorView } from '@admin/utils/editor/editor-view' import { getActiveTableColumnAlignment } from '@admin/utils/editor/get-active-table-column-alignment' import { getClosestTableCell } from '@admin/utils/editor/get-closest-table-cell' import { getEditorElement } from '@admin/utils/editor/get-editor-element' import { getEditorScrollElement } from '@admin/utils/editor/get-editor-scroll-element' import { getEventTableCell } from '@admin/utils/editor/get-event-table-cell' import { isSelectedTableVisible } from '@admin/utils/editor/is-selected-table-visible' import { setActiveTableColumnAlignment } from '@admin/utils/editor/set-active-table-column-alignment' import { showTablePartPreview } from '@admin/utils/editor/show-table-part-preview' import { cn } from '@admin/utils/shared/cn' import { BubbleMenu } from '@tiptap/react/menus' import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon, Columns3Icon, Rows3Icon } from 'lucide-react' import { RemoveTablePartIcon } from './remove-table-part-icon' const ALIGNMENT_CONTROLS: Array<{ alignment: ContentEditorTableAlignment Icon: React.ComponentType<{ className?: string }> label: string }> = [ { alignment: 'left', Icon: AlignLeftIcon, label: 'Align column left' }, { alignment: 'center', Icon: AlignCenterIcon, label: 'Align column center' }, { alignment: 'right', Icon: AlignRightIcon, label: 'Align column right' } ] export function TableBubbleMenu({ editor }: { editor: Editor }) { const { editor: currentEditor } = useTiptapEditor(editor) const tableEditor = currentEditor ?? editor const activeAlignment = getActiveTableColumnAlignment(tableEditor) const [isSelectedTableInView, setIsSelectedTableInView] = React.useState(false) const [isTypingInCell, setIsTypingInCell] = React.useState(false) const [, markEditorViewChanged] = React.useReducer((version: number) => version + 1, 0) const isTypingInCellRef = React.useRef(false) const isEditorViewMounted = Boolean(getMountedEditorView(editor)) const updateIsTypingInCell = React.useCallback((nextValue: boolean) => { if (isTypingInCellRef.current === nextValue) return isTypingInCellRef.current = nextValue setIsTypingInCell(nextValue) }, []) const updateSelectedTableVisibility = React.useCallback(() => { setIsSelectedTableInView(editor.isActive('table') && isSelectedTableVisible(editor)) }, [editor]) React.useEffect( () => () => { clearTablePartPreview(editor) }, [editor] ) React.useEffect(() => { const handleEditorViewChange = () => markEditorViewChanged() editor.on('mount', handleEditorViewChange) editor.on('unmount', handleEditorViewChange) if (getMountedEditorView(editor)) { handleEditorViewChange() } return () => { editor.off('mount', handleEditorViewChange) editor.off('unmount', handleEditorViewChange) } }, [editor]) React.useEffect(() => { if (!isEditorViewMounted) { setIsSelectedTableInView(false) return } const scrollElement = getEditorScrollElement(editor) const editorElement = getEditorElement(editor) if (!scrollElement || !editorElement) { setIsSelectedTableInView(false) return } const ownerDocument = editorElement.ownerDocument scrollElement.addEventListener('scroll', updateSelectedTableVisibility, { passive: true }) ownerDocument.addEventListener('scroll', updateSelectedTableVisibility, { capture: true, passive: true }) editor.on('selectionUpdate', updateSelectedTableVisibility) editor.on('transaction', updateSelectedTableVisibility) updateSelectedTableVisibility() return () => { scrollElement.removeEventListener('scroll', updateSelectedTableVisibility) ownerDocument.removeEventListener('scroll', updateSelectedTableVisibility, true) editor.off('selectionUpdate', updateSelectedTableVisibility) editor.off('transaction', updateSelectedTableVisibility) } }, [editor, isEditorViewMounted, updateSelectedTableVisibility]) React.useEffect(() => { if (!isEditorViewMounted) { updateIsTypingInCell(false) return } const editorElement = getEditorElement(editor) if (!editorElement) { updateIsTypingInCell(false) return } const suppressForCellInput = (event: Event) => { const cell = getEventTableCell(editor, event.target) if (!cell || !editorElement.contains(cell)) return clearTablePartPreview(editor) updateIsTypingInCell(true) } const showForCellClick = (event: Event) => { const cell = getClosestTableCell(event.target) if (!cell || !editorElement.contains(cell)) return updateIsTypingInCell(false) } editorElement.addEventListener('beforeinput', suppressForCellInput) editorElement.addEventListener('click', showForCellClick) editorElement.addEventListener('compositionstart', suppressForCellInput) editorElement.addEventListener('drop', suppressForCellInput) editorElement.addEventListener('input', suppressForCellInput) editorElement.addEventListener('mousedown', showForCellClick) editorElement.addEventListener('paste', suppressForCellInput) editorElement.addEventListener('pointerdown', showForCellClick) return () => { editorElement.removeEventListener('beforeinput', suppressForCellInput) editorElement.removeEventListener('click', showForCellClick) editorElement.removeEventListener('compositionstart', suppressForCellInput) editorElement.removeEventListener('drop', suppressForCellInput) editorElement.removeEventListener('input', suppressForCellInput) editorElement.removeEventListener('mousedown', showForCellClick) editorElement.removeEventListener('paste', suppressForCellInput) editorElement.removeEventListener('pointerdown', showForCellClick) } }, [editor, isEditorViewMounted, updateIsTypingInCell]) return ( isEditorViewMounted && isSelectedTableInView && ( nextEditor.isEditable && nextEditor.isFocused && nextEditor.isActive('table') && !isTypingInCell } options={{ flip: { padding: 8 }, offset: 8, placement: 'top', shift: { padding: 8 } }} >
{ALIGNMENT_CONTROLS.map(({ alignment, Icon, label }) => { const isActive = activeAlignment === alignment return ( ) })}
) ) }