add wisiwig editor TinyMCE. default content type = markdown.

This commit is contained in:
mirivlad 2025-11-24 11:53:56 +08:00
parent d97e4d4944
commit d7fe90a615
27 changed files with 5676 additions and 5055 deletions

View File

@ -923,3 +923,37 @@ article > header, article > footer {
resize: none !important;
box-shadow: 0 0 20px rgba(0,0,0,0.3) !important;
}
/* Стили для TinyMCE редактора */
.tox-tinymce {
border-radius: 4px !important;
border: 1px solid #ddd !important;
}
.tox-tinymce:focus {
border-color: #007bff !important;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
}
/* Адаптивность для TinyMCE */
@media (max-width: 768px) {
.tox-tinymce {
border-radius: 0 !important;
}
.tox-toolbar {
flex-wrap: wrap !important;
}
}
.alert-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.alert-warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}

View File

@ -30,6 +30,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$genre = trim($_POST['genre'] ?? '');
$editor_type = $_POST['editor_type'] ?? 'markdown';
if (empty($title)) {
$_SESSION['error'] = "Название книги обязательно";
@ -37,7 +38,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$series_id = !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null;
$sort_order_in_series = !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null;
// Если серия указана, но порядок нет - генерируем автоматически
if ($series_id && !$sort_order_in_series) {
$seriesModel = new Series($pdo);
$sort_order_in_series = $seriesModel->getNextSortOrder($series_id);
@ -49,10 +49,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'genre' => $genre,
'user_id' => $user_id,
'series_id' => $series_id,
'sort_order_in_series' => $sort_order_in_series
'sort_order_in_series' => $sort_order_in_series,
'editor_type' => $editor_type
];
$data['published'] = isset($_POST['published']) ? 1 : 0;
// Проверяем, изменился ли тип редактора
$editor_changed = false;
$old_editor_type = null;
if ($is_edit && $book['editor_type'] !== $editor_type) {
$editor_changed = true;
$old_editor_type = $book['editor_type'];
}
// Обработка загрузки обложки
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
$cover_result = handleCoverUpload($_FILES['cover_image'], $book_id);
@ -73,6 +82,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($is_edit) {
$success = $bookModel->update($book_id, $data);
// Конвертируем контент глав, если изменился редактор
if ($success && $editor_changed) {
$conversion_success = $bookModel->convertChaptersContent($book_id, $old_editor_type, $editor_type);
if (!$conversion_success) {
$_SESSION['warning'] = "Книга обновлена, но возникли ошибки при конвертации содержания глав";
} else {
$_SESSION['info'] = "Книга обновлена. Содержание глав сконвертировано в новый формат редактора.";
}
}
$message = $success ? "Книга успешно обновлена" : "Ошибка при обновлении книги";
} else {
$success = $bookModel->create($data);
@ -117,6 +137,41 @@ include 'views/header.php';
value="<?= e($book['genre'] ?? $_POST['genre'] ?? '') ?>"
placeholder="Например: Фантастика, Роман, Детектив..."
style="width: 100%; margin-bottom: 1.5rem;">
<label for="editor_type" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Режим редактора
</label>
<select id="editor_type" name="editor_type" style="width: 100%; margin-bottom: 1.5rem;" onchange="showEditorWarning(this)">
<option value="markdown" <?= ($book['editor_type'] ?? 'markdown') == 'markdown' ? 'selected' : '' ?>>Markdown редактор</option>
<option value="html" <?= ($book['editor_type'] ?? '') == 'html' ? 'selected' : '' ?>>HTML редактор (TinyMCE)</option>
</select>
<div id="editor_warning" style="display: none; background: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; border-radius: 4px; margin-bottom: 1rem;">
<strong>Внимание:</strong> При смене редактора содержимое всех глав будет автоматически сконвертировано в новый формат.
</div>
<script>
function showEditorWarning(select) {
const warning = document.getElementById('editor_warning');
const currentEditor = '<?= $book['editor_type'] ?? 'markdown' ?>';
if (select.value !== currentEditor) {
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
}
// Показать предупреждение при загрузке, если редактор уже отличается
document.addEventListener('DOMContentLoaded', function() {
const currentEditor = '<?= $book['editor_type'] ?? 'markdown' ?>';
const selectedEditor = document.getElementById('editor_type').value;
if (currentEditor !== selectedEditor) {
document.getElementById('editor_warning').style.display = 'block';
}
});
</script>
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Серия
</label>
@ -197,48 +252,60 @@ include 'views/header.php';
</div>
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
<button type="submit" class="contrast compact-button">
<button type="submit" class="contrast button">
<?= $is_edit ? '💾 Сохранить изменения' : '📖 Создать книгу' ?>
</button>
</div>
</form>
<?php if ($is_edit): ?>
<form method="post" action="book_normalize_content.php" onsubmit="return confirm('Нормализовать контент всех глав книги? Это действие нельзя отменить.')">
<input type="hidden" name="book_id" value="<?= $book_id ?>">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="button secondary">🔄 Нормализовать контент глав</button>
<p style="margin-top: 0.5rem; font-size: 0.8em; color: #666;">
Если контент глав отображается неправильно после смены редактора, можно нормализовать его структуру.
</p>
</form>
<?php endif; ?>
<?php if ($is_edit): ?>
<form method="post" action="book_delete.php" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');">
<input type="hidden" name="book_id" value="<?= $book['id'] ?>">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить книгу">
🗑️
<button type="submit" class="compact secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить книгу">
🗑️ Удалить главу
</button>
</form>
<?php endif ?>
<?php if ($is_edit): ?>
<div style="margin-top: 2rem; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
<h3>Публичная ссылка для чтения</h3>
<p style="margin-bottom: 0.5rem;">Отправьте эту ссылку читателям для просмотра опубликованных глав:</p>
<div style="display: flex; gap: 5px; align-items: center; flex-wrap: wrap;">
<input type="text"
id="share-link"
value="<?= e(SITE_URL . '/view_book.php?share_token=' . $book['share_token']) ?>"
readonly
style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; background: white;">
<button type="button" onclick="copyShareLink()" class="compact-button secondary">
style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; background: white; width:80%;">
<br>
<button type="button" onclick="copyShareLink()" class="compact-button secondary" style="width: 15%;">
📋 Копировать
</button>
<form method="post" action="book_regenerate_token.php" style="display: inline;">
<form method="post" action="book_regenerate_token.php" style="display: inline; margin-top: 1.5em;">
<input type="hidden" name="book_id" value="<?= $book_id ?>">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button secondary" onclick="return confirm('Создать новую ссылку? Старая ссылка перестанет работать.')">
<button type="submit" class="compact-button secondary" onclick="return confirm('Создать новую ссылку? Старая ссылка перестанет работать.')" >
🔄 Обновить
</button>
</form>
</div>
<p style="margin-top: 0.5rem; font-size: 0.9em; color: #666;">
</form>
<p style="margin-top: -1rem; font-size: 0.8em; color: #666; width: 100%;">
<strong>Примечание:</strong> В публичном просмотре отображаются только главы со статусом "Опубликована"
</p>
</div>
</div>
<script>

View File

@ -186,16 +186,113 @@ include 'views/header.php';
<label for="content" style="display: block; margin-bottom: 0; font-weight: bold;">
Содержание главы
<?php if (isset($book['editor_type'])): ?>
<small style="color: #666; font-weight: normal;">
(Режим: <?= $book['editor_type'] == 'markdown' ? 'Markdown' : 'HTML' ?>)
</small>
<?php endif; ?>
</label>
<?php if (($book['editor_type'] ?? 'markdown') === 'html'): ?>
<!-- HTML редактор (TinyMCE) -->
<textarea name="content" id="content" style="width: 100%; min-height: 500px;">
<?= e($chapter['content'] ?? $_POST['content'] ?? '') ?>
</textarea>
<!-- Подключаем TinyMCE -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.6/tinymce.min.js" referrerpolicy="origin"></script>
<script>
tinymce.init({
selector: '#content',
plugins: 'advlist autolink lists link image charmap preview anchor pagebreak searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media table emoticons',
toolbar: 'undo redo | blocks | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media | code preview fullscreen',
menubar: 'edit view insert format tools table',
height: 500,
language: 'ru',
branding: false,
promotion: false,
image_advtab: true,
// Важные настройки для сохранения структуры
forced_root_block: 'p', // Используем <p> вместо <div>
force_br_newlines: false, // Не использовать <br> вместо абзацев
force_p_newlines: true, // Всегда создавать новые абзацы при Enter
convert_newlines_to_brs: false, // Не конвертировать переносы в <br>
remove_trailing_brs: true, // Убирать лишние <br> в конце
// Настройки форматирования
formats: {
// Сохраняем семантическое форматирование
bold: { inline: 'strong' },
italic: { inline: 'em' },
underline: { inline: 'u', exact: true },
strikethrough: { inline: 'del' }
},
// Настройки контента
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
margin: 0;
padding: 10px;
}
p {
margin: 0 0 1em 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em 0;
}
`,
// Настройки для чистого HTML
valid_elements: '*[*]', // Разрешаем все элементы (можно ограничить при необходимости)
valid_children: '+body[p,div,h1,h2,h3,h4,h5,h6,blockquote,pre,ul,ol,li,table]',
// Автосохранение
setup: function (editor) {
editor.on('init', function () {
// Нормализуем контент при инициализации
var content = editor.getContent();
if (content && !content.match(/<p[^>]*>/) && content.trim().length > 0) {
// Если нет тегов абзацев, оборачиваем в <p>
editor.setContent('<p>' + content.replace(/\n/g, '</p><p>') + '</p>');
}
});
editor.on('keydown', function (e) {
clearTimeout(window.tinymceSaveTimeout);
window.tinymceSaveTimeout = setTimeout(function() {
if (typeof autoSave === 'function') {
autoSave();
}
}, 2000);
});
// Обработка вставки текста
editor.on('paste', function (e) {
// Нормализуем вставленный текст
setTimeout(function() {
var content = editor.getContent();
// Убеждаемся, что контент имеет правильную структуру абзацев
editor.setContent(content);
}, 100);
});
}
});
</script>
<?php else: ?>
<!-- Markdown редактор (существующий) -->
<textarea name="content" id="content"
placeholder="Начните писать вашу главу здесь..."
rows="15"
style="width: 100%; font-family: monospace;"><?= e($chapter['content'] ?? $_POST['content'] ?? '') ?></textarea>
<?php if ($is_edit && isset($chapter['word_count'])): ?>
<div style="background: #f5f5f5; padding: 10px; border-radius: 5px; margin-bottom: 1rem;">
<strong>Статистика:</strong> <?= $chapter['word_count'] ?> слов
| Обновлено: <?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?>
</div>
<script src="/assets/js/markdown-editor.js"></script>
<?php if ($is_edit): ?>
<script src="/assets/js/autosave.js"></script>
<?php endif; ?>
<?php endif; ?>
</div>
</form>
@ -218,6 +315,7 @@ include 'views/header.php';
<form method="post" action="preview.php" target="_blank" id="preview-form" style="display: none;">
<input type="hidden" name="content" id="preview-content">
<input type="hidden" name="title" id="preview-title" value="<?= e($chapter['title'] ?? 'Новая глава') ?>">
<input type="hidden" name="editor_type" id="preview-editor-type" value="<?= e($book['editor_type'] ?? 'markdown') ?>">
</form>
<?php if ($is_edit): ?>
@ -283,9 +381,6 @@ document.getElementById('preview-button').addEventListener('click', function() {
});
</script>
<script src="assets/js/markdown-editor.js"></script>
<?php if ($is_edit): ?>
<script src="assets/js/autosave.js"></script>
<?php endif; ?>
<?php include 'views/footer.php'; ?>

View File

@ -47,14 +47,18 @@ if (!$book) {
redirect('books.php');
}
// Получаем информацию об авторе
$author_info = null;
$author_info = "Неизвестный автор";
if ($book) {
$stmt = $pdo->prepare("SELECT display_name, username FROM users WHERE id = ?");
$stmt->execute([$book['user_id']]);
$author_info = $stmt->fetch(PDO::FETCH_ASSOC);
}
if ($author_info['display_name'] !=""){
$author_name = $author_info['display_name'];
}else{
$author_name = $author_info['username'] ;
}
$author_name = $author_info['display_name'] ?? $author_info['username'] ?? 'Неизвестный автор';
}
// Функция для преобразования Markdown в чистый текст с форматированием абзацев
@ -354,7 +358,11 @@ function exportPDF($book, $chapters, $is_public, $author_name) {
// Контент главы
$pdf->SetFont('dejavusans', '', 11);
if ($book['editor_type'] == 'markdown') {
$htmlContent = $Parsedown->text($chapter['content']);
} else {
$htmlContent = $chapter['content'];
}
$pdf->writeHTML($htmlContent, true, false, true, false, '');
$pdf->Ln(8);
@ -413,10 +421,17 @@ function exportDOCX($book, $chapters, $is_public, $author_name) {
// Описание
if (!empty($book['description'])) {
if ($book['editor_type'] == 'markdown') {
$descriptionParagraphs = markdownToParagraphs($book['description']);
} else {
$descriptionParagraphs = htmlToParagraphs($book['description']);
}
foreach ($descriptionParagraphs as $paragraph) {
if (!empty(trim($paragraph))) {
$section->addText($paragraph);
}
}
$section->addTextBreak(2);
}
@ -446,9 +461,14 @@ function exportDOCX($book, $chapters, $is_public, $author_name) {
$section->addText($chapter['title'], ['bold' => true, 'size' => 14]);
$section->addTextBreak(1);
// Получаем очищенный текст и разбиваем на абзацы
// Получаем очищенный текст и разбиваем на абзацы в зависимости от типа редактора
if ($book['editor_type'] == 'markdown') {
$cleanContent = cleanMarkdown($chapter['content']);
$paragraphs = markdownToParagraphs($cleanContent);
} else {
$cleanContent = strip_tags($chapter['content']);
$paragraphs = htmlToParagraphs($chapter['content']);
}
// Добавляем каждый абзац
foreach ($paragraphs as $paragraph) {
@ -479,6 +499,23 @@ function exportDOCX($book, $chapters, $is_public, $author_name) {
exit;
}
// Новая функция для разбивки HTML на абзацы
function htmlToParagraphs($html) {
// Убираем HTML теги и нормализуем пробелы
$text = strip_tags($html);
$text = preg_replace('/\s+/', ' ', $text);
// Разбиваем на абзацы по точкам и переносам строк
$paragraphs = preg_split('/(?<=[.!?])\s+/', $text);
// Фильтруем пустые абзацы
$paragraphs = array_filter($paragraphs, function($paragraph) {
return !empty(trim($paragraph));
});
return $paragraphs;
}
function exportHTML($book, $chapters, $is_public, $author_name) {
global $Parsedown;
@ -539,7 +576,7 @@ function exportHTML($book, $chapters, $is_public, $author_name) {
padding: 20px;
border-radius: 5px;
margin: 20px 0;
columns: 2;
columns: 1;
column-gap: 2rem;
}
.table-of-contents h3 {
@ -657,12 +694,18 @@ function exportHTML($book, $chapters, $is_public, $author_name) {
}
if (!empty($book['description'])) {
$html .= '<div class="book-description">' . nl2br(htmlspecialchars($book['description'])) . '</div>';
$html .= '<div class="book-description">';
if ($book['editor_type'] == 'markdown') {
$html .= nl2br(htmlspecialchars($book['description']));
} else {
$html .= $book['description'];
}
$html .= '</div>';
}
// Интерактивное оглавление
if (!empty($chapters)) {
$html .= '<div>';
$html .= '<div class="table-of-contents">';
$html .= '<h3>Оглавление</h3>';
$html .= '<ul>';
foreach ($chapters as $index => $chapter) {
@ -679,7 +722,13 @@ function exportHTML($book, $chapters, $is_public, $author_name) {
$html .= '<div class="chapter">';
$html .= '<div class="chapter-title" id="chapter-' . $chapter['id'] . '" name="chapter-' . $chapter['id'] . '">' . htmlspecialchars($chapter['title']) . '</div>';
// Обрабатываем контент в зависимости от типа редактора
if ($book['editor_type'] == 'markdown') {
$htmlContent = $Parsedown->text($chapter['content']);
} else {
$htmlContent = $chapter['content'];
}
$html .= '<div class="chapter-content">' . $htmlContent . '</div>';
$html .= '</div>';
@ -714,8 +763,15 @@ function exportTXT($book, $chapters, $is_public, $author_name) {
if (!empty($book['description'])) {
$content .= "ОПИСАНИЕ:\n";
// Ширина до 144 символов
$content .= wordwrap($book['description'], 144) . "\n\n";
// Обрабатываем описание в зависимости от типа редактора
if ($book['editor_type'] == 'markdown') {
$descriptionText = cleanMarkdown($book['description']);
} else {
$descriptionText = strip_tags($book['description']);
}
$content .= wordwrap($descriptionText, 144) . "\n\n";
}
// Оглавление
@ -735,13 +791,17 @@ function exportTXT($book, $chapters, $is_public, $author_name) {
$content .= $chapter['title'] . "\n";
$content .= str_repeat("-", 60) . "\n\n";
// Получаем очищенный текст и разбиваем на абзацы
// Получаем очищенный текст в зависимости от типа редактора
if ($book['editor_type'] == 'markdown') {
$cleanContent = cleanMarkdown($chapter['content']);
$paragraphs = markdownToParagraphs($cleanContent);
} else {
$cleanContent = strip_tags($chapter['content']);
$paragraphs = htmlToPlainTextParagraphs($cleanContent);
}
foreach ($paragraphs as $paragraph) {
if (!empty(trim($paragraph))) {
// Увеличиваем ширину до 144 символов
$content .= wordwrap($paragraph, 144) . "\n\n";
}
}
@ -762,4 +822,48 @@ function exportTXT($book, $chapters, $is_public, $author_name) {
echo $content;
exit;
}
// Новая функция для разбивки HTML на абзацы в виде простого текста
function htmlToPlainTextParagraphs($html) {
// Убираем HTML теги
$text = strip_tags($html);
// Заменяем HTML entities
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Нормализуем переносы строк
$text = str_replace(["\r\n", "\r"], "\n", $text);
// Разбиваем на строки
$lines = explode("\n", $text);
$paragraphs = [];
$currentParagraph = '';
foreach ($lines as $line) {
$trimmedLine = trim($line);
// Пустая строка - конец абзаца
if (empty($trimmedLine)) {
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
$currentParagraph = '';
}
continue;
}
// Добавляем к текущему абзацу
if (!empty($currentParagraph)) {
$currentParagraph .= ' ' . $trimmedLine;
} else {
$currentParagraph = $trimmedLine;
}
}
// Добавляем последний абзац
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
}
return $paragraphs;
}
?>

View File

@ -61,6 +61,7 @@ CREATE TABLE `books` (
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`share_token` varchar(32) DEFAULT NULL,
`published` tinyint(1) NOT NULL DEFAULT 0,
`editor_type` ENUM('markdown', 'html') DEFAULT 'markdown',
PRIMARY KEY (`id`),
UNIQUE KEY `share_token` (`share_token`),
KEY `user_id` (`user_id`),

View File

@ -1,6 +1,6 @@
<?php
// models/Book.php
require_once __DIR__ . '/../includes/parsedown/ParsedownExtra.php';
class Book {
private $pdo;
@ -41,10 +41,11 @@ class Book {
public function create($data) {
$share_token = bin2hex(random_bytes(16));
$published = isset($data['published']) ? (int)$data['published'] : 0;
$editor_type = $data['editor_type'] ?? 'markdown';
$stmt = $this->pdo->prepare("
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published, editor_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
");
return $stmt->execute([
$data['title'],
@ -54,16 +55,18 @@ class Book {
$data['series_id'] ?? null,
$data['sort_order_in_series'] ?? null,
$share_token,
$published
$published,
$editor_type
]);
}
public function update($id, $data) {
$published = isset($data['published']) ? (int)$data['published'] : 0;
$editor_type = $data['editor_type'] ?? 'markdown';
$stmt = $this->pdo->prepare("
UPDATE books
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?, editor_type = ?
WHERE id = ? AND user_id = ?
");
return $stmt->execute([
@ -73,11 +76,13 @@ class Book {
$data['series_id'] ?? null,
$data['sort_order_in_series'] ?? null,
$published,
$editor_type,
$id,
$data['user_id']
]);
}
public function delete($id, $user_id) {
try {
$this->pdo->beginTransaction();
@ -200,5 +205,295 @@ class Book {
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function convertChaptersContent($book_id, $from_editor, $to_editor) {
try {
$this->pdo->beginTransaction();
$chapters = $this->getAllChapters($book_id);
foreach ($chapters as $chapter) {
$converted_content = $this->convertContent(
$chapter['content'],
$from_editor,
$to_editor
);
$this->updateChapterContent($chapter['id'], $converted_content);
}
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
error_log("Error converting chapters: " . $e->getMessage());
return false;
}
}
private function getAllChapters($book_id) {
$stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
$stmt->execute([$book_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function updateChapterContent($chapter_id, $content) {
$word_count = $this->countWords($content);
$stmt = $this->pdo->prepare("
UPDATE chapters
SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
return $stmt->execute([$content, $word_count, $chapter_id]);
}
private function countWords($text) {
$text = strip_tags($text);
$text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text);
$words = preg_split('/\s+/', $text);
$words = array_filter($words);
return count($words);
}
private function convertContent($content, $from_editor, $to_editor) {
if ($from_editor === $to_editor) {
return $content;
}
try {
if ($from_editor === 'markdown' && $to_editor === 'html') {
// Markdown to HTML с улучшенной обработкой абзацев
return $this->markdownToHtmlWithParagraphs($content);
} elseif ($from_editor === 'html' && $to_editor === 'markdown') {
// HTML to Markdown
return $this->htmlToMarkdown($content);
}
} catch (Exception $e) {
error_log("Error converting content from {$from_editor} to {$to_editor}: " . $e->getMessage());
return $content;
}
return $content;
}
private function markdownToHtmlWithParagraphs($markdown) {
$parsedown = new ParsedownExtra();
// Включаем разметку строк для лучшей обработки абзацев
$parsedown->setBreaksEnabled(true);
// Обрабатываем Markdown
$html = $parsedown->text($markdown);
// Дополнительная обработка для обеспечения правильной структуры абзацев
$html = $this->ensureParagraphStructure($html);
return $html;
}
private function ensureParagraphStructure($html) {
// Если HTML не содержит тегов абзацев или div'ов, оборачиваем в <p>
if (!preg_match('/<(p|div|h[1-6]|blockquote|pre|ul|ol|li)/i', $html)) {
// Разбиваем на строки и оборачиваем каждую непустую строку в <p>
$lines = explode("\n", trim($html));
$wrappedLines = [];
foreach ($lines as $line) {
$line = trim($line);
if (!empty($line)) {
// Пропускаем уже обернутые строки
if (!preg_match('/^<[^>]+>/', $line) || preg_match('/^<(p|div|h[1-6])/i', $line)) {
$wrappedLines[] = $line;
} else {
$wrappedLines[] = "<p>{$line}</p>";
}
}
}
$html = implode("\n", $wrappedLines);
}
// Убеждаемся, что теги правильно закрыты
$html = $this->balanceTags($html);
return $html;
}
private function balanceTags($html) {
// Простая балансировка тегов - в реальном проекте лучше использовать DOMDocument
$tags = [
'p' => 0,
'div' => 0,
'span' => 0,
'strong' => 0,
'em' => 0,
];
// Счетчик открывающих и закрывающих тегов
foreach ($tags as $tag => &$count) {
$open = substr_count($html, "<{$tag}>") + substr_count($html, "<{$tag} ");
$close = substr_count($html, "</{$tag}>");
$count = $open - $close;
}
// Добавляем недостающие закрывающие теги
foreach ($tags as $tag => $count) {
if ($count > 0) {
$html .= str_repeat("</{$tag}>", $count);
}
}
return $html;
}
private function htmlToMarkdown($html) {
// Сначала нормализуем HTML структуру
$html = $this->normalizeHtml($html);
// Базовая конвертация HTML в Markdown
$markdown = $html;
// Обрабатываем абзацы - заменяем на двойные переносы строк
$markdown = preg_replace('/<p[^>]*>(.*?)<\/p>/is', "$1\n\n", $markdown);
// Обрабатываем разрывы строк
$markdown = preg_replace('/<br[^>]*>\s*<\/br[^>]*>/i', "\n", $markdown);
$markdown = preg_replace('/<br[^>]*>/i', " \n", $markdown); // Два пробела для Markdown разрыва
// Заголовки
$markdown = preg_replace('/<h1[^>]*>(.*?)<\/h1>/is', "# $1\n\n", $markdown);
$markdown = preg_replace('/<h2[^>]*>(.*?)<\/h2>/is', "## $1\n\n", $markdown);
$markdown = preg_replace('/<h3[^>]*>(.*?)<\/h3>/is', "### $1\n\n", $markdown);
$markdown = preg_replace('/<h4[^>]*>(.*?)<\/h4>/is', "#### $1\n\n", $markdown);
$markdown = preg_replace('/<h5[^>]*>(.*?)<\/h5>/is', "##### $1\n\n", $markdown);
$markdown = preg_replace('/<h6[^>]*>(.*?)<\/h6>/is', "###### $1\n\n", $markdown);
// Жирный текст
$markdown = preg_replace('/<strong[^>]*>(.*?)<\/strong>/is', '**$1**', $markdown);
$markdown = preg_replace('/<b[^>]*>(.*?)<\/b>/is', '**$1**', $markdown);
// Курсив
$markdown = preg_replace('/<em[^>]*>(.*?)<\/em>/is', '*$1*', $markdown);
$markdown = preg_replace('/<i[^>]*>(.*?)<\/i>/is', '*$1*', $markdown);
// Подчеркивание (не стандартно в Markdown, но обрабатываем)
$markdown = preg_replace('/<u[^>]*>(.*?)<\/u>/is', '<u>$1</u>', $markdown);
// Зачеркивание
$markdown = preg_replace('/<s[^>]*>(.*?)<\/s>/is', '~~$1~~', $markdown);
$markdown = preg_replace('/<strike[^>]*>(.*?)<\/strike>/is', '~~$1~~', $markdown);
$markdown = preg_replace('/<del[^>]*>(.*?)<\/del>/is', '~~$1~~', $markdown);
// Списки
$markdown = preg_replace('/<li[^>]*>(.*?)<\/li>/is', '- $1', $markdown);
$markdown = preg_replace('/<ul[^>]*>(.*?)<\/ul>/is', "$1\n", $markdown);
$markdown = preg_replace('/<ol[^>]*>(.*?)<\/ol>/is', "$1\n", $markdown);
// Блочные цитаты
$markdown = preg_replace('/<blockquote[^>]*>(.*?)<\/blockquote>/is', "> $1\n", $markdown);
// Код
$markdown = preg_replace('/<code[^>]*>(.*?)<\/code>/is', '`$1`', $markdown);
$markdown = preg_replace('/<pre[^>]*>(.*?)<\/pre>/is', "```\n$1\n```", $markdown);
// Ссылки
$markdown = preg_replace('/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/is', '[$2]($1)', $markdown);
// Изображения
$markdown = preg_replace('/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/is', '![$2]($1)', $markdown);
// Удаляем все остальные HTML-теги
$markdown = strip_tags($markdown);
// Чистим лишние пробелы и переносы
$markdown = preg_replace('/\n\s*\n\s*\n/', "\n\n", $markdown);
$markdown = preg_replace('/^\s+|\s+$/m', '', $markdown); // Trim каждой строки
$markdown = trim($markdown);
return $markdown;
}
private function normalizeHtml($html) {
// Нормализуем HTML структуру перед конвертацией
$html = preg_replace('/<div[^>]*>(.*?)<\/div>/is', "<p>$1</p>", $html);
// Убираем лишние пробелы
$html = preg_replace('/\s+/', ' ', $html);
// Восстанавливаем структуру абзацев
$html = preg_replace('/([^>])\s*<\/(p|div)>\s*([^<])/', "$1</$2>\n\n$3", $html);
return $html;
}
public function normalizeBookContent($book_id) {
try {
$chapters = $this->getAllChapters($book_id);
$book = $this->findById($book_id);
foreach ($chapters as $chapter) {
$normalized_content = '';
if ($book['editor_type'] == 'html') {
// Нормализуем HTML контент
$normalized_content = $this->normalizeHtmlContent($chapter['content']);
} else {
// Нормализуем Markdown контент
$normalized_content = $this->normalizeMarkdownContent($chapter['content']);
}
if ($normalized_content !== $chapter['content']) {
$this->updateChapterContent($chapter['id'], $normalized_content);
}
}
return true;
} catch (Exception $e) {
error_log("Error normalizing book content: " . $e->getMessage());
return false;
}
}
private function normalizeHtmlContent($html) {
// Простая нормализация HTML - оборачиваем текст без тегов в <p>
if (!preg_match('/<[^>]+>/', $html) && trim($html) !== '') {
// Если нет HTML тегов, оборачиваем в <p>
$lines = explode("\n", trim($html));
$wrapped = array_map(function($line) {
$line = trim($line);
return $line ? "<p>{$line}</p>" : '';
}, $lines);
return implode("\n", array_filter($wrapped));
}
return $html;
}
private function normalizeMarkdownContent($markdown) {
// Нормализация Markdown - убеждаемся, что есть пустые строки между абзацами
$lines = explode("\n", $markdown);
$normalized = [];
$inParagraph = false;
foreach ($lines as $line) {
$trimmed = trim($line);
if (empty($trimmed)) {
// Пустая строка - конец абзаца
if ($inParagraph) {
$normalized[] = '';
$inParagraph = false;
}
} else {
// Непустая строка
if (!$inParagraph && !empty($normalized) && end($normalized) !== '') {
// Добавляем пустую строку перед новым абзацем
$normalized[] = '';
}
$normalized[] = $line;
$inParagraph = true;
}
}
return implode("\n", $normalized);
}
}
?>

0
models/Series.php Executable file → Normal file
View File

View File

@ -7,9 +7,15 @@ $Parsedown = new ParsedownExtra();;
$content = $_POST['content'] ?? '';
$title = $_POST['title'] ?? 'Предпросмотр';
$editor_type = $_POST['editor_type'] ?? 'markdown'; // Новое поле
// Обрабатываем контент в зависимости от типа редактора
if ($editor_type == 'markdown') {
$html_content = $Parsedown->text($content);
} else {
$html_content = $content;
}
$Parsedown = new Parsedown();
$html_content = $Parsedown->text($content);
$page_title = "Предпросмотр: " . e($title);
?>

0
series.php Executable file → Normal file
View File

0
series_delete.php Executable file → Normal file
View File

0
series_edit.php Executable file → Normal file
View File

View File

@ -161,8 +161,14 @@ include 'views/header.php';
<a href="#start" style="text-decoration: none; color: #666; font-size: 0.8em; margin-left: 1rem;">🔗</a>
</h2>
<div class="chapter-content" style="line-height: 1.6; font-size: 1.1em;">
<?php if ($book['editor_type'] == 'markdown'): ?>
<?= $Parsedown->text($chapter['content']) ?>
<?php else: ?>
<?= $chapter['content'] ?>
<?php endif; ?>
</div>
<div style="margin-top: 1rem; padding-top: 0.5rem; border-top: 1px dashed #eee; color: #666; font-size: 0.9em;">
<small>Обновлено: <?= date('d.m.Y', strtotime($chapter['updated_at'])) ?></small>
<a href="#top" style="float: right; color: #007bff; text-decoration: none;"> Наверх</a>

0
view_series.php Executable file → Normal file
View File

View File

@ -49,3 +49,16 @@
</ul>
</nav>
<main class="container">
<?php if (isset($_SESSION['info'])): ?>
<div class="alert alert-info">
<?= e($_SESSION['info']) ?>
<?php unset($_SESSION['info']); ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['warning'])): ?>
<div class="alert alert-warning">
<?= e($_SESSION['warning']) ?>
<?php unset($_SESSION['warning']); ?>
</div>
<?php endif; ?>