import { useRef, useEffect, useState, useCallback, useContext } from 'react' import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, Code2, IndentDecrease, IndentIncrease, Italic, Underline, List, ListOrdered, ImagePlus, Palette, } from 'lucide-react' import type { DashboardLocale } from '@meshkee/dashboard-core' import { LocaleContext } from '../context/LocaleContext' import styles from './RichTextEditor.module.css' const INDENT_STEP_PX = 24 const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4']) const DEFAULT_TEXT_COLOR = '#0f172a' type TextAlign = 'left' | 'center' | 'right' | 'justify' const ALIGN_COMMAND: Record = { left: 'justifyLeft', center: 'justifyCenter', right: 'justifyRight', justify: 'justifyFull', } const FONT_SIZE_OPTIONS = [ { value: '12px', label: '12' }, { value: '14px', label: '14' }, { value: '16px', label: '16' }, { value: '20px', label: '20' }, { value: '28px', label: '28' }, ] as const type FontSizeValue = (typeof FONT_SIZE_OPTIONS)[number]['value'] const DEFAULT_FONT_SIZE: FontSizeValue = '14px' const FONT_SIZE_VALUES = new Set(FONT_SIZE_OPTIONS.map((option) => option.value)) function nearestFontSize(rawPx: number): FontSizeValue { let best: FontSizeValue = DEFAULT_FONT_SIZE let bestDelta = Number.POSITIVE_INFINITY for (const option of FONT_SIZE_OPTIONS) { const size = Number.parseFloat(option.value) const delta = Math.abs(size - rawPx) if (delta < bestDelta) { best = option.value bestDelta = delta } } return best } const SELECTED_IMAGE_CLASS = 'richTextImageSelected' const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/ /** Tags kept on paste (structure + bold). Everything else is unwrapped. */ const PASTE_KEEP_TAGS = new Set([ 'B', 'STRONG', 'UL', 'OL', 'LI', 'P', 'DIV', 'BR', 'SPAN', ]) const PASTE_DROP_TAGS = new Set([ 'SCRIPT', 'STYLE', 'META', 'LINK', 'TITLE', 'XML', 'HEAD', 'NOSCRIPT', ]) function parseCssFontSizePx(value: string): number | null { const trimmed = value.trim().toLowerCase() if (!trimmed) return null const px = trimmed.match(/^([\d.]+)\s*px$/) if (px) { const n = Number.parseFloat(px[1]) return Number.isFinite(n) ? n : null } const pt = trimmed.match(/^([\d.]+)\s*pt$/) if (pt) { const n = Number.parseFloat(pt[1]) // CSS: 1pt ≈ 1.333px at 96dpi return Number.isFinite(n) ? n * (96 / 72) : null } const em = trimmed.match(/^([\d.]+)\s*em$/) if (em) { const n = Number.parseFloat(em[1]) return Number.isFinite(n) ? n * 14 : null } return null } function isCssBold(weight: string): boolean { const w = weight.trim().toLowerCase() if (w === 'bold' || w === 'bolder') return true const n = Number.parseInt(w, 10) return Number.isFinite(n) && n >= 600 } /** * Keep bold, lists, and font-size on paste; strip bg, font-family, colors, etc. */ function sanitizePastedHtml(html: string): string { const template = document.createElement('template') template.innerHTML = html const source = template.content.querySelector('body') ?? template.content const out = document.createDocumentFragment() function appendChildren(from: ParentNode, to: ParentNode) { Array.from(from.childNodes).forEach((child) => { const cleaned = cleanNode(child) if (!cleaned) return if (cleaned instanceof DocumentFragment) { to.appendChild(cleaned) } else { to.appendChild(cleaned) } }) } function wrapBold(node: Node): HTMLElement { const strong = document.createElement('strong') strong.appendChild(node) return strong } function cleanNode(node: Node): Node | DocumentFragment | null { if (node.nodeType === Node.TEXT_NODE) { return document.createTextNode(node.textContent ?? '') } if (node.nodeType !== Node.ELEMENT_NODE) return null const el = node as HTMLElement const tag = el.tagName.toUpperCase() if (PASTE_DROP_TAGS.has(tag)) return null // Comments / Word junk like if (tag.includes(':')) { const frag = document.createDocumentFragment() appendChildren(el, frag) return frag.childNodes.length ? frag : null } const styleBold = isCssBold(el.style.fontWeight) const fontSizePx = parseCssFontSizePx(el.style.fontSize) const snappedSize = fontSizePx != null ? nearestFontSize(fontSizePx) : null // → keep as sized span let fontTagSize: FontSizeValue | null = null if (tag === 'FONT') { const sizeAttr = el.getAttribute('size') // HTML font size 1–7 → approximate px const map: Record = { '1': '12px', '2': '12px', '3': '14px', '4': '16px', '5': '20px', '6': '28px', '7': '28px', } if (sizeAttr && map[sizeAttr]) fontTagSize = map[sizeAttr] const faceSize = parseCssFontSizePx(el.style.fontSize) if (faceSize != null) fontTagSize = nearestFontSize(faceSize) } const sizeToApply = snappedSize ?? fontTagSize let keepTag: string | null = PASTE_KEEP_TAGS.has(tag) ? tag : null if (tag === 'B') keepTag = 'STRONG' if (tag === 'FONT' && sizeToApply) keepTag = 'SPAN' if (keepTag) { const clean = document.createElement(keepTag.toLowerCase()) if (sizeToApply) clean.style.fontSize = sizeToApply appendChildren(el, clean) // Drop empty style-less spans (e.g. leftover highlight wrappers). if (keepTag === 'SPAN' && !clean.style.cssText && !styleBold) { const frag = document.createDocumentFragment() while (clean.firstChild) frag.appendChild(clean.firstChild) return frag.childNodes.length ? frag : null } if (styleBold && keepTag !== 'STRONG') { const strong = document.createElement('strong') while (clean.firstChild) strong.appendChild(clean.firstChild) clean.appendChild(strong) } return clean } // Unwrap unknown tags, but preserve bold / font-size via wrappers const frag = document.createDocumentFragment() appendChildren(el, frag) let result: Node | DocumentFragment = frag if (sizeToApply) { const span = document.createElement('span') span.style.fontSize = sizeToApply span.appendChild(frag) result = span } if (styleBold) { result = wrapBold(result) } return result } appendChildren(source, out) const holder = document.createElement('div') holder.appendChild(out) // Drop empty Word paragraphs that are only   holder.querySelectorAll('p, div').forEach((block) => { const text = (block.textContent ?? '').replace(/\u00a0/g, ' ').trim() if (!text && !block.querySelector('ul, ol, li, br, img')) { block.remove() } }) return holder.innerHTML } function normalizeHexColor(value: string): string | null { const trimmed = value.trim() const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}` if (!HEX_COLOR_PATTERN.test(withHash)) return null if (withHash.length === 4) { const [, r, g, b] = withHash return `#${r}${r}${g}${g}${b}${b}`.toLowerCase() } return withHash.toLowerCase() } function toHexInputValue(color: string): string { return normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR } export interface RichTextEditorProps { value: string onChange: (value: string) => void placeholder?: string allowImages?: boolean /** Resolve uploaded image file to a public URL. Required when `allowImages` is true. */ onImageUpload?: (file: File) => Promise editorMinHeight?: number locale?: DashboardLocale dir?: 'ltr' | 'rtl' } export function RichTextEditor({ value, onChange, placeholder, allowImages = false, onImageUpload, editorMinHeight = 140, locale: localeProp, dir: dirProp, }: RichTextEditorProps) { const localeContext = useContext(LocaleContext) const locale = localeProp ?? localeContext?.locale ?? 'en' const dir = dirProp ?? localeContext?.dir ?? 'ltr' const editorShellRef = useRef(null) const editorRef = useRef(null) const fileInputRef = useRef(null) const colorPickerRef = useRef(null) const dragRef = useRef<{ startX: number; startWidth: number; img: HTMLImageElement } | null>( null, ) const [uploadingImage, setUploadingImage] = useState(false) const [uploadError, setUploadError] = useState('') const [selectedImageEl, setSelectedImageEl] = useState(null) const [handlePos, setHandlePos] = useState<{ top: number; left: number } | null>(null) const [textColor, setTextColor] = useState(DEFAULT_TEXT_COLOR) const [colorOpen, setColorOpen] = useState(false) const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR) const [currentAlign, setCurrentAlign] = useState(null) const [currentFontSize, setCurrentFontSize] = useState(DEFAULT_FONT_SIZE) const [codeMode, setCodeMode] = useState(false) const syncChange = useCallback(() => { if (editorRef.current) { onChange(editorRef.current.innerHTML) } }, [onChange]) const updateHandlePosition = useCallback((img: HTMLImageElement | null) => { if (!img || !editorShellRef.current) { setHandlePos(null) return } const shellRect = editorShellRef.current.getBoundingClientRect() const imgRect = img.getBoundingClientRect() setHandlePos({ top: imgRect.bottom - shellRect.top - 6, left: imgRect.right - shellRect.left - 6, }) }, []) const clearImageSelection = useCallback(() => { editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((img) => { img.classList.remove(SELECTED_IMAGE_CLASS) }) setSelectedImageEl(null) setHandlePos(null) }, []) const toggleCodeMode = useCallback(() => { setCodeMode((open) => { const next = !open if (next) { // Flush latest visual edits before showing the HTML source. if (editorRef.current) { onChange(editorRef.current.innerHTML) } clearImageSelection() setColorOpen(false) } return next }) }, [onChange, clearImageSelection]) const selectImage = useCallback( (img: HTMLImageElement) => { editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => { node.classList.remove(SELECTED_IMAGE_CLASS) }) img.classList.add(SELECTED_IMAGE_CLASS) setSelectedImageEl(img) updateHandlePosition(img) }, [updateHandlePosition], ) useEffect(() => { if (codeMode) return if (editorRef.current && editorRef.current.innerHTML !== value) { editorRef.current.innerHTML = value clearImageSelection() } }, [value, codeMode, clearImageSelection]) useEffect(() => { if (!allowImages) return const editor = editorRef.current if (!editor) return function onEditorClick(e: MouseEvent) { const target = e.target if (!(target instanceof HTMLImageElement) || !editor!.contains(target)) return e.preventDefault() selectImage(target) } function onDocumentClick(e: MouseEvent) { const target = e.target as Node if (editor!.contains(target)) return if (target instanceof Element && target.closest(`.${styles.resizeHandle}`)) return clearImageSelection() } editor.addEventListener('click', onEditorClick) document.addEventListener('click', onDocumentClick) return () => { editor.removeEventListener('click', onEditorClick) document.removeEventListener('click', onDocumentClick) } }, [allowImages, selectImage, clearImageSelection]) useEffect(() => { if (!selectedImageEl) return function refreshHandle() { if (selectedImageEl?.isConnected) { updateHandlePosition(selectedImageEl) } } window.addEventListener('resize', refreshHandle) editorRef.current?.addEventListener('scroll', refreshHandle) return () => { window.removeEventListener('resize', refreshHandle) editorRef.current?.removeEventListener('scroll', refreshHandle) } }, [selectedImageEl, updateHandlePosition]) useEffect(() => { if (!colorOpen) return function onDocumentClick(e: MouseEvent) { const target = e.target as Node if (colorPickerRef.current?.contains(target)) return setColorOpen(false) } document.addEventListener('mousedown', onDocumentClick) return () => document.removeEventListener('mousedown', onDocumentClick) }, [colorOpen]) function exec(cmd: string, arg?: string) { const editor = editorRef.current if (!editor) return editor.focus() document.execCommand(cmd, false, arg) syncChange() } function onToolbarMouseDown(e: React.MouseEvent) { // Keep the editor selection when clicking toolbar controls. e.preventDefault() } function findIndentBlock(editor: HTMLElement): HTMLElement | null { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return null let node: Node | null = selection.anchorNode while (node && node !== editor) { if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) { return node } node = node.parentNode } return null } function readBlockAlign(editor: HTMLElement): TextAlign | null { const block = findIndentBlock(editor) if (!block) return null const inline = block.style.textAlign.trim().toLowerCase() if (inline === 'left' || inline === 'center' || inline === 'right' || inline === 'justify') { return inline } const computed = getComputedStyle(block).textAlign.toLowerCase() if (computed === 'center') return 'center' if (computed === 'justify') return 'justify' if (computed === 'right') return 'right' if (computed === 'left') return 'left' const blockDir = (block.getAttribute('dir') || editor.getAttribute('dir') || 'ltr').toLowerCase() if (computed === 'start') return blockDir === 'rtl' ? 'right' : 'left' if (computed === 'end') return blockDir === 'rtl' ? 'left' : 'right' return null } function refreshAlignFromSelection() { const editor = editorRef.current if (!editor) return const selection = window.getSelection() if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return setCurrentAlign(readBlockAlign(editor)) setCurrentFontSize(readFontSize(editor)) } function readFontSize(editor: HTMLElement): FontSizeValue { const selection = window.getSelection() if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) { return DEFAULT_FONT_SIZE } let node: Node | null = selection.anchorNode if (node?.nodeType === Node.TEXT_NODE) node = node.parentNode while (node && node !== editor) { if (node instanceof HTMLElement) { const inline = node.style.fontSize.trim().toLowerCase() if (FONT_SIZE_VALUES.has(inline)) return inline as FontSizeValue if (inline.endsWith('px')) { const px = Number.parseFloat(inline) if (Number.isFinite(px)) return nearestFontSize(px) } } node = node.parentNode } const block = findIndentBlock(editor) if (block) { const computed = Number.parseFloat(getComputedStyle(block).fontSize) if (Number.isFinite(computed)) return nearestFontSize(computed) } return DEFAULT_FONT_SIZE } function applyFontSize(size: FontSizeValue) { const editor = editorRef.current if (!editor) return editor.focus() document.execCommand('styleWithCSS', false, 'true') document.execCommand('fontSize', false, '7') editor.querySelectorAll('font[size="7"]').forEach((font) => { const span = document.createElement('span') span.style.fontSize = size while (font.firstChild) span.appendChild(font.firstChild) font.replaceWith(span) }) editor.querySelectorAll('span').forEach((span) => { const style = span.getAttribute('style') ?? '' if ( /font-size:\s*(xx-large|xxx-large|-webkit-xxx-large)/i.test(style) || span.style.fontSize === 'xx-large' || span.style.fontSize === 'xxx-large' ) { span.style.fontSize = size } }) setCurrentFontSize(size) syncChange() } useEffect(() => { function onSelectionChange() { const editor = editorRef.current if (!editor) return const selection = window.getSelection() if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return setCurrentAlign(readBlockAlign(editor)) setCurrentFontSize(readFontSize(editor)) } document.addEventListener('selectionchange', onSelectionChange) return () => document.removeEventListener('selectionchange', onSelectionChange) }, []) function changeIndent(direction: 1 | -1) { const editor = editorRef.current if (!editor) return editor.focus() const block = findIndentBlock(editor) if (!block) { document.execCommand(direction > 0 ? 'indent' : 'outdent') syncChange() return } const current = Number.parseFloat(block.style.paddingInlineStart) || Number.parseFloat(getComputedStyle(block).paddingInlineStart) || 0 const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX)) if (next === 0) { block.style.paddingInlineStart = '' } else { block.style.paddingInlineStart = `${next}px` } syncChange() } function setBlockDirection(direction: 'rtl' | 'ltr') { const editor = editorRef.current if (!editor) return editor.focus() const block = findIndentBlock(editor) if (block) { block.setAttribute('dir', direction) syncChange() return } document.execCommand('dir', false, direction) syncChange() } function setBlockAlign(align: TextAlign) { const editor = editorRef.current if (!editor) return editor.focus() document.execCommand(ALIGN_COMMAND[align], false) const block = findIndentBlock(editor) if (block) { block.style.textAlign = align block.removeAttribute('align') } setCurrentAlign(align) syncChange() } function applyTextColor(color: string) { const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR setTextColor(normalized) setHexInput(normalized) document.execCommand('styleWithCSS', false, 'true') exec('foreColor', normalized) } function toggleColorPicker() { setColorOpen((open) => { const nextOpen = !open if (nextOpen) { setHexInput(toHexInputValue(textColor)) } return nextOpen }) } function commitHexInput(rawValue?: string) { const normalized = normalizeHexColor(rawValue ?? hexInput) if (!normalized) { setHexInput(toHexInputValue(textColor)) return } applyTextColor(normalized) } function onHexInputKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter') { e.preventDefault() commitHexInput() editorRef.current?.focus() } if (e.key === 'Escape') { e.preventDefault() setHexInput(toHexInputValue(textColor)) setColorOpen(false) } } function onEditorKeyDown(e: React.KeyboardEvent) { if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return e.preventDefault() changeIndent(e.shiftKey ? -1 : 1) } /** * Paste cleaned HTML: keep bold, lists, and font-size; * strip background, font-family, colors, and other text properties. */ function onEditorPaste(e: React.ClipboardEvent) { e.preventDefault() const editor = editorRef.current if (!editor) return const html = e.clipboardData.getData('text/html') const plain = e.clipboardData.getData('text/plain') const cleaned = html ? sanitizePastedHtml(html) : '' editor.focus() if (cleaned.trim()) { document.execCommand('insertHTML', false, cleaned) syncChange() return } if (!plain) return const inserted = document.execCommand('insertText', false, plain) if (!inserted) { const selection = window.getSelection() if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0) range.deleteContents() range.insertNode(document.createTextNode(plain)) range.collapse(false) selection.removeAllRanges() selection.addRange(range) } } syncChange() } function styleInsertedImage(img: HTMLImageElement) { img.style.width = '100%' img.style.maxWidth = '100%' img.style.height = 'auto' img.draggable = false } async function handleImageSelect(e: React.ChangeEvent) { const file = e.target.files?.[0] e.target.value = '' if (!file || !editorRef.current || !onImageUpload) return setUploadingImage(true) setUploadError('') try { const url = await onImageUpload(file) if (!url) throw new Error('Image upload failed.') editorRef.current.focus() document.execCommand('insertImage', false, url) const imgs = editorRef.current.querySelectorAll('img') const lastImg = imgs[imgs.length - 1] if (lastImg instanceof HTMLImageElement) { styleInsertedImage(lastImg) selectImage(lastImg) } syncChange() } catch (err) { setUploadError(err instanceof Error ? err.message : 'Unable to upload image.') } finally { setUploadingImage(false) } } function onResizeStart(e: React.MouseEvent) { if (!selectedImageEl || !editorRef.current) return e.preventDefault() e.stopPropagation() dragRef.current = { startX: e.clientX, startWidth: selectedImageEl.offsetWidth, img: selectedImageEl, } function onMove(ev: MouseEvent) { if (!dragRef.current || !editorRef.current) return const delta = ev.clientX - dragRef.current.startX const maxWidth = editorRef.current.clientWidth - 16 const nextWidth = Math.max(80, Math.min(maxWidth, dragRef.current.startWidth + delta)) dragRef.current.img.style.width = `${nextWidth}px` dragRef.current.img.style.height = 'auto' dragRef.current.img.style.maxWidth = '100%' updateHandlePosition(dragRef.current.img) } function onUp() { dragRef.current = null syncChange() document.removeEventListener('mousemove', onMove) document.removeEventListener('mouseup', onUp) } document.addEventListener('mousemove', onMove) document.addEventListener('mouseup', onUp) } return (
{colorOpen && !codeMode && (
applyTextColor(e.target.value)} aria-label="Pick color" />
)}
{allowImages && onImageUpload && ( <> )}
{uploadError && (

{uploadError}

)}
{codeMode && (