элемент заметка
This commit is contained in:
@@ -46,9 +46,13 @@
|
|||||||
"react-day-picker": "^9.8.0",
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-grid-layout": "^1.5.2",
|
"react-grid-layout": "^1.5.2",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-rnd": "^10.5.2",
|
"react-rnd": "^10.5.2",
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"redux": "^4.2.1",
|
"redux": "^4.2.1",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"use-debounce": "^10.0.5",
|
"use-debounce": "^10.0.5",
|
||||||
|
|||||||
375
src/entity/element/model/implementations/NoteElement.tsx
Normal file
375
src/entity/element/model/implementations/NoteElement.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { buttonGroupDefinition } from '@/entity/element/model/implementations/ButtonGroup'
|
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 { selectDefinition } from '@/entity/element/model/implementations/SelectElement'
|
||||||
import { standardsDefinition } from '@/entity/element/model/implementations/StandardsElement/definition'
|
import { standardsDefinition } from '@/entity/element/model/implementations/StandardsElement/definition'
|
||||||
import { textDefinition } from '@/entity/element/model/implementations/TextElement'
|
import { textDefinition } from '@/entity/element/model/implementations/TextElement'
|
||||||
@@ -62,6 +63,7 @@ export function getElementDefinition(
|
|||||||
export const elementDefinitions = {
|
export const elementDefinitions = {
|
||||||
text: textDefinition,
|
text: textDefinition,
|
||||||
select: selectDefinition,
|
select: selectDefinition,
|
||||||
|
note: noteDefinition,
|
||||||
standards: standardsDefinition,
|
standards: standardsDefinition,
|
||||||
verification_conditions: verificationConditionsDefinition,
|
verification_conditions: verificationConditionsDefinition,
|
||||||
button_group: buttonGroupDefinition,
|
button_group: buttonGroupDefinition,
|
||||||
@@ -72,6 +74,7 @@ export type ElementType = keyof typeof elementDefinitions
|
|||||||
export function initializeElementRegistry() {
|
export function initializeElementRegistry() {
|
||||||
registerElement('text', textDefinition)
|
registerElement('text', textDefinition)
|
||||||
registerElement('select', selectDefinition)
|
registerElement('select', selectDefinition)
|
||||||
|
registerElement('note', noteDefinition)
|
||||||
registerElement('button_group', buttonGroupDefinition)
|
registerElement('button_group', buttonGroupDefinition)
|
||||||
registerElement('standards', standardsDefinition)
|
registerElement('standards', standardsDefinition)
|
||||||
registerElement('verification_conditions', verificationConditionsDefinition)
|
registerElement('verification_conditions', verificationConditionsDefinition)
|
||||||
|
|||||||
Reference in New Issue
Block a user