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>
This commit is contained in:
Alireza Hassani
2026-10-04 18:42:21 +03:30
co-authored by Cursor
parent eb9b8b9fa3
commit ac287e02cc
4 changed files with 348 additions and 12 deletions
@@ -5,6 +5,7 @@ import {
AlignLeft,
AlignRight,
Bold,
Code2,
IndentDecrease,
IndentIncrease,
Italic,
@@ -60,6 +61,183 @@ function nearestFontSize(rawPx: number): FontSizeValue {
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}`
@@ -119,6 +297,7 @@ export function RichTextEditor({
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) {
@@ -148,6 +327,21 @@ export function RichTextEditor({
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) => {
@@ -161,11 +355,12 @@ export function RichTextEditor({
)
useEffect(() => {
if (codeMode) return
if (editorRef.current && editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value
clearImageSelection()
}
}, [value, clearImageSelection])
}, [value, codeMode, clearImageSelection])
useEffect(() => {
if (!allowImages) return
@@ -460,6 +655,43 @@ export function RichTextEditor({
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%'
@@ -538,6 +770,7 @@ export function RichTextEditor({
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('bold')}
disabled={codeMode}
title="Bold"
aria-label="Bold"
>
@@ -547,6 +780,7 @@ export function RichTextEditor({
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('italic')}
disabled={codeMode}
title="Italic"
aria-label="Italic"
>
@@ -556,6 +790,7 @@ export function RichTextEditor({
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => exec('underline')}
disabled={codeMode}
title="Underline"
aria-label="Underline"
>
@@ -569,6 +804,7 @@ export function RichTextEditor({
value={currentFontSize}
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => applyFontSize(e.target.value as FontSizeValue)}
disabled={codeMode}
title="Font size"
aria-label="Font size"
>
@@ -586,6 +822,7 @@ export function RichTextEditor({
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}
@@ -594,7 +831,7 @@ export function RichTextEditor({
<Palette size={16} />
<span className={styles.colorSwatch} style={{ background: textColor }} aria-hidden="true" />
</button>
{colorOpen && (
{colorOpen && !codeMode && (
<div className={styles.colorPopover} role="dialog" aria-label="Text color">
<input
type="color"
@@ -627,6 +864,7 @@ export function RichTextEditor({
className={styles.dirBtn}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockDirection('rtl')}
disabled={codeMode}
title="Right to left"
aria-label="Right to left"
>
@@ -637,6 +875,7 @@ export function RichTextEditor({
className={styles.dirBtn}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockDirection('ltr')}
disabled={codeMode}
title="Left to right"
aria-label="Left to right"
>
@@ -648,6 +887,7 @@ export function RichTextEditor({
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('left')}
disabled={codeMode}
title="Align left"
aria-label="Align left"
aria-pressed={currentAlign === 'left'}
@@ -659,6 +899,7 @@ export function RichTextEditor({
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('center')}
disabled={codeMode}
title="Align center"
aria-label="Align center"
aria-pressed={currentAlign === 'center'}
@@ -670,6 +911,7 @@ export function RichTextEditor({
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('right')}
disabled={codeMode}
title="Align right"
aria-label="Align right"
aria-pressed={currentAlign === 'right'}
@@ -681,6 +923,7 @@ export function RichTextEditor({
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
onMouseDown={onToolbarMouseDown}
onClick={() => setBlockAlign('justify')}
disabled={codeMode}
title="Justify"
aria-label="Justify"
aria-pressed={currentAlign === 'justify'}
@@ -688,10 +931,24 @@ export function RichTextEditor({
<AlignJustify size={16} />
</button>
<span className={styles.divider} />
<button type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<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')} title="Numbered list" aria-label="Numbered list">
<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} />
@@ -699,6 +956,7 @@ export function RichTextEditor({
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => changeIndent(-1)}
disabled={codeMode}
title="Decrease indent"
aria-label="Decrease indent"
>
@@ -708,6 +966,7 @@ export function RichTextEditor({
type="button"
onMouseDown={onToolbarMouseDown}
onClick={() => changeIndent(1)}
disabled={codeMode}
title="Increase indent"
aria-label="Increase indent"
>
@@ -719,7 +978,7 @@ export function RichTextEditor({
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
disabled={uploadingImage || codeMode}
title="Insert image"
aria-label="Insert image"
>
@@ -734,6 +993,19 @@ export function RichTextEditor({
/>
</>
)}
<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">
@@ -741,23 +1013,43 @@ export function RichTextEditor({
</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 }}
contentEditable
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
/>
{allowImages && selectedImageEl && handlePos && (
{!codeMode && allowImages && selectedImageEl && handlePos && (
<button
type="button"
className={styles.resizeHandle}