返回 oh-my-ppt
RichTextBox.tsx
根目录 / src / renderer / src / components / ui / RichTextBox.tsx
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, '&amp;')
68 .replace(/</g, '&lt;')
69 .replace(/>/g, '&gt;')
70 .replace(/"/g, '&quot;')
71
72 const escapeAttribute = (value: string): string => escapeHtml(value).replace(/'/g, '&#39;')
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
624 lines Plain Text