| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| 2 | import { Bold, Italic, Underline } from 'lucide-react' |
| 3 | import { |
| 4 | createEditor, |
| 5 | Editor, |
| 6 | Element as SlateElement, |
| 7 | Node as SlateNode, |
| 8 | Range, |
| 9 | Text, |
| 10 | Transforms, |
| 11 | type BaseSelection, |
| 12 | type Descendant |
| 13 | } from 'slate' |
| 14 | import { withHistory } from 'slate-history' |
| 15 | import { |
| 16 | Editable, |
| 17 | ReactEditor, |
| 18 | Slate, |
| 19 | useSlate, |
| 20 | useSlateSelector, |
| 21 | withReact, |
| 22 | type RenderElementProps, |
| 23 | type RenderLeafProps |
| 24 | } from 'slate-react' |
| 25 | import { cn } from '@renderer/lib/utils' |
| 26 | |
| 27 | export type RichTextValue = { html: string; text: string } |
| 28 | type Mark = 'bold' | 'italic' | 'underline' |
| 29 | |
| 30 | type RichTextNode = { |
| 31 | type: 'paragraph' | 'span' | 'link' |
| 32 | style?: string |
| 33 | className?: string |
| 34 | blockId?: string |
| 35 | href?: string |
| 36 | children: Array<RichTextLeaf | RichTextNode> |
| 37 | } |
| 38 | |
| 39 | type RichTextLeaf = { |
| 40 | text: string |
| 41 | bold?: boolean |
| 42 | italic?: boolean |
| 43 | underline?: boolean |
| 44 | color?: string |
| 45 | fontSize?: string |
| 46 | style?: string |
| 47 | className?: string |
| 48 | blockId?: string |
| 49 | } |
| 50 | |
| 51 | declare module 'slate' { |
| 52 | interface CustomTypes { |
| 53 | Editor: ReactEditor |
| 54 | Element: RichTextNode |
| 55 | Text: RichTextLeaf |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | const commandButtons = [ |
| 60 | { mark: 'bold', label: 'Bold', icon: Bold }, |
| 61 | { mark: 'italic', label: 'Italic', icon: Italic }, |
| 62 | { mark: 'underline', label: 'Underline', icon: Underline } |
| 63 | ] as const |
| 64 | |
| 65 | const escapeHtml = (value: string): string => |
| 66 | value |
| 67 | .replace(/&/g, '&') |
| 68 | .replace(/</g, '<') |
| 69 | .replace(/>/g, '>') |
| 70 | .replace(/"/g, '"') |
| 71 | |
| 72 | const escapeAttribute = (value: string): string => escapeHtml(value).replace(/'/g, ''') |
| 73 | |
| 74 | const parsePixelSize = (value: string | undefined): number | undefined => { |
| 75 | const size = Number(String(value || '').replace(/px$/i, '')) |
| 76 | return Number.isFinite(size) && size > 0 ? size : undefined |
| 77 | } |
| 78 | |
| 79 | const normalizeFontSize = (value: string | undefined): string | undefined => { |
| 80 | const size = parsePixelSize(value) |
| 81 | if (!size) return undefined |
| 82 | return `${Math.max(16, Math.min(240, Math.round(size * 10) / 10))}px` |
| 83 | } |
| 84 | |
| 85 | const normalizeColor = (value: string | undefined): string | undefined => { |
| 86 | const text = String(value || '').trim() |
| 87 | if (/^#[0-9a-f]{6}$/i.test(text)) return text |
| 88 | if (/^rgba?\(/i.test(text)) return text |
| 89 | return undefined |
| 90 | } |
| 91 | |
| 92 | const getColorChannels = (value: string | undefined): [number, number, number] | undefined => { |
| 93 | const color = String(value || '').trim() |
| 94 | const hexMatch = color.match(/^#([0-9a-f]{6})$/i) |
| 95 | if (hexMatch) { |
| 96 | const hex = hexMatch[1] |
| 97 | return [ |
| 98 | parseInt(hex.slice(0, 2), 16), |
| 99 | parseInt(hex.slice(2, 4), 16), |
| 100 | parseInt(hex.slice(4, 6), 16) |
| 101 | ] |
| 102 | } |
| 103 | |
| 104 | const rgbMatch = color.match(/^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})/i) |
| 105 | if (!rgbMatch) return undefined |
| 106 | return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])] |
| 107 | } |
| 108 | |
| 109 | const getTextPreviewShadow = (color: string | undefined): string | undefined => { |
| 110 | const channels = getColorChannels(color) |
| 111 | if (!channels) return undefined |
| 112 | const [red, green, blue] = channels.map((channel) => Math.max(0, Math.min(255, channel)) / 255) |
| 113 | const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue |
| 114 | if (luminance < 0.72) return undefined |
| 115 | return '0 1px 1px rgba(20, 28, 23, 0.9), 0 0 1px rgba(20, 28, 23, 0.72)' |
| 116 | } |
| 117 | |
| 118 | const parseStyleAttribute = (style: string | undefined): React.CSSProperties | undefined => { |
| 119 | if (!style) return undefined |
| 120 | const parsed: React.CSSProperties = {} |
| 121 | for (const item of style.split(';')) { |
| 122 | const [rawKey, ...rawValue] = item.split(':') |
| 123 | const key = rawKey?.trim() |
| 124 | const value = rawValue.join(':').trim() |
| 125 | if (!key || !value) continue |
| 126 | const camelKey = key.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()) |
| 127 | ;(parsed as Record<string, string>)[camelKey] = value |
| 128 | } |
| 129 | return parsed |
| 130 | } |
| 131 | |
| 132 | const getStyleProperty = (style: string | undefined, propertyName: string): string | undefined => { |
| 133 | if (!style) return undefined |
| 134 | for (const item of style.split(';')) { |
| 135 | const [rawKey, ...rawValue] = item.split(':') |
| 136 | const key = rawKey?.trim().toLowerCase() |
| 137 | const value = rawValue.join(':').trim() |
| 138 | if (key === propertyName && value) return value |
| 139 | } |
| 140 | return undefined |
| 141 | } |
| 142 | |
| 143 | const stripStyleProperties = (style: string | undefined, propertyNames: string[]): string | undefined => { |
| 144 | if (!style) return undefined |
| 145 | const excluded = new Set(propertyNames.map((name) => name.toLowerCase())) |
| 146 | const kept = style |
| 147 | .split(';') |
| 148 | .map((item) => item.trim()) |
| 149 | .filter((item) => { |
| 150 | const separator = item.indexOf(':') |
| 151 | if (separator < 0) return false |
| 152 | return !excluded.has(item.slice(0, separator).trim().toLowerCase()) |
| 153 | }) |
| 154 | return kept.length > 0 ? kept.join('; ') : undefined |
| 155 | } |
| 156 | |
| 157 | const stripEditorOnlyStyleProperties = (style: string | undefined): string | undefined => |
| 158 | stripStyleProperties(style, ['zoom']) |
| 159 | |
| 160 | function getSelectionElementStyle(editor: Editor, propertyName: string): string | undefined { |
| 161 | if (!editor.selection) return undefined |
| 162 | const entry = Editor.above(editor, { |
| 163 | at: editor.selection.anchor, |
| 164 | match: (node) => |
| 165 | SlateElement.isElement(node) && (node.type === 'span' || node.type === 'link') && Boolean(node.style) |
| 166 | }) |
| 167 | const element = entry?.[0] |
| 168 | return SlateElement.isElement(element) ? getStyleProperty(element.style, propertyName) : undefined |
| 169 | } |
| 170 | |
| 171 | function getSelectionMarks(editor: Editor): Partial<RichTextLeaf> { |
| 172 | const marks = (Editor.marks(editor) as Partial<RichTextLeaf> | null) || {} |
| 173 | if (!editor.selection) return marks |
| 174 | const textEntry = Editor.nodes(editor, { |
| 175 | at: editor.selection, |
| 176 | match: Text.isText |
| 177 | }).next().value as [RichTextLeaf, unknown] | undefined |
| 178 | const leaf: Partial<RichTextLeaf> = textEntry?.[0] || {} |
| 179 | return { |
| 180 | ...marks, |
| 181 | ...leaf, |
| 182 | color: marks.color || leaf.color || getSelectionElementStyle(editor, 'color'), |
| 183 | fontSize: marks.fontSize || leaf.fontSize || getSelectionElementStyle(editor, 'font-size') |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | const leafFromText = (text: string, marks: Partial<RichTextLeaf> = {}): RichTextLeaf => ({ |
| 188 | ...marks, |
| 189 | text |
| 190 | }) |
| 191 | |
| 192 | function deserializeNode(node: Node, marks: Partial<RichTextLeaf> = {}): Array<RichTextLeaf | RichTextNode> { |
| 193 | if (node.nodeType === Node.TEXT_NODE) { |
| 194 | return [leafFromText(node.textContent || '', marks)] |
| 195 | } |
| 196 | if (!(node instanceof HTMLElement)) return [] |
| 197 | |
| 198 | const tagName = node.tagName.toLowerCase() |
| 199 | const nextMarks: Partial<RichTextLeaf> = { ...marks } |
| 200 | const rawStyle = node.getAttribute('style') || undefined |
| 201 | const color = getStyleProperty(rawStyle, 'color') |
| 202 | const fontSize = getStyleProperty(rawStyle, 'font-size') |
| 203 | const fontWeight = getStyleProperty(rawStyle, 'font-weight') |
| 204 | if (color) nextMarks.color = color |
| 205 | if (fontSize) nextMarks.fontSize = fontSize |
| 206 | if (fontWeight && (fontWeight === 'bold' || Number(fontWeight) >= 600)) nextMarks.bold = true |
| 207 | if (tagName === 'strong' || tagName === 'b') nextMarks.bold = true |
| 208 | if (tagName === 'em' || tagName === 'i') nextMarks.italic = true |
| 209 | if (tagName === 'u') nextMarks.underline = true |
| 210 | if (tagName === 'br') return [leafFromText('\n', marks)] |
| 211 | |
| 212 | const children = Array.from(node.childNodes).flatMap((child) => deserializeNode(child, nextMarks)) |
| 213 | if (tagName === 'span' || tagName === 'a') { |
| 214 | const inlineNode: RichTextNode = { |
| 215 | type: tagName === 'a' ? 'link' : 'span', |
| 216 | style: stripStyleProperties(rawStyle, ['color', 'font-size', 'font-weight', 'zoom']), |
| 217 | className: node.getAttribute('class') || undefined, |
| 218 | blockId: node.getAttribute('data-block-id') || undefined, |
| 219 | href: tagName === 'a' ? node.getAttribute('href') || undefined : undefined, |
| 220 | children: |
| 221 | children.length > 0 |
| 222 | ? (children as Array<RichTextLeaf | RichTextNode>) |
| 223 | : [leafFromText('', nextMarks)] |
| 224 | } |
| 225 | return [inlineNode] |
| 226 | } |
| 227 | |
| 228 | return children |
| 229 | } |
| 230 | |
| 231 | function deserializeHtml(html: string, fallbackText: string): Descendant[] { |
| 232 | const source = html || escapeHtml(fallbackText) |
| 233 | const parser = new DOMParser() |
| 234 | const doc = parser.parseFromString(`<div>${source}</div>`, 'text/html') |
| 235 | const root = doc.body.firstElementChild |
| 236 | const children = root |
| 237 | ? Array.from(root.childNodes).flatMap((node) => deserializeNode(node)) |
| 238 | : [leafFromText(fallbackText)] |
| 239 | return [ |
| 240 | { |
| 241 | type: 'paragraph', |
| 242 | children: |
| 243 | children.length > 0 |
| 244 | ? (children as Array<RichTextLeaf | RichTextNode>) |
| 245 | : [leafFromText('')] |
| 246 | } |
| 247 | ] |
| 248 | } |
| 249 | |
| 250 | function serializeLeaf(leaf: RichTextLeaf): string { |
| 251 | let html = escapeHtml(leaf.text) |
| 252 | if (leaf.underline) html = `<u>${html}</u>` |
| 253 | if (leaf.italic) html = `<em>${html}</em>` |
| 254 | if (leaf.bold) html = `<strong>${html}</strong>` |
| 255 | |
| 256 | const attrs: string[] = [] |
| 257 | const style = [ |
| 258 | stripEditorOnlyStyleProperties(leaf.style), |
| 259 | leaf.color ? `color: ${leaf.color}` : '', |
| 260 | leaf.fontSize ? `font-size: ${leaf.fontSize}` : '' |
| 261 | ] |
| 262 | .filter(Boolean) |
| 263 | .join('; ') |
| 264 | if (style) attrs.push(`style="${escapeAttribute(style)}"`) |
| 265 | if (leaf.className) attrs.push(`class="${escapeAttribute(leaf.className)}"`) |
| 266 | if (leaf.blockId) attrs.push(`data-block-id="${escapeAttribute(leaf.blockId)}"`) |
| 267 | return attrs.length > 0 ? `<span ${attrs.join(' ')}>${html}</span>` : html |
| 268 | } |
| 269 | |
| 270 | function serializeNode(node: Descendant): string { |
| 271 | if (Text.isText(node)) return serializeLeaf(node) |
| 272 | const children = node.children.map((child) => serializeNode(child)).join('') |
| 273 | if (node.type === 'span') { |
| 274 | const attrs: string[] = [] |
| 275 | const style = stripEditorOnlyStyleProperties(node.style) |
| 276 | if (style) attrs.push(`style="${escapeAttribute(style)}"`) |
| 277 | if (node.className) attrs.push(`class="${escapeAttribute(node.className)}"`) |
| 278 | if (node.blockId) attrs.push(`data-block-id="${escapeAttribute(node.blockId)}"`) |
| 279 | return `<span${attrs.length ? ` ${attrs.join(' ')}` : ''}>${children}</span>` |
| 280 | } |
| 281 | if (node.type === 'link') { |
| 282 | const attrs = node.href ? ` href="${escapeAttribute(node.href)}"` : '' |
| 283 | return `<a${attrs}>${children}</a>` |
| 284 | } |
| 285 | return children |
| 286 | } |
| 287 | |
| 288 | const serializeValue = (value: Descendant[]): RichTextValue => ({ |
| 289 | html: value.map((node) => serializeNode(node)).join(''), |
| 290 | text: value.map((node) => SlateNode.string(node)).join('') |
| 291 | }) |
| 292 | |
| 293 | export function applyColorMark( |
| 294 | editor: Editor, |
| 295 | color: string, |
| 296 | cachedSelection?: BaseSelection |
| 297 | ): RichTextValue | null { |
| 298 | if (!/^#[0-9a-f]{6}$/i.test(color)) return null |
| 299 | |
| 300 | let selection = cachedSelection |
| 301 | if (!selection || Range.isCollapsed(selection)) selection = editor.selection |
| 302 | if (!selection || Range.isCollapsed(selection)) selection = Editor.range(editor, []) |
| 303 | if (Range.isCollapsed(selection)) return null |
| 304 | |
| 305 | Transforms.select(editor, selection) |
| 306 | Editor.addMark(editor, 'color', color) |
| 307 | return serializeValue(editor.children) |
| 308 | } |
| 309 | |
| 310 | function getNodeMaxFontSize(node: Descendant | RichTextNode | RichTextLeaf): number { |
| 311 | if (Text.isText(node)) return parsePixelSize(node.fontSize) || 0 |
| 312 | const ownSize = parsePixelSize(getStyleProperty(node.style, 'font-size')) || 0 |
| 313 | return node.children.reduce( |
| 314 | (maxSize, child) => Math.max(maxSize, getNodeMaxFontSize(child)), |
| 315 | ownSize |
| 316 | ) |
| 317 | } |
| 318 | |
| 319 | function getEditorZoom(value: Descendant[], defaultFontSize: string | undefined): number { |
| 320 | const defaultSize = parsePixelSize(defaultFontSize) || 0 |
| 321 | const maxSize = value.reduce( |
| 322 | (largestSize, node) => Math.max(largestSize, getNodeMaxFontSize(node)), |
| 323 | defaultSize |
| 324 | ) |
| 325 | if (maxSize <= 36) return 1 |
| 326 | return Math.max(0.25, Math.min(1, Math.round((36 / maxSize) * 100) / 100)) |
| 327 | } |
| 328 | |
| 329 | function withInlineRichText(editor: ReactEditor): ReactEditor { |
| 330 | const { isInline } = editor |
| 331 | editor.isInline = (element) => |
| 332 | SlateElement.isElement(element) && (element.type === 'span' || element.type === 'link') |
| 333 | ? true |
| 334 | : isInline(element) |
| 335 | return editor |
| 336 | } |
| 337 | |
| 338 | function isMarkActive(editor: Editor, mark: Mark): boolean { |
| 339 | const marks = getSelectionMarks(editor) as Partial<Record<Mark, boolean>> |
| 340 | return marks?.[mark] === true |
| 341 | } |
| 342 | |
| 343 | function toggleMark(editor: Editor, mark: Mark): void { |
| 344 | ReactEditor.focus(editor) |
| 345 | if (isMarkActive(editor, mark)) Editor.removeMark(editor, mark) |
| 346 | else Editor.addMark(editor, mark, true) |
| 347 | } |
| 348 | |
| 349 | function setColorMark( |
| 350 | editor: Editor, |
| 351 | color: string, |
| 352 | cachedSelection?: BaseSelection |
| 353 | ): RichTextValue | null { |
| 354 | const nextValue = applyColorMark(editor, color, cachedSelection) |
| 355 | if (!nextValue) return null |
| 356 | ReactEditor.focus(editor) |
| 357 | return nextValue |
| 358 | } |
| 359 | |
| 360 | function getCurrentFontSize(editor: Editor, defaultFontSize?: string): number { |
| 361 | if (editor.selection && !Range.isCollapsed(editor.selection)) { |
| 362 | const selectedSizes = new Set<number>() |
| 363 | for (const [node, path] of Editor.nodes(editor, { |
| 364 | at: editor.selection, |
| 365 | match: Text.isText |
| 366 | })) { |
| 367 | const leaf = node as RichTextLeaf |
| 368 | const parentEntry = Editor.parent(editor, path) |
| 369 | const parent = parentEntry[0] |
| 370 | const parentSize = SlateElement.isElement(parent) |
| 371 | ? getStyleProperty(parent.style, 'font-size') |
| 372 | : undefined |
| 373 | const size = parsePixelSize(leaf.fontSize || parentSize || defaultFontSize) |
| 374 | if (size) selectedSizes.add(Math.round(size * 10) / 10) |
| 375 | if (selectedSizes.size > 1) return 16 |
| 376 | } |
| 377 | const selectedSize = Array.from(selectedSizes)[0] |
| 378 | return selectedSize ? Math.max(16, selectedSize) : 16 |
| 379 | } |
| 380 | const marks = getSelectionMarks(editor) |
| 381 | const size = Number(String(marks?.fontSize || '').replace(/px$/i, '')) |
| 382 | if (Number.isFinite(size)) return size |
| 383 | const fallback = Number(String(defaultFontSize || '').replace(/px$/i, '')) |
| 384 | return Number.isFinite(fallback) && fallback > 0 ? Math.max(16, fallback) : 16 |
| 385 | } |
| 386 | |
| 387 | function getCurrentColor(editor: Editor, defaultColor?: string): string { |
| 388 | const marks = getSelectionMarks(editor) |
| 389 | return normalizeColor(marks.color) || normalizeColor(defaultColor) || '#34402c' |
| 390 | } |
| 391 | |
| 392 | function setFontSizeMark(editor: Editor, value: number, selection?: BaseSelection): void { |
| 393 | const size = Number(value) |
| 394 | if (!Number.isFinite(size)) return |
| 395 | const clamped = Math.max(16, Math.min(160, Math.round(size * 10) / 10)) |
| 396 | if (selection) Transforms.select(editor, selection) |
| 397 | ReactEditor.focus(editor) |
| 398 | Editor.addMark(editor, 'fontSize', `${clamped}px`) |
| 399 | } |
| 400 | |
| 401 | function ColorMarkButton({ |
| 402 | defaultColor, |
| 403 | onCommit |
| 404 | }: { |
| 405 | defaultColor?: string |
| 406 | onCommit?: (value: RichTextValue) => void |
| 407 | }): React.JSX.Element { |
| 408 | const editor = useSlate() |
| 409 | const selectionRef = useRef<BaseSelection>(null) |
| 410 | const color = useSlateSelector((selectorEditor) => getCurrentColor(selectorEditor, defaultColor)) |
| 411 | const captureSelection = (): void => { |
| 412 | if (editor.selection && Range.isExpanded(editor.selection)) { |
| 413 | selectionRef.current = editor.selection |
| 414 | } |
| 415 | } |
| 416 | return ( |
| 417 | <label |
| 418 | className="relative inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border border-[#d9cdb8]/80 bg-white/60 transition-colors hover:bg-[#d4e4c1]/70" |
| 419 | title="Text color" |
| 420 | onMouseDown={captureSelection} |
| 421 | onFocus={captureSelection} |
| 422 | > |
| 423 | <span |
| 424 | className="h-3.5 w-3.5 rounded-full border border-black/10" |
| 425 | style={{ backgroundColor: color }} |
| 426 | /> |
| 427 | <input |
| 428 | type="color" |
| 429 | className="absolute inset-0 h-full w-full cursor-pointer opacity-0" |
| 430 | value={/^#[0-9a-f]{6}$/i.test(color) ? color : '#34402c'} |
| 431 | onChange={(event) => { |
| 432 | const nextValue = setColorMark(editor, event.target.value, selectionRef.current) |
| 433 | if (nextValue) onCommit?.(nextValue) |
| 434 | }} |
| 435 | /> |
| 436 | </label> |
| 437 | ) |
| 438 | } |
| 439 | |
| 440 | function FontSizeMarkInput({ defaultFontSize }: { defaultFontSize?: string }): React.JSX.Element { |
| 441 | const editor = useSlate() |
| 442 | const selectionRef = useRef<BaseSelection>(null) |
| 443 | const value = useSlateSelector((selectorEditor) => |
| 444 | getCurrentFontSize(selectorEditor, defaultFontSize) |
| 445 | ) |
| 446 | const captureSelection = (): void => { |
| 447 | if (editor.selection && Range.isExpanded(editor.selection)) { |
| 448 | selectionRef.current = editor.selection |
| 449 | } |
| 450 | } |
| 451 | return ( |
| 452 | <input |
| 453 | type="number" |
| 454 | min={16} |
| 455 | max={100} |
| 456 | value={String(Math.max(16, Math.min(100, Math.round(value))))} |
| 457 | title="Font size" |
| 458 | onMouseDown={captureSelection} |
| 459 | onFocus={captureSelection} |
| 460 | onChange={(event) => setFontSizeMark(editor, Number(event.target.value), selectionRef.current)} |
| 461 | className="h-6 w-[58px] rounded-md border border-[#d9cdb8]/80 bg-white/70 px-1 text-[11px] text-[#3f4b35] outline-none focus:border-[#9bb98a]" |
| 462 | /> |
| 463 | ) |
| 464 | } |
| 465 | |
| 466 | function ToolbarButton({ mark, label, icon: Icon }: (typeof commandButtons)[number]): React.JSX.Element { |
| 467 | const editor = useSlate() |
| 468 | const active = isMarkActive(editor, mark) |
| 469 | return ( |
| 470 | <button |
| 471 | type="button" |
| 472 | aria-label={label} |
| 473 | title={label} |
| 474 | onMouseDown={(event) => { |
| 475 | event.preventDefault() |
| 476 | toggleMark(editor, mark) |
| 477 | }} |
| 478 | className={cn( |
| 479 | 'inline-flex h-6 w-6 items-center justify-center rounded-md text-[#5f6e50] transition-colors hover:bg-[#d4e4c1]/70 hover:text-[#34402c]', |
| 480 | active && 'bg-[#d4e4c1]/80 text-[#34402c]' |
| 481 | )} |
| 482 | > |
| 483 | <Icon className="h-3.5 w-3.5" /> |
| 484 | </button> |
| 485 | ) |
| 486 | } |
| 487 | |
| 488 | export function RichTextBox({ |
| 489 | value, |
| 490 | fallbackText = '', |
| 491 | defaultColor, |
| 492 | defaultFontSize, |
| 493 | previewScale, |
| 494 | onChange, |
| 495 | onCommit, |
| 496 | className |
| 497 | }: { |
| 498 | value: string |
| 499 | fallbackText?: string |
| 500 | defaultColor?: string |
| 501 | defaultFontSize?: string |
| 502 | previewScale?: number |
| 503 | onChange: (value: RichTextValue) => void |
| 504 | onCommit?: (value: RichTextValue) => void |
| 505 | className?: string |
| 506 | }): React.JSX.Element { |
| 507 | const rootRef = useRef<HTMLDivElement | null>(null) |
| 508 | const [revision, setRevision] = useState(0) |
| 509 | const [initialValue, setInitialValue] = useState<Descendant[]>(() => |
| 510 | deserializeHtml(value, fallbackText) |
| 511 | ) |
| 512 | const [focused, setFocused] = useState(false) |
| 513 | const editorColor = normalizeColor(defaultColor) |
| 514 | const editorFontSize = normalizeFontSize(defaultFontSize) |
| 515 | const safePreviewScale = |
| 516 | Number.isFinite(previewScale) && previewScale && previewScale > 0 |
| 517 | ? Math.max(0.1, Math.min(1, previewScale)) |
| 518 | : undefined |
| 519 | const editorZoom = safePreviewScale ?? getEditorZoom(initialValue, editorFontSize) |
| 520 | const editor = useMemo( |
| 521 | () => withInlineRichText(withHistory(withReact(createEditor()))), |
| 522 | [revision] |
| 523 | ) |
| 524 | |
| 525 | useEffect(() => { |
| 526 | if (focused) return |
| 527 | setInitialValue(deserializeHtml(value, fallbackText)) |
| 528 | setRevision((current) => current + 1) |
| 529 | }, [fallbackText, focused, value]) |
| 530 | |
| 531 | const renderElement = useCallback((props: RenderElementProps) => { |
| 532 | const element = props.element |
| 533 | if (element.type === 'span') { |
| 534 | return ( |
| 535 | <span |
| 536 | {...props.attributes} |
| 537 | style={parseStyleAttribute(element.style)} |
| 538 | data-block-id={element.blockId} |
| 539 | className={element.className} |
| 540 | > |
| 541 | {props.children} |
| 542 | </span> |
| 543 | ) |
| 544 | } |
| 545 | if (element.type === 'link') { |
| 546 | return ( |
| 547 | <a {...props.attributes} href={element.href}> |
| 548 | {props.children} |
| 549 | </a> |
| 550 | ) |
| 551 | } |
| 552 | return <div {...props.attributes}>{props.children}</div> |
| 553 | }, []) |
| 554 | |
| 555 | const renderLeaf = useCallback((props: RenderLeafProps) => { |
| 556 | let children = props.children |
| 557 | if (props.leaf.bold) children = <strong>{children}</strong> |
| 558 | if (props.leaf.italic) children = <em>{children}</em> |
| 559 | if (props.leaf.underline) children = <u>{children}</u> |
| 560 | return ( |
| 561 | <span |
| 562 | {...props.attributes} |
| 563 | style={{ |
| 564 | color: props.leaf.color, |
| 565 | fontSize: props.leaf.fontSize, |
| 566 | textShadow: props.leaf.color ? getTextPreviewShadow(props.leaf.color) || 'none' : undefined |
| 567 | }} |
| 568 | > |
| 569 | {children} |
| 570 | </span> |
| 571 | ) |
| 572 | }, []) |
| 573 | |
| 574 | const handleRootBlur = (event: React.FocusEvent<HTMLDivElement>): void => { |
| 575 | const nextTarget = event.relatedTarget |
| 576 | if (nextTarget instanceof Node && rootRef.current?.contains(nextTarget)) { |
| 577 | return |
| 578 | } |
| 579 | setFocused(false) |
| 580 | onCommit?.(serializeValue(editor.children)) |
| 581 | } |
| 582 | |
| 583 | return ( |
| 584 | <div |
| 585 | ref={rootRef} |
| 586 | onFocus={() => setFocused(true)} |
| 587 | onBlur={handleRootBlur} |
| 588 | className="overflow-hidden rounded-[1rem] border border-[#ded2bd]/72 bg-[#fffdf8]/88 shadow-[inset_0_1px_2px_rgba(74,59,42,0.05)]" |
| 589 | > |
| 590 | <Slate |
| 591 | key={revision} |
| 592 | editor={editor} |
| 593 | initialValue={initialValue} |
| 594 | onChange={(nextValue) => onChange(serializeValue(nextValue))} |
| 595 | > |
| 596 | <div className="flex h-8 items-center gap-1 border-b border-[#ded2bd]/60 bg-[#fbf6ec]/78 px-2"> |
| 597 | {commandButtons.map((button) => ( |
| 598 | <ToolbarButton key={button.mark} {...button} /> |
| 599 | ))} |
| 600 | <ColorMarkButton defaultColor={defaultColor} onCommit={onCommit} /> |
| 601 | <FontSizeMarkInput defaultFontSize={defaultFontSize} /> |
| 602 | </div> |
| 603 | <Editable |
| 604 | renderElement={renderElement} |
| 605 | renderLeaf={renderLeaf} |
| 606 | style={{ |
| 607 | color: editorColor, |
| 608 | fontSize: editorFontSize, |
| 609 | lineHeight: 'normal', |
| 610 | whiteSpace: 'pre', |
| 611 | zoom: editorZoom, |
| 612 | caretColor: '#20271f', |
| 613 | textShadow: getTextPreviewShadow(editorColor) |
| 614 | }} |
| 615 | className={cn( |
| 616 | 'min-h-[120px] overflow-auto px-3 py-2 outline-none selection:bg-[#d4e4c1]/72 selection:text-[#20271f] focus-visible:bg-white/40 [&_a]:underline [&_b]:font-bold [&_strong]:font-bold [&_i]:italic [&_em]:italic [&_u]:underline', |
| 617 | className |
| 618 | )} |
| 619 | /> |
| 620 | </Slate> |
| 621 | </div> |
| 622 | ) |
| 623 | } |
| 624 |