элемент заметка

This commit is contained in:
2025-08-06 00:26:15 +03:00
parent f66499467c
commit 21c89e3a5b
4 changed files with 1342 additions and 72 deletions

View File

@@ -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",

View File

@@ -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 (
<div className="flex flex-wrap gap-1 border-b border-gray-200 bg-gray-50 p-2">
{tools.map((tool, index) => (
<Button
key={index}
variant="ghost"
size="sm"
onClick={() => onInsert(tool.syntax)}
disabled={disabled}
className="h-8 w-8 p-0"
title={tool.label}
>
<tool.icon className="h-3 w-3" />
</Button>
))}
</div>
)
}
// Компонент предварительного просмотра markdown
const MarkdownPreview = ({ content }: { content: string }) => {
return (
<div className="prose prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={
{
// Кастомные стили для компонентов
h1: ({ children }: { children: React.ReactNode }) => (
<h1 className="mb-2 text-lg font-bold">{children}</h1>
),
h2: ({ children }: { children: React.ReactNode }) => (
<h2 className="mb-2 text-base font-bold">{children}</h2>
),
h3: ({ children }: { children: React.ReactNode }) => (
<h3 className="mb-2 text-sm font-bold">{children}</h3>
),
p: ({ children }: { children: React.ReactNode }) => (
<p className="mb-2 text-sm">{children}</p>
),
ul: ({ children }: { children: React.ReactNode }) => (
<ul className="mb-2 list-inside list-disc text-sm">{children}</ul>
),
ol: ({ children }: { children: React.ReactNode }) => (
<ol className="mb-2 list-inside list-decimal text-sm">
{children}
</ol>
),
li: ({ children }: { children: React.ReactNode }) => (
<li className="mb-1">{children}</li>
),
blockquote: ({ children }: { children: React.ReactNode }) => (
<blockquote className="mb-2 border-l-4 border-gray-300 pl-4 italic text-gray-600">
{children}
</blockquote>
),
code: ({ children }: { children: React.ReactNode }) => (
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs">
{children}
</code>
),
pre: ({ children }: { children: React.ReactNode }) => (
<pre className="mb-2 overflow-auto rounded bg-gray-100 p-2 font-mono text-xs">
{children}
</pre>
),
table: ({ children }: { children: React.ReactNode }) => (
<table className="mb-2 w-full border-collapse border border-gray-300 text-xs">
{children}
</table>
),
th: ({ children }: { children: React.ReactNode }) => (
<th className="border border-gray-300 bg-gray-100 px-2 py-1 font-semibold">
{children}
</th>
),
td: ({ children }: { children: React.ReactNode }) => (
<td className="border border-gray-300 px-2 py-1">{children}</td>
),
a: ({
children,
href,
}: {
children: React.ReactNode
href?: string
}) => (
<a
href={href}
className="text-blue-600 underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
} as Components
}
>
{content || '*Введите текст для предварительного просмотра...*'}
</ReactMarkdown>
</div>
)
}
export const noteDefinition: ElementDefinition<NoteConfig, string> = {
type: 'note',
label: 'Заметка',
icon: <FileText className="h-4 w-4" />,
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 (
<div className="space-y-4">
<div>
<Label htmlFor="content" className="text-sm font-medium">
Markdown содержимое
</Label>
<div className="mt-1 overflow-hidden rounded-md border border-gray-200">
<MarkdownToolbar onInsert={insertText} />
<Textarea
data-markdown-editor
value={markdownContent}
onChange={e => handleContentChange(e.target.value)}
placeholder="Введите markdown текст..."
className="resize-none rounded-none border-0 font-mono text-sm focus:ring-0"
style={{ minHeight: 300 }}
/>
</div>
</div>
</div>
)
},
Preview: ({ config }) => {
return (
<div className="space-y-2">
{config.name && (
<Label className="text-sm font-medium text-gray-900">
{config.name}
</Label>
)}
<MarkdownPreview
content={config.content || '*Содержимое будет отображено здесь...*'}
/>
</div>
)
},
Render: ({ config, value, onChange }) => {
return (
<div className="relative space-y-2">
{/* Бейдж ячеек в правом верхнем углу */}
<div className="absolute right-0 top-0 z-10 flex items-center gap-1">
<CellsBadge targetCells={config.targetCells} className="" />
</div>
{/* Лейбл поля */}
{config.name && (
<Label className="text-sm font-medium text-gray-900">
{config.name}
</Label>
)}
{/* Отображение markdown содержимого */}
<MarkdownPreview
content={config.content || value || '*Содержимое не задано...*'}
/>
</div>
)
},
}

View File

@@ -1,4 +1,5 @@
import { buttonGroupDefinition } from '@/entity/element/model/implementations/ButtonGroup'
import { noteDefinition } from '@/entity/element/model/implementations/NoteElement'
import { selectDefinition } from '@/entity/element/model/implementations/SelectElement'
import { standardsDefinition } from '@/entity/element/model/implementations/StandardsElement/definition'
import { textDefinition } from '@/entity/element/model/implementations/TextElement'
@@ -62,6 +63,7 @@ export function getElementDefinition(
export const elementDefinitions = {
text: textDefinition,
select: selectDefinition,
note: noteDefinition,
standards: standardsDefinition,
verification_conditions: verificationConditionsDefinition,
button_group: buttonGroupDefinition,
@@ -72,6 +74,7 @@ export type ElementType = keyof typeof elementDefinitions
export function initializeElementRegistry() {
registerElement('text', textDefinition)
registerElement('select', selectDefinition)
registerElement('note', noteDefinition)
registerElement('button_group', buttonGroupDefinition)
registerElement('standards', standardsDefinition)
registerElement('verification_conditions', verificationConditionsDefinition)

1030
yarn.lock

File diff suppressed because it is too large Load Diff