элемент заметка
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
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 { 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)
|
||||
|
||||
Reference in New Issue
Block a user