# Руководство по работе с Vditor ## 6. Отображение контента без редактора ### 6.1 Рендеринг HTML с поддержкой KaTeX ```javascript // Функция для отображения контента без редактора function renderContentWithoutEditor(content, containerId, options = {}) { const container = document.getElementById(containerId); if (!container) { console.error(`Контейнер ${containerId} не найден`); return; } const config = { enableMath: true, enableSyntaxHighlight: true, theme: 'light', ...options }; // Конвертация Markdown в HTML если нужно let htmlContent; if (content.markdown) { htmlContent = convertMarkdownToHTML(content.markdown); } else if (content.html) { htmlContent = content.html; } else { htmlContent = convertMarkdownToHTML(content); } // Создание структуры для отображения container.innerHTML = `
${htmlContent}
`; // Инициализация KaTeX для рендеринга формул if (config.enableMath && typeof katex !== 'undefined') { renderLatexFormulas(container); } // Подсветка синтаксиса if (config.enableSyntaxHighlight && typeof hljs !== 'undefined') { hljs.highlightAll(); } // Добавление стилей если их нет addContentStyles(); } // Рендеринг LaTeX формул в контейнере function renderLatexFormulas(container) { if (!container || typeof katex === 'undefined') return; // Рендеринг display формул ($$...$$) container.querySelectorAll('.language-math.display, .katex-display').forEach(element => { try { const formula = element.textContent.trim(); katex.render(formula, element, { displayMode: true, throwOnError: false, errorColor: '#cc0000' }); } catch (error) { console.error('Ошибка рендеринга display формулы:', error); element.innerHTML = `
Ошибка формулы: ${element.textContent}
`; } }); // Рендеринг inline формул ($...$) container.querySelectorAll('.language-math:not(.display), .katex').forEach(element => { try { const formula = element.textContent.trim(); katex.render(formula, element, { displayMode: false, throwOnError: false, errorColor: '#cc0000' }); } catch (error) { console.error('Ошибка рендеринга inline формулы:', error); element.innerHTML = `${element.textContent}`; } }); // Обработка формул в тексте (если они не обернуты в теги) const textNodes = Array.from(container.childNodes).filter(node => node.nodeType === Node.TEXT_NODE && (node.textContent.includes('$') || node.textContent.includes('\\\\\\\\[')) ); textNodes.forEach(node => { const wrapper = document.createElement('span'); wrapper.innerHTML = node.textContent .replace(/\\\\$\\\\$(.*?)\\\\$\\\\$/g, (match, formula) => { try { return katex.renderToString(formula, { displayMode: true }); } catch (e) { return `
Ошибка: ${formula}
`; } }) .replace(/\\\\$(.*?)\\\\$/g, (match, formula) => { try { return katex.renderToString(formula, { displayMode: false }); } catch (e) { return `${formula}`; } }); node.parentNode.replaceChild(wrapper, node); }); } // Добавление необходимых стилей function addContentStyles() { if (document.getElementById('content-render-styles')) return; const styles = ` `; document.head.insertAdjacentHTML('beforeend', styles); } ``` ### 6.2 Пример использования ```javascript // Пример 1: Отображение сохраненного контента async function displaySavedContent(contentId) { try { // Загрузка контента из базы данных const response = await fetch(`/api/content/${contentId}`); const content = await response.json(); // Отображение в контейнере renderContentWithoutEditor(content, 'content-container', { theme: 'light', enableMath: true, enableSyntaxHighlight: true }); } catch (error) { console.error('Ошибка загрузки контента:', error); document.getElementById('content-container').innerHTML = `

Ошибка загрузки контента

${error.message}

`; } } // Пример 2: Предпросмотр перед сохранением function previewContent() { if (!vditorInstance) return; const content = { markdown: vditorInstance.getValue(), html: vditorInstance.getHTML() }; // Открытие предпросмотра в модальном окне const modal = document.createElement('div'); modal.className = 'preview-modal'; modal.innerHTML = `

Предпросмотр

`; document.body.appendChild(modal); // Отображение контента renderContentWithoutEditor(content, 'preview-container', { theme: 'light', enableMath: true }); // Закрытие модального окна modal.querySelector('.close-btn').addEventListener('click', () => { modal.remove(); }); modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); } }); } // Пример 3: Экспорт в PDF/печать function exportToPDF(content, filename = 'document') { const printWindow = window.open('', '_blank'); printWindow.document.write(` ${filename} ${content.html || convertMarkdownToHTML(content.markdown || content)} `); printWindow.document.close(); // Автоматическая печать после загрузки printWindow.onload = function() { printWindow.print(); }; } ``` ### 6.3 Оптимизация производительности ```javascript // Ленивая загрузка KaTeX и Highlight.js let katexLoaded = false; let hljsLoaded = false; async function loadMathRenderer() { if (katexLoaded) return; return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js'; script.onload = () => { katexLoaded = true; resolve(); }; script.onerror = reject; document.head.appendChild(script); const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css'; document.head.appendChild(css); }); } async function loadSyntaxHighlighter() { if (hljsLoaded) return; return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js'; script.onload = () => { hljsLoaded = true; resolve(); }; script.onerror = reject; document.head.appendChild(script); const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; document.head.appendChild(css); }); } // Оптимизированный рендеринг с ленивой загрузкой async function renderContentOptimized(content, containerId) { const container = document.getElementById(containerId); // Показываем спиннер загрузки container.innerHTML = '
Загрузка...
'; try { // Параллельная загрузка зависимостей await Promise.all([ loadMathRenderer(), loadSyntaxHighlighter() ]); // Рендеринг контента renderContentWithoutEditor(content, containerId, { enableMath: true, enableSyntaxHighlight: true }); } catch (error) { console.error('Ошибка загрузки зависимостей:', error); container.innerHTML = `

Ошибка отображения контента

${error.message}

`; } } ``` ## 7. Хранение в базе данных ### 7.1 Структура таблиц для хранения контента ```sql -- Таблица для хранения курсов/статей CREATE TABLE content ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title VARCHAR(255) NOT NULL, description TEXT, markdown_content TEXT NOT NULL, html_content TEXT, metadata JSONB DEFAULT '{}', author_id UUID REFERENCES users(id), status VARCHAR(50) DEFAULT 'draft', created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, published_at TIMESTAMP WITH TIME ZONE, version INTEGER DEFAULT 1 ); -- Индексы для оптимизации поиска CREATE INDEX idx_content_author ON content(author_id); CREATE INDEX idx_content_status ON content(status); CREATE INDEX idx_content_created ON content(created_at); CREATE INDEX idx_content_title ON content(title); CREATE INDEX idx_content_metadata ON content USING gin(metadata); -- Таблица для версий контента (история изменений) CREATE TABLE content_versions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), content_id UUID REFERENCES content(id) ON DELETE CASCADE, markdown_content TEXT NOT NULL, html_content TEXT, version INTEGER NOT NULL, change_description TEXT, author_id UUID REFERENCES users(id), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Индекс для быстрого поиска версий CREATE INDEX idx_content_versions_content ON content_versions(content_id); CREATE INDEX idx_content_versions_version ON content_versions(version); -- Таблица для извлеченных формул (для поиска и индексации) CREATE TABLE content_formulas ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), content_id UUID REFERENCES content(id) ON DELETE CASCADE, formula_text TEXT NOT NULL, formula_type VARCHAR(20), -- 'inline' или 'display' position INTEGER, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Индекс для поиска формул CREATE INDEX idx_formulas_content ON content_formulas(content_id); CREATE INDEX idx_formulas_text ON content_formulas USING gin(formula_text gin_trgm_ops); ``` ### 7.2 Модели данных для Node.js/Express ```javascript // models/Content.js const { DataTypes } = require('sequelize'); module.exports = (sequelize) => { const Content = sequelize.define('Content', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, title: { type: DataTypes.STRING(255), allowNull: false, validate: { notEmpty: true } }, description: { type: DataTypes.TEXT, allowNull: true }, markdownContent: { type: DataTypes.TEXT, allowNull: false, field: 'markdown_content' }, htmlContent: { type: DataTypes.TEXT, allowNull: true, field: 'html_content' }, metadata: { type: DataTypes.JSONB, defaultValue: {}, get() { const rawValue = this.getDataValue('metadata'); return rawValue ? rawValue : {}; } }, status: { type: DataTypes.ENUM('draft', 'published', 'archived'), defaultValue: 'draft' }, version: { type: DataTypes.INTEGER, defaultValue: 1 }, publishedAt: { type: DataTypes.DATE, allowNull: true, field: 'published_at' } }, { tableName: 'content', timestamps: true, underscored: true, hooks: { beforeSave: async (content) => { // Автоматическая генерация HTML из Markdown if (content.changed('markdownContent') && content.markdownContent) { content.htmlContent = await convertMarkdownToHTML(content.markdownContent); // Извлечение и сохранение формул await extractAndSaveFormulas(content); } // Обновление версии при изменении контента if (content.changed('markdownContent')) { content.version += 1; } // Обновление времени публикации if (content.changed('status') && content.status === 'published') { content.publishedAt = new Date(); } }, afterSave: async (content) => { // Сохранение версии в историю if (content.changed('markdownContent')) { await sequelize.models.ContentVersion.create({ contentId: content.id, markdownContent: content.markdownContent, htmlContent: content.htmlContent, version: content.version, authorId: content.authorId }); } } } }); Content.associate = (models) => { Content.belongsTo(models.User, { foreignKey: 'author_id', as: 'author' }); Content.hasMany(models.ContentVersion, { foreignKey: 'content_id', as: 'versions' }); Content.hasMany(models.ContentFormula, { foreignKey: 'content_id', as: 'formulas' }); }; return Content; }; // models/ContentVersion.js module.exports = (sequelize) => { const ContentVersion = sequelize.define('ContentVersion', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, markdownContent: { type: DataTypes.TEXT, allowNull: false, field: 'markdown_content' }, htmlContent: { type: DataTypes.TEXT, allowNull: true, field: 'html_content' }, version: { type: DataTypes.INTEGER, allowNull: false }, changeDescription: { type: DataTypes.TEXT, allowNull: true, field: 'change_description' } }, { tableName: 'content_versions', timestamps: true, underscored: true }); ContentVersion.associate = (models) => { ContentVersion.belongsTo(models.Content, { foreignKey: 'content_id', as: 'content' }); ContentVersion.belongsTo(models.User, { foreignKey: 'author_id', as: 'author' }); }; return ContentVersion; }; // Вспомогательные функции async function convertMarkdownToHTML(markdown) { // Используем Lute или marked.js для конвертации // с поддержкой KaTeX // ... } async function extractAndSaveFormulas(content) { const formulas = extractLatexFormulas(content.markdownContent); // Удаляем старые формулы await sequelize.models.ContentFormula.destroy({ where: { contentId: content.id } }); // Сохраняем новые формулы const formulaPromises = []; formulas.inline.forEach((formula, index) => { formulaPromises.push( sequelize.models.ContentFormula.create({ contentId: content.id, formulaText: formula.formula, formulaType: 'inline', position: formula.position }) ); }); formulas.display.forEach((formula, index) => { formulaPromises.push( sequelize.models.ContentFormula.create({ contentId: content.id, formulaText: formula.formula, formulaType: 'display', position: formula.position }) ); }); await Promise.all(formulaPromises); } ``` ### 7.3 API endpoints для работы с контентом ```javascript // routes/content.js const express = require('express'); const router = express.Router(); const { Content, ContentVersion } = require('../models'); const auth = require('../middleware/auth'); // Получение списка контента router.get('/', auth, async (req, res) => { try { const { page = 1, limit = 20, status, search } = req.query; const offset = (page - 1) * limit; const where = {}; if (status) { where.status = status; } if (search) { where[Op.or] = [ { title: { [Op.iLike]: `%${search}%` } }, { description: { [Op.iLike]: `%${search}%` } } ]; } const { count, rows } = await Content.findAndCountAll({ where, limit: parseInt(limit), offset: parseInt(offset), order: [['created_at', 'DESC']], include: [{ model: User, as: 'author', attributes: ['id', 'name', 'email'] }] }); res.json({ data: rows, pagination: { total: count, page: parseInt(page), limit: parseInt(limit), pages: Math.ceil(count / limit) } }); } catch (error) { console.error('Ошибка получения контента:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Создание нового контента router.post('/', auth, async (req, res) => { try { const { title, description, markdownContent, metadata } = req.body; const content = await Content.create({ title, description, markdownContent, metadata: metadata || {}, authorId: req.user.id }); res.status(201).json({ message: 'Контент создан', data: content }); } catch (error) { console.error('Ошибка создания контента:', error); res.status(400).json({ error: error.message }); } }); // Получение контента по ID router.get('/:id', async (req, res) => { try { const content = await Content.findByPk(req.params.id, { include: [{ model: User, as: 'author', attributes: ['id', 'name', 'email'] }] }); if (!content) { return res.status(404).json({ error: 'Контент не найден' }); } res.json({ data: content }); } catch (error) { console.error('Ошибка получения контента:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Обновление контента router.put('/:id', auth, async (req, res) => { try { const { title, description, markdownContent, metadata } = req.body; const content = await Content.findByPk(req.params.id); if (!content) { return res.status(404).json({ error: 'Контент не найден' }); } // Проверка прав доступа if (content.authorId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Доступ запрещен' }); } // Обновление полей await content.update({ title: title || content.title, description: description !== undefined ? description : content.description, markdownContent: markdownContent || content.markdownContent, metadata: metadata || content.metadata }); res.json({ message: 'Контент обновлен', data: content }); } catch (error) { console.error('Ошибка обновления контента:', error); res.status(400).json({ error: error.message }); } }); // Публикация контента router.post('/:id/publish', auth, async (req, res) => { try { const content = await Content.findByPk(req.params.id); if (!content) { return res.status(404).json({ error: 'Контент не найден' }); } if (content.authorId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Доступ запрещен' }); } await content.update({ status: 'published', publishedAt: new Date() }); res.json({ message: 'Контент опубликован', data: content }); } catch (error) { console.error('Ошибка публикации:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Получение версий контента router.get('/:id/versions', auth, async (req, res) => { try { const versions = await ContentVersion.findAll({ where: { contentId: req.params.id }, order: [['version', 'DESC']], include: [{ model: User, as: 'author', attributes: ['id', 'name', 'email'] }] }); res.json({ data: versions }); } catch (error) { console.error('Ошибка получения версий:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); // Восстановление версии router.post('/:id/restore/:versionId', auth, async (req, res) => { try { const version = await ContentVersion.findByPk(req.params.versionId, { include: [{ model: Content, as: 'content', where: { id: req.params.id } }] }); if (!version) { return res.status(404).json({ error: 'Версия не найдена' }); } const content = version.content; if (content.authorId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Доступ запрещен' }); } // Восстановление контента из версии await content.update({ markdownContent: version.markdownContent, htmlContent: version.htmlContent, version: content.version + 1 }); res.json({ message: 'Версия восстановлена', data: content }); } catch (error) { console.error('Ошибка восстановления:', error); res.status(500).json({ error: 'Ошибка сервера' }); } }); module.exports = router; ``` ### 7.4 Оптимизация хранения больших документов ```javascript // Стратегии хранения больших документов // 1. Компрессия контента async function compressContent(content) { // Используем gzip для сжатия больших текстов const compressed = await new Promise((resolve, reject) => { zlib.gzip(content, (err, result) => { if (err) reject(err); else resolve(result); }); }); return compressed.toString('base64'); } async function decompressContent(compressed) { const buffer = Buffer.from(compressed, 'base64'); return new Promise((resolve, reject) => { zlib.gunzip(buffer, (err, result) => { if (err) reject(err); else resolve(result.toString()); }); }); } // 2. Разделение контента на части async function splitLargeContent(content, maxChunkSize = 100000) { if (content.length <= maxChunkSize) { return [content]; } const chunks = []; let currentChunk = ''; const lines = content.split('\\ '); for (const line of lines) { if ((currentChunk + line + '\\ ').length > maxChunkSize) { chunks.push(currentChunk); currentChunk = line + '\\ '; } else { currentChunk += line + '\\ '; } } if (currentChunk) { chunks.push(currentChunk); } return chunks; } // 3. Ленивая загрузка частей контента class LazyContentLoader { constructor(contentId) { this.contentId = contentId; this.chunks = []; this.loadedChunks = new Set(); } async getChunk(index) { if (this.loadedChunks.has(index)) { return this.chunks[index]; } // Загрузка чанка из базы данных const response = await fetch(`/api/content/${this.contentId}/chunks/${index}`); const chunk = await response.json(); this.chunks[index] = chunk; this.loadedChunks.add(index); return chunk; } async *getContent() { // Получаем метаданные о количестве чанков const metadata = await fetch(`/api/content/${this.contentId}/metadata`); const { totalChunks } = await metadata.json(); for (let i = 0; i < totalChunks; i++) { yield await this.getChunk(i); } } } // 4. Кэширование в Redis const redis = require('redis'); const client = redis.createClient(); async function getContentWithCache(contentId) { const cacheKey = `content:${contentId}`; // Попытка получить из кэша const cached = await client.get(cacheKey); if (cached) { return JSON.parse(cached); } // Загрузка из базы данных const content = await Content.findByPk(contentId); if (!content) { return null; } // Сохранение в кэш на 1 час await client.setex(cacheKey, 3600, JSON.stringify(content)); return content; } // 5. Индексация для полнотекстового поиска async function indexContentForSearch(content) { // Извлечение текста без Markdown разметки const plainText = content.markdownContent .replace(/#+\\\\s*/g, '') // Удаляем заголовки .replace(/\\\\*\\\\*?(.*?)\\\\*\\\\*?/g, '$1') // Удаляем жирный/курсив .replace(/`(.*?)`/g, '$1') // Удаляем inline код .replace(/\\\\[(.*?)\\\\]\\\\(.*?\\\\)/g, '$1') // Удаляем ссылки .replace(/\\\\$\\\\$?(.*?)\\\\$?\\\\$/g, '') // Удаляем формулы .replace(/```[\\\\s\\\\S]*?```/g, '') // Удаляем блоки кода .replace(/[^\\\\w\\\\sа-яА-ЯёЁ]/g, ' ') // Оставляем только буквы и пробелы .replace(/\\\\s+/g, ' ') // Убираем лишние пробелы .trim(); // Сохранение в поисковый индекс (Elasticsearch или PostgreSQL tsvector) await saveToSearchIndex(content.id, plainText, { title: content.title, description: content.description, author: content.authorId, status: content.status, publishedAt: content.publishedAt }); } ``` ## 8. Примеры использования ### 8.1 Полный пример: Система управления курсами ```javascript // course-system.js class CourseSystem { constructor() { this.vditorInstance = null; this.currentCourse = null; this.draftManager = null; } // Инициализация системы async initialize() { // Загрузка Vditor await this.loadVditor(); // Инициализация менеджера черновиков this.draftManager = new DraftManager(this.vditorInstance, 'course_draft'); this.draftManager.enableAutoSave(30000); // Автосохранение каждые 30 секунд // Настройка обработчиков событий this.setupEventHandlers(); // Загрузка сохраненного черновика const draft = this.draftManager.loadDraft(); if (draft) { this.showDraftNotification(draft); } console.log('Система управления курсами инициализирована'); } // Загрузка Vditor async loadVditor() { return new Promise((resolve, reject) => { if (typeof Vditor !== 'undefined') { this.initVditorEditor(); resolve(); return; } const script = document.createElement('script'); script.src = 'https://unpkg.com/vditor@3.10.4/dist/index.min.js'; script.onload = () => { this.initVditorEditor(); resolve(); }; script.onerror = reject; document.head.appendChild(script); }); } // Инициализация редактора initVditorEditor() { this.vditorInstance = new Vditor('vditor-container', { height: 600, placeholder: 'Начните создавать курс...', theme: 'classic', lang: 'ru_RU', toolbar: [ 'emoji', 'headings', 'bold', 'italic',.