'use client' import * as React from 'react' import type { ContentEditorMode } from '@admin/utils/editor/content-editor-mode' import type { ContentEditorSelectionRequest } from '@admin/utils/editor/content-editor-selection-request' import type { Editor } from '@tiptap/react' import { NodeBackground } from '@admin/components/custom/content-editor/tiptap-extension/node-background-extension' import { HorizontalRule } from '@admin/components/custom/content-editor/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension' import { MediaGalleryPlaceholderNode } from '@admin/components/custom/content-editor/tiptap-node/media-gallery-placeholder-node/media-gallery-placeholder-node-extension' import { RemovableImage } from '@admin/components/custom/content-editor/tiptap-node/removable-image-node/removable-image-node-extension' import { ContentEditorTaskItem } from '@admin/components/custom/content-editor/tiptap-node/task-item-node/task-item-node-extension' import { ActiveTableCell } from '@admin/utils/editor/active-table-cell' import { ContentEditorHighlight } from '@admin/utils/editor/content-editor-highlight' import { ContentEditorInlineMath } from '@admin/utils/editor/content-editor-inline-math' import { ContentEditorKeyboard } from '@admin/utils/editor/content-editor-keyboard' import { ContentEditorRawContentBlock } from '@admin/utils/editor/content-editor-raw-content-block' import { ContentEditorSubscript } from '@admin/utils/editor/content-editor-subscript' import { ContentEditorSuperscript } from '@admin/utils/editor/content-editor-superscript' import { ContentEditorUnderline } from '@admin/utils/editor/content-editor-underline' import { docPositionFromPlainTextOffset } from '@admin/utils/editor/doc-position-from-plain-text-offset' import { getPlainTextOffsetBeforeDocPosition } from '@admin/utils/editor/get-plain-text-offset-before-doc-position' import { getPlainTextOffsetBeforeMarkdownOffset } from '@admin/utils/editor/get-plain-text-offset-before-markdown-offset' import { markdownOffsetFromPlainTextOffset } from '@admin/utils/editor/markdown-offset-from-plain-text-offset' import { normalizeEditorMarkdown } from '@admin/utils/editor/normalize-editor-markdown' import { prepareMarkdownForRichMode } from '@admin/utils/editor/prepare-markdown-for-rich-mode' import { TaskList } from '@tiptap/extension-list' import Placeholder from '@tiptap/extension-placeholder' import { Table } from '@tiptap/extension-table' import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' import { Typography } from '@tiptap/extension-typography' import { Selection } from '@tiptap/extensions' import { Markdown } from '@tiptap/markdown' import { useEditor } from '@tiptap/react' import { StarterKit } from '@tiptap/starter-kit' import { useContentEditorSlashMenu } from './use-content-editor-slash-menu' export interface UseContentEditorOptions { value: string onChange?: (value: string) => void placeholder?: string disabled: boolean defaultMode: ContentEditorMode } export function useContentEditor({ value, onChange, placeholder, disabled, defaultMode }: UseContentEditorOptions) { const [mode, setMode] = React.useState(defaultMode) const [sourceSelectionRequest, setSourceSelectionRequest] = React.useState(null) const latestMarkdownRef = React.useRef(value) const lastValuePropRef = React.useRef(value) const pendingRichSelectionOffsetRef = React.useRef(null) const sourceSelectionRef = React.useRef(0) const isInternalChange = React.useRef(false) const isSyncingRichContent = React.useRef(false) const editorRef = React.useRef(null) const isSlashMenuOpenRef = React.useRef(false) const { activeSlashIndex, closeSlashMenu, filteredSlashCommands, handleSlashKeyDown, selectSlashCommand, setActiveSlashIndex, setSlashSearch, slashMenu, slashSearchQuery, updateSlashMenu } = useContentEditorSlashMenu(editorRef) isSlashMenuOpenRef.current = slashMenu !== null if (value !== lastValuePropRef.current) { lastValuePropRef.current = value latestMarkdownRef.current = value } const emitChange = React.useCallback( (nextValue: string) => { latestMarkdownRef.current = nextValue onChange?.(nextValue) }, [onChange] ) const editor = useEditor({ immediatelyRender: false, editorProps: { attributes: { autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', class: 'simple-editor content-editor-prose prose prose-sm max-w-none focus:outline-none' }, handleKeyDown: (_view, event) => handleSlashKeyDown(event) }, extensions: [ StarterKit.configure({ horizontalRule: false, underline: false, link: { openOnClick: false, enableClickSelection: true } }), HorizontalRule, TaskList, ContentEditorTaskItem.configure({ nested: true }), ContentEditorHighlight.configure({ multicolor: true }), ContentEditorUnderline, ContentEditorSubscript, ContentEditorSuperscript, ContentEditorKeyboard, ContentEditorInlineMath, ContentEditorRawContentBlock, RemovableImage, Table.configure({ cellMinWidth: 96, resizable: true }), TableRow, TableHeader, TableCell, ActiveTableCell, Typography, Selection, NodeBackground, Placeholder.configure({ placeholder: placeholder || "Write, type '/' for commands..." }), Markdown.configure({ markedOptions: { gfm: true } }), MediaGalleryPlaceholderNode.configure({ accept: 'image/*' }) ], content: prepareMarkdownForRichMode(value), contentType: 'markdown', editable: !disabled, onUpdate: ({ editor: nextEditor, transaction }) => { if (isSyncingRichContent.current || transaction.getMeta('preventUpdate')) { updateSlashMenu(nextEditor) return } const nextValue = normalizeEditorMarkdown(nextEditor.getMarkdown()) isInternalChange.current = true emitChange(nextValue) updateSlashMenu(nextEditor) }, onSelectionUpdate: ({ editor: nextEditor }) => updateSlashMenu(nextEditor), onBlur: ({ event }) => { const nextFocusTarget = event.relatedTarget if (isSlashMenuOpenRef.current && !nextFocusTarget) return if ( nextFocusTarget instanceof Element && nextFocusTarget.closest('[data-content-editor-slash-menu]') ) { return } closeSlashMenu() } }) editorRef.current = editor const syncRichContent = React.useCallback( (nextValue: string = latestMarkdownRef.current, selectionOffset?: number) => { if (!editor) return null const normalizedMarkdown = normalizeEditorMarkdown(editor.getMarkdown()) if (normalizedMarkdown !== nextValue) { isSyncingRichContent.current = true try { editor.commands.setContent(prepareMarkdownForRichMode(nextValue), { contentType: 'markdown', emitUpdate: false }) } finally { queueMicrotask(() => { isSyncingRichContent.current = false }) } } if (selectionOffset !== undefined) { const plainTextOffset = getPlainTextOffsetBeforeMarkdownOffset(nextValue, selectionOffset) const position = docPositionFromPlainTextOffset(editor.state.doc, plainTextOffset) editor.commands.setTextSelection(position) return position } return null }, [editor] ) React.useEffect(() => { if (!editor || mode === 'source') return if (isInternalChange.current) { isInternalChange.current = false return } syncRichContent(latestMarkdownRef.current) }, [editor, mode, syncRichContent]) React.useEffect(() => { editor?.setEditable(!disabled, false) }, [disabled, editor]) React.useEffect(() => { if (!editor || mode !== 'rich') return const selectionOffset = pendingRichSelectionOffsetRef.current if (selectionOffset === null) return pendingRichSelectionOffsetRef.current = null const animationFrame = requestAnimationFrame(() => { const position = syncRichContent(latestMarkdownRef.current, selectionOffset) if (position !== null) { editor.commands.focus(position, { scrollIntoView: true }) return } editor.commands.focus(undefined, { scrollIntoView: true }) }) return () => cancelAnimationFrame(animationFrame) }, [editor, mode, syncRichContent]) const handleSourceSelectionChange = React.useCallback((offset: number) => { sourceSelectionRef.current = offset }, []) const handleSourceChange = React.useCallback( (nextValue: string) => { emitChange(nextValue) }, [emitChange] ) const handleModeChange = React.useCallback(() => { if (mode === 'source') { pendingRichSelectionOffsetRef.current = sourceSelectionRef.current setMode('rich') return } if (editor) { const plainTextOffset = getPlainTextOffsetBeforeDocPosition( editor.state.doc, editor.state.selection.from ) const mappedOffset = markdownOffsetFromPlainTextOffset( latestMarkdownRef.current, plainTextOffset ) sourceSelectionRef.current = mappedOffset setSourceSelectionRequest((current) => ({ id: (current?.id ?? 0) + 1, offset: mappedOffset })) } setMode('source') }, [editor, mode]) return { activeSlashIndex, editor, filteredSlashCommands, handleModeChange, handleSourceChange, handleSourceSelectionChange, mode, closeSlashMenu, selectSlashCommand, setActiveSlashIndex, setSlashSearch, slashMenu, slashSearchQuery, sourceValue: latestMarkdownRef.current, sourceSelectionRequest, syncRichContent } }