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>
1080 lines
32 KiB
TypeScript
1080 lines
32 KiB
TypeScript
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
|
||
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(/ /gi, ' ')
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
}
|
||
|
||
export function isEmptyRichText(html: string | null | undefined): boolean {
|
||
return richTextToPlain(html).length === 0
|
||
}
|