import { useRef, useState, useEffect, useCallback } from 'react'; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export interface ActiveFormats { bold: boolean; italic: boolean; underline: boolean; justifyLeft: boolean; justifyCenter: boolean; justifyRight: boolean; insertUnorderedList: boolean; insertOrderedList: boolean; link: boolean; h1: boolean; h2: boolean; h3: boolean; p: boolean; } export interface UseRichTextEditorProps { /** Current HTML value of the editor */ value: string; /** Called whenever the editor content changes */ onChange?: (value: string) => void; } export interface UseRichTextEditorReturn { // ── Refs ────────────────────────────────────────────────────────────────── /** Attach to the `contenteditable` div */ editorRef: React.RefObject; /** Attach to the search `` */ searchInputRef: React.RefObject; /** Attach to the link URL `` */ linkInputRef: React.RefObject; // ── State ───────────────────────────────────────────────────────────────── /** Map of currently active formatting commands */ activeFormats: ActiveFormats; /** Whether the inline search bar is open */ isSearchOpen: boolean; setIsSearchOpen: (open: boolean) => void; /** Current search query string */ searchQuery: string; setSearchQuery: (query: string) => void; /** Current link URL being edited */ linkUrl: string; setLinkUrl: (url: string) => void; /** Whether the link popover is open */ isLinkOpen: boolean; /** * True when the popover was opened with a non-collapsed text selection * (or with the cursor inside an existing link). Use this to decide whether * to show the link-insert form vs. the "select text first" hint. */ hasSavedSelection: boolean; // ── Computed ────────────────────────────────────────────────────────────── /** Word count of the editor content */ wordCount: number; /** Character count of the editor content */ characterCount: number; // ── Handlers ────────────────────────────────────────────────────────────── /** Re-read the current selection and update `activeFormats` */ updateActiveFormats: () => void; /** Execute a `document.execCommand` and sync state */ execCommand: (command: string, value?: string) => void; /** Called on every `input` event of the editor */ handleInput: () => void; /** Search forward or backward for `searchQuery` */ performSearch: (text: string, backward?: boolean) => void; /** Create or update a hyperlink at the current selection */ handleCreateLink: () => void; /** Remove the hyperlink at the current cursor position */ handleUnlink: () => void; /** Handle link popover open/close (saves selection, pre-fills URL) */ onLinkPopoverOpenChange: (open: boolean) => void; } // ───────────────────────────────────────────────────────────────────────────── // Hook // ───────────────────────────────────────────────────────────────────────────── /** * `useRichTextEditor` — Headless hook for the RichTextEditor component. * * Encapsulates all state, refs, and DOM-manipulation logic for a * `contenteditable`-based rich text editor. Use this hook when you need to * build a fully custom editor UI while reusing the same formatting logic as * the default `RichTextEditor` component. * * @example * ```tsx * import { useRichTextEditor } from 'xertica-ui/ui'; * * function MyEditor({ value, onChange }) { * const { * editorRef, * activeFormats, * execCommand, * handleInput, * wordCount, * } = useRichTextEditor({ value, onChange }); * * return ( *
* *
* {wordCount} words *
* ); * } * ``` */ export function useRichTextEditor({ value, onChange, }: UseRichTextEditorProps): UseRichTextEditorReturn { // ── Refs ──────────────────────────────────────────────────────────────────── const editorRef = useRef(null); const searchInputRef = useRef(null); const linkInputRef = useRef(null); const savedSelection = useRef(null); // ── State ─────────────────────────────────────────────────────────────────── const [wordCount, setWordCount] = useState(0); const [characterCount, setCharacterCount] = useState(0); const [activeFormats, setActiveFormats] = useState({ bold: false, italic: false, underline: false, justifyLeft: false, justifyCenter: false, justifyRight: false, insertUnorderedList: false, insertOrderedList: false, link: false, h1: false, h2: false, h3: false, p: true, }); const [isSearchOpen, setIsSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [linkUrl, setLinkUrl] = useState('https://'); const [isLinkOpen, setIsLinkOpen] = useState(false); const [hasSavedSelection, setHasSavedSelection] = useState(false); // ── Helpers ───────────────────────────────────────────────────────────────── const findParentTag = useCallback((node: Node | null, tagName: string): HTMLElement | null => { let current = node; while (current && current !== editorRef.current) { if (current.nodeName === tagName) return current as HTMLElement; current = current.parentNode; } return null; }, []); const updateActiveFormats = useCallback(() => { let formatBlock = ''; try { formatBlock = document.queryCommandValue('formatBlock'); } catch (_e) { /* ignore */ } const selection = window.getSelection(); const anchorNode = selection?.anchorNode; const focusNode = selection?.focusNode; setActiveFormats({ bold: document.queryCommandState('bold'), italic: document.queryCommandState('italic'), underline: document.queryCommandState('underline'), justifyLeft: document.queryCommandState('justifyLeft'), justifyCenter: document.queryCommandState('justifyCenter'), justifyRight: document.queryCommandState('justifyRight'), insertUnorderedList: document.queryCommandState('insertUnorderedList'), insertOrderedList: document.queryCommandState('insertOrderedList'), link: !!(findParentTag(anchorNode || null, 'A') || findParentTag(focusNode || null, 'A')), h1: formatBlock === 'h1' || formatBlock === 'H1', h2: formatBlock === 'h2' || formatBlock === 'H2', h3: formatBlock === 'h3' || formatBlock === 'H3', p: formatBlock === 'p' || formatBlock === 'P' || formatBlock === 'div' || formatBlock === 'DIV' || formatBlock === '', }); }, [findParentTag]); // ── Effects ───────────────────────────────────────────────────────────────── // 1. Mantém a ref sempre atualizada (sem re-registro do listener) const updateActiveFormatsRef = useRef(updateActiveFormats); useEffect(() => { updateActiveFormatsRef.current = updateActiveFormats; }, [updateActiveFormats]); // 2. Mount-only: inicializa conteúdo e registra listener via ref useEffect(() => { if (editorRef.current && editorRef.current.innerHTML !== value) { editorRef.current.innerHTML = value; } const handleSelectionChange = () => { if (document.activeElement === editorRef.current) { updateActiveFormatsRef.current(); } }; document.addEventListener('selectionchange', handleSelectionChange); return () => document.removeEventListener('selectionchange', handleSelectionChange); }, []); // mount-only — usa ref para evitar re-registro // Sincronizar contadores quando `value` muda externamente useEffect(() => { const text = editorRef.current?.innerText || ''; setWordCount(text.trim() ? text.trim().split(/\s+/).length : 0); setCharacterCount(text.trim() ? text.length : 0); }, [value]); // ── Handlers ──────────────────────────────────────────────────────────────── const handleInput = useCallback(() => { if (editorRef.current) { onChange?.(editorRef.current.innerHTML); updateActiveFormats(); const text = editorRef.current.innerText || ''; setWordCount(text.trim() ? text.trim().split(/\s+/).length : 0); setCharacterCount(text.trim() ? text.length : 0); } }, [onChange, updateActiveFormats]); const execCommand = useCallback( (command: string, val: string = '') => { document.execCommand(command, false, val); updateActiveFormats(); editorRef.current?.focus(); if (editorRef.current) { onChange?.(editorRef.current.innerHTML); } }, [onChange, updateActiveFormats] ); const performSearch = useCallback((text: string, backward = false) => { if (!text || !editorRef.current) return; const editor = editorRef.current; const selection = window.getSelection(); if (!selection) return; const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); const segments: Array<{ node: Text; start: number }> = []; let offset = 0; let node: Node | null; while ((node = walker.nextNode())) { segments.push({ node: node as Text, start: offset }); offset += (node as Text).length; } const fullText = segments.reduce((acc, s) => acc + (s.node.textContent ?? ''), ''); let searchFrom = 0; if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); if (editor.contains(range.startContainer)) { const seg = segments.find(s => s.node === range.startContainer); if (seg) { searchFrom = backward ? seg.start + range.startOffset - 1 : seg.start + range.endOffset; } } } const lowerFull = fullText.toLowerCase(); const lowerQuery = text.toLowerCase(); let matchStart = -1; if (backward) { matchStart = lowerFull.lastIndexOf(lowerQuery, Math.max(0, searchFrom)); if (matchStart === -1) matchStart = lowerFull.lastIndexOf(lowerQuery); } else { matchStart = lowerFull.indexOf(lowerQuery, searchFrom); if (matchStart === -1) matchStart = lowerFull.indexOf(lowerQuery); } if (matchStart === -1) return; const matchEnd = matchStart + text.length; const range = document.createRange(); let startSet = false; let endSet = false; for (let i = 0; i < segments.length && !endSet; i++) { const seg = segments[i]; const segEnd = seg.start + seg.node.length; if (!startSet && matchStart < segEnd && matchStart >= seg.start) { range.setStart(seg.node, matchStart - seg.start); startSet = true; } if (startSet && matchEnd <= segEnd) { range.setEnd(seg.node, matchEnd - seg.start); endSet = true; } } if (startSet && endSet) { selection.removeAllRanges(); selection.addRange(range); (range.startContainer as Element).parentElement?.scrollIntoView?.({ block: 'nearest' }); } }, []); const handleCreateLink = useCallback(() => { if (savedSelection.current) { const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(savedSelection.current); } const selection = window.getSelection(); const anchorNode = selection?.anchorNode; const existingLink = findParentTag(anchorNode || null, 'A'); if (existingLink) { if (linkUrl) { existingLink.setAttribute('href', linkUrl); existingLink.setAttribute('target', '_blank'); existingLink.setAttribute('rel', 'noopener noreferrer'); existingLink.style.color = 'hsl(var(--primary))'; existingLink.style.textDecoration = 'underline'; existingLink.style.cursor = 'pointer'; } handleInput(); setIsLinkOpen(false); savedSelection.current = null; return; } if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return; } if (linkUrl) { document.execCommand('createLink', false, linkUrl); setTimeout(() => { const anchor = findParentTag(window.getSelection()?.anchorNode || null, 'A'); if (anchor) { anchor.setAttribute('target', '_blank'); anchor.setAttribute('rel', 'noopener noreferrer'); anchor.style.color = 'hsl(var(--primary))'; anchor.style.textDecoration = 'underline'; anchor.style.cursor = 'pointer'; } handleInput(); }, 10); setIsLinkOpen(false); savedSelection.current = null; } }, [linkUrl, findParentTag, handleInput]); const handleUnlink = useCallback(() => { const selection = window.getSelection(); const anchorNode = selection?.anchorNode; const existingLink = findParentTag(anchorNode || null, 'A'); if (existingLink) { const parent = existingLink.parentNode; while (existingLink.firstChild) { parent?.insertBefore(existingLink.firstChild, existingLink); } parent?.removeChild(existingLink); handleInput(); } else { document.execCommand('unlink', false, ''); } }, [findParentTag, handleInput]); const onLinkPopoverOpenChange = useCallback( (open: boolean) => { if (open) { const selection = window.getSelection(); const anchorNode = selection?.anchorNode; const focusNode = selection?.focusNode; const existingLink = findParentTag(anchorNode || null, 'A') || findParentTag(focusNode || null, 'A'); if (existingLink) { setLinkUrl(existingLink.getAttribute('href') || 'https://'); } else { setLinkUrl('https://'); } // Save the selection whenever there is one (collapsed or not) so // handleCreateLink can restore it after the popover steals focus. // Also mark hasSavedSelection so the UI can show the insert form. if (selection && selection.rangeCount > 0 && (!selection.isCollapsed || existingLink)) { savedSelection.current = selection.getRangeAt(0).cloneRange(); setHasSavedSelection(true); } else { savedSelection.current = null; setHasSavedSelection(false); } setTimeout(() => linkInputRef.current?.focus(), 100); } else { setHasSavedSelection(false); } setIsLinkOpen(open); }, [findParentTag] ); return { // Refs editorRef, searchInputRef, linkInputRef, // State activeFormats, isSearchOpen, setIsSearchOpen, searchQuery, setSearchQuery, linkUrl, setLinkUrl, isLinkOpen, hasSavedSelection, // Computed wordCount, characterCount, // Handlers updateActiveFormats, execCommand, handleInput, performSearch, handleCreateLink, handleUnlink, onLinkPopoverOpenChange, }; }