153 lines
5.3 KiB
PHP
153 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
class ProseMirror
|
|
{
|
|
public static function render(mixed $doc): string
|
|
{
|
|
if (is_string($doc)) {
|
|
$doc = json_decode($doc, true);
|
|
}
|
|
|
|
if (!is_array($doc)) {
|
|
return '';
|
|
}
|
|
|
|
if (isset($doc['type']) && $doc['type'] === 'doc') {
|
|
return self::renderNodes($doc['content'] ?? []);
|
|
}
|
|
|
|
if (array_is_list($doc)) {
|
|
return self::renderNodes($doc);
|
|
}
|
|
|
|
return self::renderNode($doc);
|
|
}
|
|
|
|
private static function renderNodes(array $nodes): string
|
|
{
|
|
return implode('', array_map([self::class, 'renderNode'], $nodes));
|
|
}
|
|
|
|
private static function renderNode(array $node): string
|
|
{
|
|
$type = $node['type'] ?? '';
|
|
$content = $node['content'] ?? [];
|
|
$attrs = $node['attrs'] ?? [];
|
|
|
|
return match ($type) {
|
|
'paragraph' => '<p>' . self::renderNodes($content) . '</p>',
|
|
'heading' => self::renderHeading($attrs, $content),
|
|
'bulletList' => '<ul>' . self::renderNodes($content) . '</ul>',
|
|
'orderedList' => '<ol>' . self::renderNodes($content) . '</ol>',
|
|
'listItem' => '<li>' . self::renderNodes($content) . '</li>',
|
|
'blockquote' => '<blockquote>' . self::renderNodes($content) . '</blockquote>',
|
|
'codeBlock' => '<pre><code>' . e(self::extractText($content)) . '</code></pre>',
|
|
'hardBreak' => '<br>',
|
|
'horizontalRule' => '<hr>',
|
|
'text' => self::renderText($node),
|
|
'table' => self::renderTable($content),
|
|
'tableRow' => '<tr>' . self::renderNodes($content) . '</tr>',
|
|
'tableCell' => self::renderTableCell($attrs, $content, false),
|
|
'tableHeader' => self::renderTableCell($attrs, $content, true),
|
|
default => self::renderNodes($content),
|
|
};
|
|
}
|
|
|
|
private static function renderHeading(array $attrs, array $content): string
|
|
{
|
|
$level = max(1, min(6, (int) ($attrs['level'] ?? 1)));
|
|
return "<h{$level}>" . self::renderNodes($content) . "</h{$level}>";
|
|
}
|
|
|
|
private static function renderText(array $node): string
|
|
{
|
|
$text = e($node['text'] ?? '');
|
|
$marks = $node['marks'] ?? [];
|
|
|
|
foreach ($marks as $mark) {
|
|
$text = self::applyMark($mark, $text);
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
private static function applyMark(array $mark, string $text): string
|
|
{
|
|
$type = $mark['type'] ?? '';
|
|
$attrs = $mark['attrs'] ?? [];
|
|
|
|
return match ($type) {
|
|
'bold' => "<strong>{$text}</strong>",
|
|
'italic' => "<em>{$text}</em>",
|
|
'code' => "<code>{$text}</code>",
|
|
'strike' => "<s>{$text}</s>",
|
|
'underline' => "<u>{$text}</u>",
|
|
'link' => self::renderLink($attrs, $text),
|
|
default => $text,
|
|
};
|
|
}
|
|
|
|
private static function renderLink(array $attrs, string $text): string
|
|
{
|
|
$href = e($attrs['href'] ?? '');
|
|
if (preg_match('/^\s*javascript:/i', rawurldecode($href))) {
|
|
$href = '#';
|
|
}
|
|
$target = isset($attrs['target']) ? ' target="' . e($attrs['target']) . '"' : '';
|
|
$rel = $target ? ' rel="noopener noreferrer"' : '';
|
|
return "<a href=\"{$href}\"{$target}{$rel}>{$text}</a>";
|
|
}
|
|
|
|
private static function renderTable(array $rows): string
|
|
{
|
|
if (empty($rows)) {
|
|
return '<table></table>';
|
|
}
|
|
|
|
// If the first row consists entirely of tableHeader cells, put it in <thead>
|
|
$firstCells = $rows[0]['content'] ?? [];
|
|
$headerCount = count(array_filter($firstCells, fn($c) => ($c['type'] ?? '') === 'tableHeader'));
|
|
$firstIsHeaderRow = !empty($firstCells) && $headerCount === count($firstCells);
|
|
|
|
if ($firstIsHeaderRow) {
|
|
$thead = '<thead>' . self::renderNode($rows[0]) . '</thead>';
|
|
$tbody = count($rows) > 1
|
|
? '<tbody>' . self::renderNodes(array_slice($rows, 1)) . '</tbody>'
|
|
: '';
|
|
return "<table>{$thead}{$tbody}</table>";
|
|
}
|
|
|
|
return '<table><tbody>' . self::renderNodes($rows) . '</tbody></table>';
|
|
}
|
|
|
|
private static function renderTableCell(array $attrs, array $content, bool $header): string
|
|
{
|
|
$tag = $header ? 'th' : 'td';
|
|
$extra = '';
|
|
if (!empty($attrs['colspan']) && $attrs['colspan'] > 1) {
|
|
$extra .= ' colspan="' . (int) $attrs['colspan'] . '"';
|
|
}
|
|
if (!empty($attrs['rowspan']) && $attrs['rowspan'] > 1) {
|
|
$extra .= ' rowspan="' . (int) $attrs['rowspan'] . '"';
|
|
}
|
|
|
|
// TipTap wraps cell content in a paragraph node; unwrap single paragraphs
|
|
if (count($content) === 1 && ($content[0]['type'] ?? '') === 'paragraph') {
|
|
$inner = self::renderNodes($content[0]['content'] ?? []);
|
|
} else {
|
|
$inner = self::renderNodes($content);
|
|
}
|
|
|
|
return "<{$tag}{$extra}>{$inner}</{$tag}>";
|
|
}
|
|
|
|
private static function extractText(array $nodes): string
|
|
{
|
|
return implode('', array_map(
|
|
fn($node) => $node['text'] ?? self::extractText($node['content'] ?? []),
|
|
$nodes
|
|
));
|
|
}
|
|
}
|