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:
co-authored by
Cursor
parent
eb9b8b9fa3
commit
ac287e02cc
@@ -201,7 +201,7 @@ export function BusinessProfilePage() {
|
|||||||
: item.province,
|
: item.province,
|
||||||
city: item.city,
|
city: item.city,
|
||||||
address: item.address,
|
address: item.address,
|
||||||
postalCode: item.postalCode,
|
postalCode: item.postalCode ?? '',
|
||||||
landline: item.landline ?? '',
|
landline: item.landline ?? '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -332,7 +332,7 @@ export function BusinessProfilePage() {
|
|||||||
province: item.province.trim(),
|
province: item.province.trim(),
|
||||||
city: item.city.trim(),
|
city: item.city.trim(),
|
||||||
address: item.address.trim(),
|
address: item.address.trim(),
|
||||||
postalCode: item.postalCode.trim(),
|
postalCode: item.postalCode.trim() || undefined,
|
||||||
landline: item.landline?.trim() || null,
|
landline: item.landline?.trim() || null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export interface BusinessAddress {
|
|||||||
province: string
|
province: string
|
||||||
city: string
|
city: string
|
||||||
address: string
|
address: string
|
||||||
postalCode: string
|
postalCode: string | null
|
||||||
landline: string | null
|
landline: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export interface UpdateBusinessProfilePayload {
|
|||||||
province: string
|
province: string
|
||||||
city: string
|
city: string
|
||||||
address: string
|
address: string
|
||||||
postalCode: string
|
postalCode?: string
|
||||||
landline?: string | null
|
landline?: string | null
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,21 @@
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar button.codeModeBtn {
|
||||||
|
width: auto;
|
||||||
|
min-width: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fontSizeSelect:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.fontSizeWrap {
|
.fontSizeWrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -238,6 +253,35 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codeEditor {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 140px;
|
||||||
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
|
border: 0;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--elevated-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
|
monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeEditor::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
.editor > * {
|
.editor > * {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
AlignLeft,
|
AlignLeft,
|
||||||
AlignRight,
|
AlignRight,
|
||||||
Bold,
|
Bold,
|
||||||
|
Code2,
|
||||||
IndentDecrease,
|
IndentDecrease,
|
||||||
IndentIncrease,
|
IndentIncrease,
|
||||||
Italic,
|
Italic,
|
||||||
@@ -60,6 +61,183 @@ function nearestFontSize(rawPx: number): FontSizeValue {
|
|||||||
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
|
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
|
||||||
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
|
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 {
|
function normalizeHexColor(value: string): string | null {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
|
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
|
||||||
@@ -119,6 +297,7 @@ export function RichTextEditor({
|
|||||||
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
|
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
|
||||||
const [currentAlign, setCurrentAlign] = useState<TextAlign | null>(null)
|
const [currentAlign, setCurrentAlign] = useState<TextAlign | null>(null)
|
||||||
const [currentFontSize, setCurrentFontSize] = useState<FontSizeValue>(DEFAULT_FONT_SIZE)
|
const [currentFontSize, setCurrentFontSize] = useState<FontSizeValue>(DEFAULT_FONT_SIZE)
|
||||||
|
const [codeMode, setCodeMode] = useState(false)
|
||||||
|
|
||||||
const syncChange = useCallback(() => {
|
const syncChange = useCallback(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
@@ -148,6 +327,21 @@ export function RichTextEditor({
|
|||||||
setHandlePos(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(
|
const selectImage = useCallback(
|
||||||
(img: HTMLImageElement) => {
|
(img: HTMLImageElement) => {
|
||||||
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => {
|
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => {
|
||||||
@@ -161,11 +355,12 @@ export function RichTextEditor({
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (codeMode) return
|
||||||
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
||||||
editorRef.current.innerHTML = value
|
editorRef.current.innerHTML = value
|
||||||
clearImageSelection()
|
clearImageSelection()
|
||||||
}
|
}
|
||||||
}, [value, clearImageSelection])
|
}, [value, codeMode, clearImageSelection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allowImages) return
|
if (!allowImages) return
|
||||||
@@ -460,6 +655,43 @@ export function RichTextEditor({
|
|||||||
changeIndent(e.shiftKey ? -1 : 1)
|
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) {
|
function styleInsertedImage(img: HTMLImageElement) {
|
||||||
img.style.width = '100%'
|
img.style.width = '100%'
|
||||||
img.style.maxWidth = '100%'
|
img.style.maxWidth = '100%'
|
||||||
@@ -538,6 +770,7 @@ export function RichTextEditor({
|
|||||||
type="button"
|
type="button"
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => exec('bold')}
|
onClick={() => exec('bold')}
|
||||||
|
disabled={codeMode}
|
||||||
title="Bold"
|
title="Bold"
|
||||||
aria-label="Bold"
|
aria-label="Bold"
|
||||||
>
|
>
|
||||||
@@ -547,6 +780,7 @@ export function RichTextEditor({
|
|||||||
type="button"
|
type="button"
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => exec('italic')}
|
onClick={() => exec('italic')}
|
||||||
|
disabled={codeMode}
|
||||||
title="Italic"
|
title="Italic"
|
||||||
aria-label="Italic"
|
aria-label="Italic"
|
||||||
>
|
>
|
||||||
@@ -556,6 +790,7 @@ export function RichTextEditor({
|
|||||||
type="button"
|
type="button"
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => exec('underline')}
|
onClick={() => exec('underline')}
|
||||||
|
disabled={codeMode}
|
||||||
title="Underline"
|
title="Underline"
|
||||||
aria-label="Underline"
|
aria-label="Underline"
|
||||||
>
|
>
|
||||||
@@ -569,6 +804,7 @@ export function RichTextEditor({
|
|||||||
value={currentFontSize}
|
value={currentFontSize}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => applyFontSize(e.target.value as FontSizeValue)}
|
onChange={(e) => applyFontSize(e.target.value as FontSizeValue)}
|
||||||
|
disabled={codeMode}
|
||||||
title="Font size"
|
title="Font size"
|
||||||
aria-label="Font size"
|
aria-label="Font size"
|
||||||
>
|
>
|
||||||
@@ -586,6 +822,7 @@ export function RichTextEditor({
|
|||||||
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
|
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={toggleColorPicker}
|
onClick={toggleColorPicker}
|
||||||
|
disabled={codeMode}
|
||||||
title="Text color"
|
title="Text color"
|
||||||
aria-label="Text color"
|
aria-label="Text color"
|
||||||
aria-expanded={colorOpen}
|
aria-expanded={colorOpen}
|
||||||
@@ -594,7 +831,7 @@ export function RichTextEditor({
|
|||||||
<Palette size={16} />
|
<Palette size={16} />
|
||||||
<span className={styles.colorSwatch} style={{ background: textColor }} aria-hidden="true" />
|
<span className={styles.colorSwatch} style={{ background: textColor }} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{colorOpen && (
|
{colorOpen && !codeMode && (
|
||||||
<div className={styles.colorPopover} role="dialog" aria-label="Text color">
|
<div className={styles.colorPopover} role="dialog" aria-label="Text color">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -627,6 +864,7 @@ export function RichTextEditor({
|
|||||||
className={styles.dirBtn}
|
className={styles.dirBtn}
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockDirection('rtl')}
|
onClick={() => setBlockDirection('rtl')}
|
||||||
|
disabled={codeMode}
|
||||||
title="Right to left"
|
title="Right to left"
|
||||||
aria-label="Right to left"
|
aria-label="Right to left"
|
||||||
>
|
>
|
||||||
@@ -637,6 +875,7 @@ export function RichTextEditor({
|
|||||||
className={styles.dirBtn}
|
className={styles.dirBtn}
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockDirection('ltr')}
|
onClick={() => setBlockDirection('ltr')}
|
||||||
|
disabled={codeMode}
|
||||||
title="Left to right"
|
title="Left to right"
|
||||||
aria-label="Left to right"
|
aria-label="Left to right"
|
||||||
>
|
>
|
||||||
@@ -648,6 +887,7 @@ export function RichTextEditor({
|
|||||||
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
|
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockAlign('left')}
|
onClick={() => setBlockAlign('left')}
|
||||||
|
disabled={codeMode}
|
||||||
title="Align left"
|
title="Align left"
|
||||||
aria-label="Align left"
|
aria-label="Align left"
|
||||||
aria-pressed={currentAlign === 'left'}
|
aria-pressed={currentAlign === 'left'}
|
||||||
@@ -659,6 +899,7 @@ export function RichTextEditor({
|
|||||||
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
|
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockAlign('center')}
|
onClick={() => setBlockAlign('center')}
|
||||||
|
disabled={codeMode}
|
||||||
title="Align center"
|
title="Align center"
|
||||||
aria-label="Align center"
|
aria-label="Align center"
|
||||||
aria-pressed={currentAlign === 'center'}
|
aria-pressed={currentAlign === 'center'}
|
||||||
@@ -670,6 +911,7 @@ export function RichTextEditor({
|
|||||||
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
|
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockAlign('right')}
|
onClick={() => setBlockAlign('right')}
|
||||||
|
disabled={codeMode}
|
||||||
title="Align right"
|
title="Align right"
|
||||||
aria-label="Align right"
|
aria-label="Align right"
|
||||||
aria-pressed={currentAlign === 'right'}
|
aria-pressed={currentAlign === 'right'}
|
||||||
@@ -681,6 +923,7 @@ export function RichTextEditor({
|
|||||||
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
|
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => setBlockAlign('justify')}
|
onClick={() => setBlockAlign('justify')}
|
||||||
|
disabled={codeMode}
|
||||||
title="Justify"
|
title="Justify"
|
||||||
aria-label="Justify"
|
aria-label="Justify"
|
||||||
aria-pressed={currentAlign === 'justify'}
|
aria-pressed={currentAlign === 'justify'}
|
||||||
@@ -688,10 +931,24 @@ export function RichTextEditor({
|
|||||||
<AlignJustify size={16} />
|
<AlignJustify size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.divider} />
|
<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} />
|
<List size={16} />
|
||||||
</button>
|
</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} />
|
<ListOrdered size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.divider} />
|
<span className={styles.divider} />
|
||||||
@@ -699,6 +956,7 @@ export function RichTextEditor({
|
|||||||
type="button"
|
type="button"
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => changeIndent(-1)}
|
onClick={() => changeIndent(-1)}
|
||||||
|
disabled={codeMode}
|
||||||
title="Decrease indent"
|
title="Decrease indent"
|
||||||
aria-label="Decrease indent"
|
aria-label="Decrease indent"
|
||||||
>
|
>
|
||||||
@@ -708,6 +966,7 @@ export function RichTextEditor({
|
|||||||
type="button"
|
type="button"
|
||||||
onMouseDown={onToolbarMouseDown}
|
onMouseDown={onToolbarMouseDown}
|
||||||
onClick={() => changeIndent(1)}
|
onClick={() => changeIndent(1)}
|
||||||
|
disabled={codeMode}
|
||||||
title="Increase indent"
|
title="Increase indent"
|
||||||
aria-label="Increase indent"
|
aria-label="Increase indent"
|
||||||
>
|
>
|
||||||
@@ -719,7 +978,7 @@ export function RichTextEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploadingImage}
|
disabled={uploadingImage || codeMode}
|
||||||
title="Insert image"
|
title="Insert image"
|
||||||
aria-label="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>
|
</div>
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
<p className={styles.uploadError} role="alert">
|
<p className={styles.uploadError} role="alert">
|
||||||
@@ -741,23 +1013,43 @@ export function RichTextEditor({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div ref={editorShellRef} className={styles.editorShell}>
|
<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
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
className={styles.editor}
|
className={styles.editor}
|
||||||
style={{ minHeight: editorMinHeight }}
|
style={{
|
||||||
contentEditable
|
minHeight: editorMinHeight,
|
||||||
|
display: codeMode ? 'none' : undefined,
|
||||||
|
}}
|
||||||
|
contentEditable={!codeMode}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
|
aria-hidden={codeMode}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
lang={locale}
|
lang={locale}
|
||||||
data-placeholder={placeholder}
|
data-placeholder={placeholder}
|
||||||
onInput={syncChange}
|
onInput={syncChange}
|
||||||
onKeyDown={onEditorKeyDown}
|
onKeyDown={onEditorKeyDown}
|
||||||
|
onPaste={onEditorPaste}
|
||||||
onKeyUp={refreshAlignFromSelection}
|
onKeyUp={refreshAlignFromSelection}
|
||||||
onMouseUp={refreshAlignFromSelection}
|
onMouseUp={refreshAlignFromSelection}
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
/>
|
/>
|
||||||
{allowImages && selectedImageEl && handlePos && (
|
{!codeMode && allowImages && selectedImageEl && handlePos && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.resizeHandle}
|
className={styles.resizeHandle}
|
||||||
|
|||||||
Reference in New Issue
Block a user