'use client' import type { Editor } from '@tiptap/react' import { useTiptapEditor } from '@admin/hooks/content-editor/use-tiptap-editor' import { findNodePosition } from '@admin/utils/editor/find-node-position' import { getSelectedBlockNodes } from '@admin/utils/editor/get-selected-block-nodes' import { isNodeInSchema } from '@admin/utils/editor/is-node-in-schema' import { isNodeTypeSelected } from '@admin/utils/editor/is-node-type-selected' import { isValidPosition } from '@admin/utils/editor/is-valid-position' import { selectionWithinConvertibleTypes } from '@admin/utils/editor/selection-within-convertible-types' import { NodeSelection, TextSelection } from '@tiptap/pm/state' import { Heading5Icon as HeadingFiveIcon, Heading4Icon as HeadingFourIcon, Heading1Icon as HeadingOneIcon, Heading6Icon as HeadingSixIcon, Heading3Icon as HeadingThreeIcon, Heading2Icon as HeadingTwoIcon } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' export type Level = 1 | 2 | 3 | 4 | 5 | 6 export interface UseHeadingConfig { editor?: Editor | null level: Level hideWhenUnavailable?: boolean onToggled?: () => void } export const headingIcons = { 1: HeadingOneIcon, 2: HeadingTwoIcon, 3: HeadingThreeIcon, 4: HeadingFourIcon, 5: HeadingFiveIcon, 6: HeadingSixIcon } export const HEADING_SHORTCUT_KEYS: Record = { 1: 'ctrl+alt+1', 2: 'ctrl+alt+2', 3: 'ctrl+alt+3', 4: 'ctrl+alt+4', 5: 'ctrl+alt+5', 6: 'ctrl+alt+6' } export function canToggle(editor: Editor | null, level?: Level, turnInto: boolean = true): boolean { if (!editor || !editor.isEditable) return false if (!isNodeInSchema('heading', editor) || isNodeTypeSelected(editor, ['image'])) return false if (!turnInto) { return level ? editor.can().setNode('heading', { level }) : editor.can().setNode('heading') } if ( !selectionWithinConvertibleTypes(editor, [ 'paragraph', 'heading', 'bulletList', 'orderedList', 'taskList', 'blockquote', 'codeBlock' ]) ) return false return level ? editor.can().setNode('heading', { level }) || editor.can().clearNodes() : editor.can().setNode('heading') || editor.can().clearNodes() } export function isHeadingActive(editor: Editor | null, level?: Level | Level[]): boolean { if (!editor || !editor.isEditable) return false if (Array.isArray(level)) { return level.some((l) => editor.isActive('heading', { level: l })) } return level ? editor.isActive('heading', { level }) : editor.isActive('heading') } export function toggleHeading(editor: Editor | null, level: Level | Level[]): boolean { if (!editor || !editor.isEditable) return false const levels = Array.isArray(level) ? level : [level] const toggleLevel = levels.find((l) => canToggle(editor, l)) if (!toggleLevel) return false try { const view = editor.view let state = view.state let tr = state.tr const blocks = getSelectedBlockNodes(editor) const isPossibleToTurnInto = selectionWithinConvertibleTypes(editor, [ 'paragraph', 'heading', 'bulletList', 'orderedList', 'taskList', 'blockquote', 'codeBlock' ]) && blocks.length === 1 if ( (state.selection.empty || state.selection instanceof TextSelection) && isPossibleToTurnInto ) { const pos = findNodePosition({ editor, node: state.selection.$anchor.node(1) })?.pos if (!isValidPosition(pos)) return false tr = tr.setSelection(NodeSelection.create(state.doc, pos)) view.dispatch(tr) state = view.state } const selection = state.selection let chain = editor.chain().focus() if (selection instanceof NodeSelection) { const firstChild = selection.node.firstChild?.firstChild const lastChild = selection.node.lastChild?.lastChild const from = firstChild ? selection.from + firstChild.nodeSize : selection.from + 1 const to = lastChild ? selection.to - lastChild.nodeSize : selection.to - 1 const resolvedFrom = state.doc.resolve(from) const resolvedTo = state.doc.resolve(to) chain = chain.setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)).clearNodes() } const isActive = levels.some((l) => editor.isActive('heading', { level: l })) const toggle = isActive ? chain.setNode('paragraph') : chain.setNode('heading', { level: toggleLevel }) toggle.run() editor.chain().focus().selectTextblockEnd().run() return true } catch { return false } } export function shouldShowButton(props: { editor: Editor | null level?: Level | Level[] hideWhenUnavailable: boolean }): boolean { const { editor, level, hideWhenUnavailable } = props if (!editor || !editor.isEditable) return false if (!hideWhenUnavailable) { return true } if (!isNodeInSchema('heading', editor)) return false if (!editor.isActive('code')) { if (Array.isArray(level)) { return level.some((l) => canToggle(editor, l)) } return canToggle(editor, level) } return true } export function useHeading(config: UseHeadingConfig) { const { editor: providedEditor, level, hideWhenUnavailable = false, onToggled } = config const { editor } = useTiptapEditor(providedEditor) const [isVisible, setIsVisible] = useState(true) const canToggleState = canToggle(editor, level) const isActive = isHeadingActive(editor, level) useEffect(() => { if (!editor) return const handleSelectionUpdate = () => { setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable })) } handleSelectionUpdate() editor.on('selectionUpdate', handleSelectionUpdate) return () => { editor.off('selectionUpdate', handleSelectionUpdate) } }, [editor, level, hideWhenUnavailable]) const handleToggle = useCallback(() => { if (!editor) return false const success = toggleHeading(editor, level) if (success) { onToggled?.() } return success }, [editor, level, onToggled]) return { isVisible, isActive, handleToggle, canToggle: canToggleState, label: `Heading ${level}`, shortcutKeys: HEADING_SHORTCUT_KEYS[level], Icon: headingIcons[level] } }