diff --git a/package.json b/package.json index 3ea38dc..4256b13 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,13 @@ "react-day-picker": "^9.8.0", "react-dom": "^18.2.0", "react-grid-layout": "^1.5.2", + "react-markdown": "^10.1.0", "react-rnd": "^10.5.2", "react-window": "^1.8.11", "redux": "^4.2.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "use-debounce": "^10.0.5", @@ -79,4 +83,4 @@ "typescript": "^4.4.4", "vite": "^4.3.9" } -} +} \ No newline at end of file diff --git a/src/entity/element/model/implementations/NoteElement.tsx b/src/entity/element/model/implementations/NoteElement.tsx new file mode 100644 index 0000000..3865be3 --- /dev/null +++ b/src/entity/element/model/implementations/NoteElement.tsx @@ -0,0 +1,375 @@ +import { ElementDefinition } from '@/entity/element/model/interface' +import { CellsBadge } from '@/entity/element/ui/cellsBadge' +import { + Bold, + Code, + FileText, + Heading1, + Heading2, + Heading3, + Italic, + Link, + List, + ListOrdered, + Quote, + Strikethrough, + Table, +} from 'lucide-react' +import { useState } from 'react' +import ReactMarkdown, { Components } from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import remarkGfm from 'remark-gfm' +import { Button } from 'shared/ui/button' +import { Label } from 'shared/ui/label' +import { Textarea } from 'shared/ui/textarea' + +interface NoteConfig { + content?: string // Основное содержимое markdown + targetCells: any[] + name?: string + id?: string +} + +// Компонент панели инструментов +const MarkdownToolbar = ({ + onInsert, + disabled = false, +}: { + onInsert: (text: string) => void + disabled?: boolean +}) => { + const tools = [ + { icon: Bold, label: 'Жирный', syntax: '**текст**' }, + { icon: Italic, label: 'Курсив', syntax: '*текст*' }, + { icon: Strikethrough, label: 'Зачеркнутый', syntax: '~~текст~~' }, + { icon: Heading1, label: 'Заголовок 1', syntax: '# Заголовок' }, + { icon: Heading2, label: 'Заголовок 2', syntax: '## Заголовок' }, + { icon: Heading3, label: 'Заголовок 3', syntax: '### Заголовок' }, + { icon: List, label: 'Список', syntax: '- Элемент списка' }, + { + icon: ListOrdered, + label: 'Нумерованный список', + syntax: '1. Элемент списка', + }, + { icon: Quote, label: 'Цитата', syntax: '> Цитата' }, + { icon: Code, label: 'Код', syntax: '`код`' }, + { icon: Link, label: 'Ссылка', syntax: '[текст](url)' }, + { + icon: Table, + label: 'Таблица', + syntax: + '| Колонка 1 | Колонка 2 |\n|-----------|----------|\n| Ячейка 1 | Ячейка 2 |', + }, + ] + + return ( +
+ {tools.map((tool, index) => ( + + ))} +
+ ) +} + +// Компонент предварительного просмотра markdown +const MarkdownPreview = ({ content }: { content: string }) => { + return ( +
+ ( +

{children}

+ ), + h2: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + h3: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + p: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + ul: ({ children }: { children: React.ReactNode }) => ( + + ), + ol: ({ children }: { children: React.ReactNode }) => ( +
    + {children} +
+ ), + li: ({ children }: { children: React.ReactNode }) => ( +
  • {children}
  • + ), + blockquote: ({ children }: { children: React.ReactNode }) => ( +
    + {children} +
    + ), + code: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + pre: ({ children }: { children: React.ReactNode }) => ( +
    +                {children}
    +              
    + ), + table: ({ children }: { children: React.ReactNode }) => ( + + {children} +
    + ), + th: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + td: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + a: ({ + children, + href, + }: { + children: React.ReactNode + href?: string + }) => ( + + {children} + + ), + } as Components + } + > + {content || '*Введите текст для предварительного просмотра...*'} +
    +
    + ) +} + +export const noteDefinition: ElementDefinition = { + type: 'note', + label: 'Заметка', + icon: , + version: 1, + defaultConfig: { + content: '', + targetCells: [], + name: '', + }, + getInitialValue: config => { + return config.content || '' + }, + mapToCells: config => config.targetCells || [], + mapToCellValues: (config, value) => { + const cells = config.targetCells || [] + return cells.map(target => ({ target, value: value || '' })) + }, + Editor: ({ config, onChange }) => { + const [markdownContent, setMarkdownContent] = useState(config.content || '') + + const handleContentChange = (value: string) => { + setMarkdownContent(value) + onChange({ ...config, content: value }) + } + + const insertText = (syntax: string) => { + const textarea = document.querySelector( + 'textarea[data-markdown-editor]' + ) as HTMLTextAreaElement + if (!textarea) return + + const start = textarea.selectionStart + const end = textarea.selectionEnd + const selectedText = markdownContent.substring(start, end) + + let insertedText = syntax + let cursorPosition = start + insertedText.length + + if (selectedText) { + // Если есть выделенный текст, оборачиваем его соответствующим синтаксисом + if (syntax.includes('**текст**')) { + insertedText = `**${selectedText}**` + cursorPosition = start + insertedText.length + } else if (syntax.includes('*текст*')) { + insertedText = `*${selectedText}*` + cursorPosition = start + insertedText.length + } else if (syntax.includes('~~текст~~')) { + insertedText = `~~${selectedText}~~` + cursorPosition = start + insertedText.length + } else if (syntax.includes('`код`')) { + insertedText = `\`${selectedText}\`` + cursorPosition = start + insertedText.length + } else if (syntax.includes('[текст](url)')) { + insertedText = `[${selectedText}](url)` + cursorPosition = start + selectedText.length + 3 // Курсор на "url" + } else if (syntax.includes('# Заголовок')) { + insertedText = `# ${selectedText}` + cursorPosition = start + insertedText.length + } else if (syntax.includes('## Заголовок')) { + insertedText = `## ${selectedText}` + cursorPosition = start + insertedText.length + } else if (syntax.includes('### Заголовок')) { + insertedText = `### ${selectedText}` + cursorPosition = start + insertedText.length + } else if (syntax.includes('> Цитата')) { + insertedText = `> ${selectedText}` + cursorPosition = start + insertedText.length + } else if (syntax.includes('- Элемент списка')) { + insertedText = `- ${selectedText}` + cursorPosition = start + insertedText.length + } else if (syntax.includes('1. Элемент списка')) { + insertedText = `1. ${selectedText}` + cursorPosition = start + insertedText.length + } else { + // Для остальных случаев просто заменяем placeholder + insertedText = syntax.replace('текст', selectedText) + cursorPosition = start + insertedText.length + } + } else { + // Если нет выделенного текста, вставляем шаблон + insertedText = syntax + + // Позиционируем курсор внутри шаблона для редактирования + if (syntax.includes('**текст**')) { + cursorPosition = start + 2 // После ** + } else if (syntax.includes('*текст*')) { + cursorPosition = start + 1 // После * + } else if (syntax.includes('~~текст~~')) { + cursorPosition = start + 2 // После ~~ + } else if (syntax.includes('`код`')) { + cursorPosition = start + 1 // После ` + } else if (syntax.includes('[текст](url)')) { + cursorPosition = start + 1 // После [ + } else if (syntax.includes('# Заголовок')) { + cursorPosition = start + 2 // После "# " + } else if (syntax.includes('## Заголовок')) { + cursorPosition = start + 3 // После "## " + } else if (syntax.includes('### Заголовок')) { + cursorPosition = start + 4 // После "### " + } else if (syntax.includes('> Цитата')) { + cursorPosition = start + 2 // После "> " + } else if (syntax.includes('- Элемент списка')) { + cursorPosition = start + 2 // После "- " + } else if (syntax.includes('1. Элемент списка')) { + cursorPosition = start + 3 // После "1. " + } else { + cursorPosition = start + insertedText.length + } + } + + const newValue = + markdownContent.substring(0, start) + + insertedText + + markdownContent.substring(end) + handleContentChange(newValue) + + // Возвращаем фокус в textarea + setTimeout(() => { + textarea.focus() + if (!selectedText && syntax.includes('[текст](url)')) { + // Для ссылок выделяем "текст" + textarea.setSelectionRange(start + 1, start + 6) + } else if ( + !selectedText && + (syntax.includes('**текст**') || + syntax.includes('*текст*') || + syntax.includes('~~текст~~') || + syntax.includes('`код`')) + ) { + // Для форматирования выделяем placeholder + const placeholderStart = + syntax.indexOf('текст') !== -1 + ? insertedText.indexOf('текст') + start + : insertedText.indexOf('код') + start + const placeholderLength = syntax.includes('код') ? 3 : 5 + textarea.setSelectionRange( + placeholderStart, + placeholderStart + placeholderLength + ) + } else { + textarea.setSelectionRange(cursorPosition, cursorPosition) + } + }, 0) + } + + return ( +
    +
    + +
    + +