Files
dashboards/packages/dashboard-ui/src/components/RichTextEditor.tsx
T
Alireza HassaniandCursor ac287e02cc Sanitize rich-text paste and allow empty business postal codes.
Strip background/font-family/colors on paste while keeping bold, lists, and font size; treat business address postal code as optional.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 18:42:21 +03:30

1080 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<TextAlign, string> = {
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<string>(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 <o:p>
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
// <font size="…"> → 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<string, FontSizeValue> = {
'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 &nbsp;
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<string>
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<HTMLDivElement>(null)
const editorRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const colorPickerRef = useRef<HTMLDivElement>(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<HTMLImageElement | null>(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<TextAlign | null>(null)
const [currentFontSize, setCurrentFontSize] = useState<FontSizeValue>(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<HTMLInputElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLInputElement>) {
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 (
<div className={styles.wrapper} dir={dir} lang={locale}>
<div className={styles.toolbar} dir="ltr">
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('bold')}
disabled={codeMode}
title="Bold"
aria-label="Bold"
>
<Bold size={16} />
</button>
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('italic')}
disabled={codeMode}
title="Italic"
aria-label="Italic"
>
<Italic size={16} />
</button>
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('underline')}
disabled={codeMode}
title="Underline"
aria-label="Underline"
>
<Underline size={16} />
</button>
<span className={styles.divider} />
<label className={styles.fontSizeWrap}>
<span className={styles.srOnly}>Font size</span>
<select
className={styles.fontSizeSelect}
value={currentFontSize}
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => applyFontSize(e.target.value as FontSizeValue)}
disabled={codeMode}
title="Font size"
aria-label="Font size"
>
{FONT_SIZE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<span className={styles.divider} />
<div ref={colorPickerRef} className={styles.colorPickerWrap}>
<button
type="button"
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
onMouseDown={onToolbarMouseDown}
onClick={toggleColorPicker}
disabled={codeMode}
title="Text color"
aria-label="Text color"
aria-expanded={colorOpen}
aria-haspopup="dialog"
>
<Palette size={16} />
<span className={styles.colorSwatch} style={{ background: textColor }} aria-hidden="true" />
</button>
{colorOpen && !codeMode && (
<div className={styles.colorPopover} role="dialog" aria-label="Text color">
<input
type="color"
className={styles.colorVisualInput}
value={textColor}
onChange={(e) => applyTextColor(e.target.value)}
aria-label="Pick color"
/>
<label className={styles.hexField}>
<span className={styles.hexLabel}>HEX</span>
<input
type="text"
className={styles.hexInput}
value={hexInput}
onChange={(e) => setHexInput(e.target.value)}
onBlur={() => commitHexInput()}
onKeyDown={onHexInputKeyDown}
spellCheck={false}
autoComplete="off"
dir="ltr"
aria-label="Hex color"
/>
</label>
</div>
)}
</div>
<span className={styles.divider} />
<button
type="button"
className={styles.dirBtn}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockDirection('rtl')}
disabled={codeMode}
title="Right to left"
aria-label="Right to left"
>
RTL
</button>
<button
type="button"
className={styles.dirBtn}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockDirection('ltr')}
disabled={codeMode}
title="Left to right"
aria-label="Left to right"
>
LTR
</button>
<span className={styles.divider} />
<button
type="button"
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('left')}
disabled={codeMode}
title="Align left"
aria-label="Align left"
aria-pressed={currentAlign === 'left'}
>
<AlignLeft size={16} />
</button>
<button
type="button"
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('center')}
disabled={codeMode}
title="Align center"
aria-label="Align center"
aria-pressed={currentAlign === 'center'}
>
<AlignCenter size={16} />
</button>
<button
type="button"
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('right')}
disabled={codeMode}
title="Align right"
aria-label="Align right"
aria-pressed={currentAlign === 'right'}
>
<AlignRight size={16} />
</button>
<button
type="button"
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('justify')}
disabled={codeMode}
title="Justify"
aria-label="Justify"
aria-pressed={currentAlign === 'justify'}
>
<AlignJustify size={16} />
</button>
<span className={styles.divider} />
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('insertUnorderedList')}
disabled={codeMode}
title="Bullet list"
aria-label="Bullet list"
>
<List size={16} />
</button>
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('insertOrderedList')}
disabled={codeMode}
title="Numbered list"
aria-label="Numbered list"
>
<ListOrdered size={16} />
</button>
<span className={styles.divider} />
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => changeIndent(-1)}
disabled={codeMode}
title="Decrease indent"
aria-label="Decrease indent"
>
<IndentDecrease size={16} />
</button>
<button
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => changeIndent(1)}
disabled={codeMode}
title="Increase indent"
aria-label="Increase indent"
>
<IndentIncrease size={16} />
</button>
{allowImages && onImageUpload && (
<>
<span className={styles.divider} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage || codeMode}
title="Insert image"
aria-label="Insert image"
>
<ImagePlus size={16} />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={handleImageSelect}
/>
</>
)}
<span className={styles.divider} />
<button
type="button"
className={[styles.codeModeBtn, codeMode ? styles.toolbarBtnActive : ''].filter(Boolean).join(' ')}
onMouseDown={onToolbarMouseDown}
onClick={toggleCodeMode}
title={codeMode ? 'Visual mode' : 'Code mode'}
aria-label={codeMode ? 'Visual mode' : 'Code mode'}
aria-pressed={codeMode}
>
<Code2 size={16} />
<span>HTML</span>
</button>
</div>
{uploadError && (
<p className={styles.uploadError} role="alert">
{uploadError}
</p>
)}
<div ref={editorShellRef} className={styles.editorShell}>
{codeMode && (
<textarea
className={styles.codeEditor}
style={{ minHeight: editorMinHeight }}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
dir="ltr"
aria-label="HTML source"
/>
)}
<div
ref={editorRef}
className={styles.editor}
style={{
minHeight: editorMinHeight,
display: codeMode ? 'none' : undefined,
}}
contentEditable={!codeMode}
role="textbox"
aria-multiline="true"
aria-hidden={codeMode}
dir={dir}
lang={locale}
data-placeholder={placeholder}
onInput={syncChange}
onKeyDown={onEditorKeyDown}
onPaste={onEditorPaste}
onKeyUp={refreshAlignFromSelection}
onMouseUp={refreshAlignFromSelection}
suppressContentEditableWarning
/>
{!codeMode && allowImages && selectedImageEl && handlePos && (
<button
type="button"
className={styles.resizeHandle}
style={{ top: handlePos.top, left: handlePos.left }}
onMouseDown={onResizeStart}
aria-label="Resize image"
/>
)}
</div>
</div>
)
}
/** Strip tags for single-line list previews. */
export function richTextToPlain(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function isEmptyRichText(html: string | null | undefined): boolean {
return richTextToPlain(html).length === 0
}