/
kan64
/
spreadsheet-lab
Обзор
Документация
Войти
/
kan64
/
spreadsheet-lab
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
client/src/components/CellTextEditor.tsx
311 строк
10 KB
Anton Kravchenkov
fix(ui): Formatted text display
25 мар 2026, 00:21
25 мар 2026, 00:21
a355a4d
Код
Авторство
О чём код?
import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; import Konva from 'konva'; import type { TextRun, CellStyle } from '../types/types'; import { textRunsToHtml, htmlToTextRuns } from '../utils/richTextUtils'; const FONT_FAMILY = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; export interface CellTextEditorHandle { applyFormat: (command: string, value?: string) => void; getPlainText: () => string; getRichText: () => TextRun[]; focus: () => void; getSelectedText: () => string; getSelectedLinkUrl: () => string | undefined; saveSelection: () => void; restoreSelection: () => void; applyLink: (url: string, displayText?: string) => void; removeLink: () => void; } interface CellTextEditorProps { richText: TextRun[]; cellStyle?: CellStyle; startWithValue?: string; onValueChange?: (plainText: string) => void; onConfirm: (plainText: string, richText: TextRun[]) => void; onCancel: () => void; } const konvaTextNode = new Konva.Text({ text: '' }); function measureTextWidth(text: string, fontSize: number, bold?: boolean, italic?: boolean): number { const parts: string[] = []; if (italic) parts.push('italic'); if (bold) parts.push('bold'); konvaTextNode.fontStyle(parts.join(' ') || 'normal'); konvaTextNode.fontSize(fontSize); konvaTextNode.fontFamily(FONT_FAMILY); konvaTextNode.text(text); return konvaTextNode.width(); } export const CellTextEditor = forwardRef<CellTextEditorHandle, CellTextEditorProps>(({ richText, cellStyle, startWithValue, onValueChange, onConfirm, onCancel, }, ref) => { const editorRef = useRef<HTMLDivElement>(null); const initializedRef = useRef(false); const savedRangeRef = useRef<Range | null>(null); const getContentData = useCallback(() => { if (!editorRef.current) return { plainText: '', runs: [] as TextRun[] }; const plainText = editorRef.current.innerText?.replace(/\n$/, '') || ''; const runs = htmlToTextRuns(editorRef.current); return { plainText, runs }; }, []); const syncContent = useCallback(() => { if (!editorRef.current) return; const plainText = editorRef.current.innerText?.replace(/\n$/, '') || ''; onValueChange?.(plainText); }, [onValueChange]); useImperativeHandle(ref, () => ({ applyFormat: (command: string, value?: string) => { const el = editorRef.current; if (!el) return; el.focus(); if (command === 'fontSize' && value) { document.execCommand('fontSize', false, '7'); el.querySelectorAll('font[size="7"]').forEach((font) => { const span = document.createElement('span'); span.style.fontSize = `${value}px`; while (font.firstChild) span.appendChild(font.firstChild); font.parentNode?.replaceChild(span, font); }); } else { document.execCommand(command, false, value); } syncContent(); }, getPlainText: () => getContentData().plainText, getRichText: () => getContentData().runs, focus: () => editorRef.current?.focus(), getSelectedText: () => { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return ''; return sel.toString(); }, getSelectedLinkUrl: () => { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return undefined; let node: Node | null = sel.anchorNode; while (node && node !== editorRef.current) { if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === 'A') { return (node as HTMLAnchorElement).href || undefined; } node = node.parentNode; } return undefined; }, saveSelection: () => { const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { savedRangeRef.current = sel.getRangeAt(0).cloneRange(); } }, restoreSelection: () => { const el = editorRef.current; if (!el) return; el.focus(); const sel = window.getSelection(); if (!sel) return; if (savedRangeRef.current) { sel.removeAllRanges(); sel.addRange(savedRangeRef.current); savedRangeRef.current = null; } }, applyLink: (url: string, displayText?: string) => { const el = editorRef.current; if (!el) return; el.focus(); const sel = window.getSelection(); if (!sel) return; if (savedRangeRef.current) { sel.removeAllRanges(); sel.addRange(savedRangeRef.current); savedRangeRef.current = null; } if (sel.isCollapsed && displayText) { const a = document.createElement('a'); a.href = url; a.textContent = displayText; a.style.color = '#1a73e8'; a.style.textDecoration = 'underline'; const range = sel.getRangeAt(0); range.insertNode(a); range.setStartAfter(a); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } else { if (displayText && sel.toString() !== displayText) { const range = sel.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(displayText)); sel.removeAllRanges(); const newRange = document.createRange(); newRange.selectNodeContents(range.startContainer); sel.addRange(newRange); } document.execCommand('createLink', false, url); el.querySelectorAll('a').forEach((a) => { a.style.color = '#1a73e8'; a.style.textDecoration = 'underline'; }); } syncContent(); }, removeLink: () => { const el = editorRef.current; if (!el) return; el.focus(); const sel = window.getSelection(); if (!sel) return; if (savedRangeRef.current) { sel.removeAllRanges(); sel.addRange(savedRangeRef.current); savedRangeRef.current = null; } let anchorEl: HTMLAnchorElement | null = null; let node: Node | null = sel.anchorNode; while (node && node !== el) { if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === 'A') { anchorEl = node as HTMLAnchorElement; break; } node = node.parentNode; } if (anchorEl && anchorEl.parentNode) { const parent = anchorEl.parentNode; const firstChild = anchorEl.firstChild; while (anchorEl.firstChild) { const child = anchorEl.firstChild; if (child.nodeType === Node.ELEMENT_NODE) { const childEl = child as HTMLElement; childEl.style.removeProperty('color'); childEl.style.removeProperty('text-decoration'); if (!childEl.getAttribute('style')?.trim()) { childEl.removeAttribute('style'); } } parent.insertBefore(child, anchorEl); } parent.removeChild(anchorEl); parent.normalize(); if (firstChild) { const range = document.createRange(); range.setStartBefore(firstChild); range.setEndAfter(firstChild); sel.removeAllRanges(); sel.addRange(range); } } else { document.execCommand('unlink'); } syncContent(); }, })); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); const { plainText, runs } = getContentData(); onConfirm(plainText, runs); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel(); } else if (e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); const { plainText, runs } = getContentData(); onConfirm(plainText, runs); } }, [getContentData, onConfirm, onCancel]); useEffect(() => { if (!editorRef.current || initializedRef.current) return; initializedRef.current = true; const el = editorRef.current; if (startWithValue !== undefined) { el.textContent = startWithValue; } else if (richText && richText.length > 0) { el.innerHTML = textRunsToHtml(richText); } else { el.innerHTML = ''; } el.focus(); requestAnimationFrame(() => { if (!el.isConnected) return; const sel = window.getSelection(); if (!sel) return; const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); }); }, []); const fontSize = cellStyle?.fontSize ?? 13; return ( <div ref={editorRef} contentEditable suppressContentEditableWarning onKeyDown={handleKeyDown} onInput={syncContent} onPaste={() => requestAnimationFrame(syncContent)} onClick={(e) => { if ((e.target as HTMLElement).tagName === 'A') { e.preventDefault(); } }} style={{ fontFamily: FONT_FAMILY, fontSize: `${fontSize}px`, fontStyle: cellStyle?.italic ? 'italic' : 'normal', fontWeight: cellStyle?.bold ? 'bold' : 'normal', color: cellStyle?.color || '#202124', textAlign: cellStyle?.textAlign || 'left', padding: '1px 2px', boxSizing: 'border-box', outline: 'none', background: cellStyle?.backgroundColor || 'white', overflowWrap: 'break-word', whiteSpace: cellStyle?.wrapText ? 'pre-wrap' : 'pre', lineHeight: '1.25', minHeight: '100%', width: '100%', cursor: 'text', }} /> ); }); CellTextEditor.displayName = 'CellTextEditor'; export { measureTextWidth };