/
kan64
/
spreadsheet-lab
Обзор
Документация
Войти
/
kan64
/
spreadsheet-lab
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
client/src/components/Toolbar.tsx
511 строк
21 KB
Anton Kravchenkov
feat(ui): Add rich text cells
23 мар 2026, 23:33
23 мар 2026, 23:33
7b25f1e
Код
Авторство
О чём код?
import React, { useState, useRef, useEffect } from 'react'; import { BiArrowToLeft, BiArrowToTop } from 'react-icons/bi'; import { CellStyle, CellRange } from '../types/types.ts'; import { BorderPicker, BorderPreset } from './BorderPicker'; import { ColorPicker } from './ColorPicker'; import { DEFAULT_COL_WIDTH } from '../types/types.ts'; interface ToolbarProps { selectedRange: CellRange | null; selectedCell: { row: number; col: number } | null; currentStyle: CellStyle; currentCellHasLink: boolean; isMerged: boolean; canMerge: boolean; isEditing: boolean; columnWidths: Record<number, number>; frozenRows: number; frozenCols: number; onStyleChange: (style: Partial<CellStyle>) => void; onApplyBorder: (preset: BorderPreset, color: string) => void; onInsertLink: () => void; onMergeCells: () => void; onUnmergeCells: () => void; onSetColumnsWidth: (width: number) => void; onFreeze: (rows: number, cols: number) => void; onInsertComment?: () => void; onUndo: () => void; onRedo: () => void; } export const Toolbar: React.FC<ToolbarProps> = ({ selectedRange, selectedCell, currentStyle, currentCellHasLink, isMerged, canMerge, isEditing, columnWidths, frozenRows, frozenCols, onStyleChange, onApplyBorder, onInsertLink, onMergeCells, onUnmergeCells, onSetColumnsWidth, onFreeze, onInsertComment, onUndo, onRedo, }) => { const isDisabled = !selectedRange; const [borderColor, setBorderColor] = useState('#000000'); const [columnWidthInput, setColumnWidthInput] = useState(''); const [freezeMenuOpen, setFreezeMenuOpen] = useState(false); const freezeMenuRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!freezeMenuOpen) return; const handleClickOutside = (e: MouseEvent) => { if (freezeMenuRef.current && !freezeMenuRef.current.contains(e.target as Node)) { setFreezeMenuOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [freezeMenuOpen]); const selectedCols = selectedRange ? (() => { const sc = Math.min(selectedRange.start.col, selectedRange.end.col); const ec = Math.max(selectedRange.start.col, selectedRange.end.col); const cols: number[] = []; for (let c = sc; c <= ec; c++) cols.push(c); return cols; })() : []; const selectedColsWidths = selectedCols.map((c) => columnWidths[c] ?? DEFAULT_COL_WIDTH); const displayWidth = selectedColsWidths.length ? selectedColsWidths.every((w) => w === selectedColsWidths[0]) ? String(selectedColsWidths[0]) : '' : ''; return ( <div className="toolbar" onMouseDown={isEditing ? (e) => e.preventDefault() : undefined}> <div className="toolbar-group"> <button className="toolbar-btn" onClick={onUndo} title="Отменить (Ctrl+Z)"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M3 10h10a5 5 0 0 1 0 10H9" /> <path d="M3 10l4-4M3 10l4 4" /> </svg> </button> <button className="toolbar-btn" onClick={onRedo} title="Повторить (Ctrl+Y)"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M21 10H11a5 5 0 0 0 0 10h4" /> <path d="M21 10l-4-4M21 10l-4 4" /> </svg> </button> </div> <div className="toolbar-separator" /> <div className="toolbar-group"> <button className={`toolbar-btn ${currentStyle.bold ? 'active' : ''}`} onClick={() => onStyleChange({ bold: !currentStyle.bold })} disabled={isDisabled} title="Жирный (Ctrl+B)" > <strong>B</strong> </button> <button className={`toolbar-btn ${currentStyle.italic ? 'active' : ''}`} onClick={() => onStyleChange({ italic: !currentStyle.italic })} disabled={isDisabled} title="Курсив (Ctrl+I)" > <em>I</em> </button> <button className={`toolbar-btn ${currentStyle.underline ? 'active' : ''}`} onClick={() => onStyleChange({ underline: !currentStyle.underline })} disabled={isDisabled} title="Подчёркнутый (Ctrl+U)" > <span style={{ textDecoration: 'underline' }}>U</span> </button> <button className={`toolbar-btn ${currentStyle.strikethrough ? 'active' : ''}`} onClick={() => onStyleChange({ strikethrough: !currentStyle.strikethrough } as Partial<CellStyle>)} disabled={isDisabled} title="Зачёркнутый" > <span style={{ textDecoration: 'line-through' }}>S</span> </button> <select className="toolbar-select" value={currentStyle.fontSize ?? 13} onChange={(e) => onStyleChange({ fontSize: Number(e.target.value) })} disabled={isDisabled} title="Размер шрифта" > {[8, 9, 10, 11, 12, 13, 14, 16, 18, 20, 24, 28, 32, 36].map((s) => ( <option key={s} value={s}>{s}</option> ))} </select> </div> <div className="toolbar-separator" /> <div className="toolbar-group"> <button className={`toolbar-btn ${currentStyle.textAlign === 'left' ? 'active' : ''}`} onClick={() => onStyleChange({ textAlign: 'left' })} disabled={isDisabled} title="По левому краю" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="15" y2="12" /><line x1="3" y1="18" x2="18" y2="18" /> </svg> </button> <button className={`toolbar-btn ${currentStyle.textAlign === 'center' ? 'active' : ''}`} onClick={() => onStyleChange({ textAlign: 'center' })} disabled={isDisabled} title="По центру" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="3" y1="6" x2="21" y2="6" /><line x1="6" y1="12" x2="18" y2="12" /><line x1="4" y1="18" x2="20" y2="18" /> </svg> </button> <button className={`toolbar-btn ${currentStyle.textAlign === 'right' ? 'active' : ''}`} onClick={() => onStyleChange({ textAlign: 'right' })} disabled={isDisabled} title="По правому краю" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="3" y1="6" x2="21" y2="6" /><line x1="9" y1="12" x2="21" y2="12" /><line x1="6" y1="18" x2="21" y2="18" /> </svg> </button> </div> <div className="toolbar-separator" /> <div className="toolbar-group"> <button className={`toolbar-btn ${currentStyle.verticalAlign === 'top' ? 'active' : ''}`} onClick={() => onStyleChange({ verticalAlign: 'top' })} disabled={isDisabled} title="По верхнему краю" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="6" y1="4" x2="6" y2="20" /><line x1="12" y1="8" x2="12" y2="20" /><line x1="18" y1="12" x2="18" y2="20" /> </svg> </button> <button className={`toolbar-btn ${currentStyle.verticalAlign === 'middle' ? 'active' : ''}`} onClick={() => onStyleChange({ verticalAlign: 'middle' })} disabled={isDisabled} title="По центру по вертикали" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="6" y1="8" x2="6" y2="16" /><line x1="12" y1="6" x2="12" y2="18" /><line x1="18" y1="10" x2="18" y2="14" /> </svg> </button> <button className={`toolbar-btn ${currentStyle.verticalAlign === 'bottom' ? 'active' : ''}`} onClick={() => onStyleChange({ verticalAlign: 'bottom' })} disabled={isDisabled} title="По нижнему краю" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="6" y1="4" x2="6" y2="16" /><line x1="12" y1="4" x2="12" y2="16" /><line x1="18" y1="4" x2="18" y2="12" /> </svg> </button> </div> <div className="toolbar-separator" /> <div className="toolbar-group"> <button className={`toolbar-btn ${currentStyle.wrapText ? 'active' : ''}`} onClick={() => onStyleChange({ wrapText: !currentStyle.wrapText })} disabled={isDisabled} title="Перенос текста" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <line x1="3" y1="6" x2="21" y2="6" /> <path d="M3 12h15a3 3 0 1 1 0 6h-4" /> <polyline points="13 15 10 18 13 21" /> </svg> </button> <button className={`toolbar-btn ${currentStyle.textOrientation === 'vertical' ? 'active' : ''}`} onClick={() => onStyleChange({ textOrientation: currentStyle.textOrientation === 'vertical' ? 'horizontal' : 'vertical' })} disabled={isDisabled} title={currentStyle.textOrientation === 'vertical' ? 'Вертикальный текст' : 'Горизонтальный текст'} > {currentStyle.textOrientation === 'vertical' ? ( <BiArrowToLeft size={16} /> ) : ( <BiArrowToTop size={16} /> )} </button> </div> <div className="toolbar-separator" /> <div className="toolbar-group"> <ColorPicker currentColor={currentStyle.color} defaultColor="#000000" onColorChange={(color) => onStyleChange({ color })} onReset={() => onStyleChange({ color: undefined })} disabled={isDisabled} title="Цвет текста" resetLabel="По умолчанию" icon={ <span style={{ color: currentStyle.color || '#000', fontWeight: 'bold', fontSize: 14 }}>A</span> } /> <ColorPicker currentColor={currentStyle.backgroundColor} defaultColor="#ffffff" onColorChange={(color) => onStyleChange({ backgroundColor: color })} onReset={() => onStyleChange({ backgroundColor: undefined })} disabled={isDisabled} title="Цвет заливки" resetLabel="Без заливки" icon={ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" strokeWidth="1.5"> <path d="M2 20 L12 4 L22 20 Z" fill={currentStyle.backgroundColor || '#ffffff'} stroke="#555" /> </svg> } /> </div> <div className="toolbar-separator" /> <div className="toolbar-group"> <BorderPicker borderColor={borderColor} onBorderColorChange={setBorderColor} onApplyBorder={(preset) => onApplyBorder(preset, borderColor)} disabled={isDisabled} /> </div> <div className="toolbar-separator" /> <div className="toolbar-group"> <select className="toolbar-select" value={currentStyle.format || 'general'} onChange={(e) => onStyleChange({ format: e.target.value as CellStyle['format'] })} disabled={isDisabled} title="Формат" > <option value="general">Общий</option> <option value="number">Число</option> <option value="currency">Валюта</option> <option value="percent">Процент</option> <option value="date">Дата</option> </select> </div> <div className="toolbar-separator" /> <div className="toolbar-group toolbar-column-width"> <label className="toolbar-label" title="Ширина выбранных столбцов"> Ширина: </label> <input type="number" className="toolbar-input" min={20} max={500} step={5} placeholder={displayWidth || '100'} value={columnWidthInput} onChange={(e) => setColumnWidthInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && columnWidthInput) { const w = parseInt(columnWidthInput, 10); if (!isNaN(w) && w >= 20 && w <= 500) { onSetColumnsWidth(w); setColumnWidthInput(''); } } }} onBlur={() => { if (columnWidthInput) { const w = parseInt(columnWidthInput, 10); if (!isNaN(w) && w >= 20 && w <= 500) { onSetColumnsWidth(w); } setColumnWidthInput(''); } }} disabled={isDisabled || selectedCols.length === 0} /> <span className="toolbar-unit">px</span> </div> <div className="toolbar-separator" /> <div className="toolbar-group"> <button className={`toolbar-btn ${currentCellHasLink ? 'active' : ''}`} onClick={onInsertLink} disabled={isDisabled} title="Вставить ссылку (Ctrl+K)" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> </svg> </button> {onInsertComment && ( <button type="button" className="toolbar-btn" onClick={onInsertComment} disabled={isDisabled} title="Вставить комментарий" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> </svg> </button> )} </div> <div className="toolbar-separator" /> <div className="toolbar-group toolbar-freeze-wrapper" ref={freezeMenuRef} title="Закрепить области"> <button type="button" className={`toolbar-btn toolbar-freeze-btn ${freezeMenuOpen ? 'active' : ''}`} onClick={() => setFreezeMenuOpen((v) => !v)} title="Закрепление строк и столбцов" > <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <rect x="4" y="4" width="16" height="16" rx="1" /> <rect x="4" y="4" width="16" height="5" rx="0.5" fill="currentColor" fillOpacity="0.35" stroke="none" /> <rect x="4" y="4" width="5" height="16" rx="0.5" fill="currentColor" fillOpacity="0.35" stroke="none" /> <line x1="4" y1="9" x2="20" y2="9" strokeWidth="2" /> <line x1="9" y1="4" x2="9" y2="20" strokeWidth="2" /> <line x1="4" y1="14" x2="20" y2="14" /> <line x1="14" y1="4" x2="14" y2="20" /> </svg> <svg className="toolbar-freeze-chevron" width="10" height="10" viewBox="0 0 16 16" fill="none"> <path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> </button> {freezeMenuOpen && ( <div className="toolbar-freeze-dropdown"> <button type="button" className={`toolbar-btn ${frozenRows === 0 && frozenCols === 0 ? 'active' : ''}`} title="Снять закрепление" onClick={() => { onFreeze(0, 0); setFreezeMenuOpen(false); }} > <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <rect x="4" y="4" width="16" height="16" rx="1" /> <line x1="4" y1="10" x2="20" y2="10" /> <line x1="10" y1="4" x2="10" y2="20" /> <line x1="4" y1="16" x2="20" y2="16" /> <line x1="16" y1="4" x2="16" y2="20" /> </svg> </button> <button type="button" className={`toolbar-btn ${frozenRows === 1 && frozenCols === 0 ? 'active' : ''}`} title="Закрепить первую строку" onClick={() => { onFreeze(1, 0); setFreezeMenuOpen(false); }} > <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <rect x="4" y="4" width="16" height="16" rx="1" /> <rect x="4" y="4" width="16" height="5" rx="0.5" fill="currentColor" fillOpacity="0.35" stroke="none" /> <line x1="4" y1="9" x2="20" y2="9" strokeWidth="2" /> <line x1="10" y1="4" x2="10" y2="20" /> <line x1="16" y1="4" x2="16" y2="20" /> </svg> </button> <button type="button" className={`toolbar-btn ${frozenRows === 0 && frozenCols === 1 ? 'active' : ''}`} title="Закрепить первый столбец" onClick={() => { onFreeze(0, 1); setFreezeMenuOpen(false); }} > <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <rect x="4" y="4" width="16" height="16" rx="1" /> <rect x="4" y="4" width="5" height="16" rx="0.5" fill="currentColor" fillOpacity="0.35" stroke="none" /> <line x1="4" y1="10" x2="20" y2="10" /> <line x1="9" y1="4" x2="9" y2="20" strokeWidth="2" /> <line x1="4" y1="16" x2="20" y2="16" /> <line x1="16" y1="4" x2="16" y2="20" /> </svg> </button> <button type="button" className={`toolbar-btn ${(frozenRows > 0 && frozenCols > 0) || (frozenRows > 1) || (frozenCols > 1) ? 'active' : ''}`} title="Закрепить по выделению" onClick={() => { if (selectedCell) { onFreeze(selectedCell.row, selectedCell.col); setFreezeMenuOpen(false); } }} disabled={!selectedCell} > <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <rect x="4" y="4" width="16" height="16" rx="1" /> <rect x="4" y="4" width="16" height="5" rx="0.5" fill="currentColor" fillOpacity="0.35" stroke="none" /> <rect x="4" y="4" width="5" height="16" rx="0.5" fill="currentColor" fillOpacity="0.35" stroke="none" /> <line x1="4" y1="9" x2="20" y2="9" strokeWidth="2" /> <line x1="9" y1="4" x2="9" y2="20" strokeWidth="2" /> <line x1="4" y1="14" x2="20" y2="14" /> <line x1="14" y1="4" x2="14" y2="20" /> </svg> </button> </div> )} </div> <div className="toolbar-separator" /> <div className="toolbar-group"> {isMerged ? ( <button className="toolbar-btn active" onClick={onUnmergeCells} disabled={isDisabled} title="Отменить объединение" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <rect x="3" y="3" width="18" height="18" rx="1" /> <line x1="12" y1="3" x2="12" y2="21" /> <line x1="3" y1="12" x2="21" y2="12" /> </svg> </button> ) : ( <button className="toolbar-btn" onClick={onMergeCells} disabled={isDisabled || !canMerge} title="Объединить ячейки" > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <rect x="3" y="3" width="18" height="18" rx="1" /> <path d="M9 3v18M3 9h18" opacity="0.3" /> </svg> </button> )} </div> </div> ); };