Add customer SMS/Excel tools, selection bar, and sticky import toasts.
Bulk and single SMS from Users, row selection with overflow summary, persistent large import toasts, and business SMS sender in Super Admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2a32c46b2e
commit
4c3bf8324e
@@ -62,6 +62,52 @@
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.fontSizeWrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fontSizeSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: auto;
|
||||
min-width: 52px;
|
||||
height: 28px;
|
||||
padding: 0 22px 0 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 4px center;
|
||||
background-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fontSizeSelect:hover,
|
||||
.fontSizeSelect:focus {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
@@ -212,6 +258,20 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor b,
|
||||
.editor strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor i,
|
||||
.editor em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.editor u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.editor blockquote {
|
||||
margin: 0.5em 0;
|
||||
margin-inline-start: 1.5em;
|
||||
|
||||
@@ -30,6 +30,33 @@ const ALIGN_COMMAND: Record<TextAlign, string> = {
|
||||
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})$/
|
||||
|
||||
@@ -91,6 +118,7 @@ export function RichTextEditor({
|
||||
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 syncChange = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
@@ -199,11 +227,18 @@ export function RichTextEditor({
|
||||
}, [colorOpen])
|
||||
|
||||
function exec(cmd: string, arg?: string) {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
document.execCommand(cmd, false, arg)
|
||||
editorRef.current?.focus()
|
||||
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
|
||||
@@ -245,6 +280,66 @@ export function RichTextEditor({
|
||||
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(() => {
|
||||
@@ -254,6 +349,7 @@ export function RichTextEditor({
|
||||
const selection = window.getSelection()
|
||||
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
|
||||
setCurrentAlign(readBlockAlign(editor))
|
||||
setCurrentFontSize(readFontSize(editor))
|
||||
}
|
||||
|
||||
document.addEventListener('selectionchange', onSelectionChange)
|
||||
@@ -438,20 +534,57 @@ export function RichTextEditor({
|
||||
return (
|
||||
<div className={styles.wrapper} dir={dir} lang={locale}>
|
||||
<div className={styles.toolbar} dir="ltr">
|
||||
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => exec('bold')}
|
||||
title="Bold"
|
||||
aria-label="Bold"
|
||||
>
|
||||
<Bold size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => exec('italic')}
|
||||
title="Italic"
|
||||
aria-label="Italic"
|
||||
>
|
||||
<Italic size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => exec('underline')} title="Underline" aria-label="Underline">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => exec('underline')}
|
||||
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)}
|
||||
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}
|
||||
title="Text color"
|
||||
aria-label="Text color"
|
||||
@@ -492,6 +625,7 @@ export function RichTextEditor({
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dirBtn}
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => setBlockDirection('rtl')}
|
||||
title="Right to left"
|
||||
aria-label="Right to left"
|
||||
@@ -501,6 +635,7 @@ export function RichTextEditor({
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dirBtn}
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => setBlockDirection('ltr')}
|
||||
title="Left to right"
|
||||
aria-label="Left to right"
|
||||
@@ -511,7 +646,7 @@ export function RichTextEditor({
|
||||
<button
|
||||
type="button"
|
||||
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => setBlockAlign('left')}
|
||||
title="Align left"
|
||||
aria-label="Align left"
|
||||
@@ -522,7 +657,7 @@ export function RichTextEditor({
|
||||
<button
|
||||
type="button"
|
||||
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => setBlockAlign('center')}
|
||||
title="Align center"
|
||||
aria-label="Align center"
|
||||
@@ -533,7 +668,7 @@ export function RichTextEditor({
|
||||
<button
|
||||
type="button"
|
||||
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => setBlockAlign('right')}
|
||||
title="Align right"
|
||||
aria-label="Align right"
|
||||
@@ -544,7 +679,7 @@ export function RichTextEditor({
|
||||
<button
|
||||
type="button"
|
||||
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => setBlockAlign('justify')}
|
||||
title="Justify"
|
||||
aria-label="Justify"
|
||||
@@ -553,15 +688,16 @@ export function RichTextEditor({
|
||||
<AlignJustify size={16} />
|
||||
</button>
|
||||
<span className={styles.divider} />
|
||||
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
|
||||
<button type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
|
||||
<List size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
|
||||
<button type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
|
||||
<ListOrdered size={16} />
|
||||
</button>
|
||||
<span className={styles.divider} />
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => changeIndent(-1)}
|
||||
title="Decrease indent"
|
||||
aria-label="Decrease indent"
|
||||
@@ -570,6 +706,7 @@ export function RichTextEditor({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={onToolbarMouseDown}
|
||||
onClick={() => changeIndent(1)}
|
||||
title="Increase indent"
|
||||
aria-label="Increase indent"
|
||||
|
||||
Reference in New Issue
Block a user