forked from UKSOURCE/hailearning.edu.vn
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
/**
|
|
* Convert EditorJS JSON data to HTML
|
|
* Supports: header, paragraph, list, image, quote, marker, embed, code, table, delimiter,
|
|
* checklist, link, inlineCode, warning, alert, raw, personality, and more
|
|
*/
|
|
|
|
interface EditorJSBlockData {
|
|
text?: string;
|
|
level?: number;
|
|
items?: string[] | Array<{ text?: string; checked?: boolean }>;
|
|
style?: 'ordered' | 'unordered';
|
|
file?: { url?: string };
|
|
url?: string;
|
|
caption?: string;
|
|
withBorder?: boolean;
|
|
withBackground?: boolean;
|
|
stretched?: boolean;
|
|
alignment?: 'left' | 'center' | 'right';
|
|
service?: string;
|
|
source?: string;
|
|
embed?: string;
|
|
width?: number;
|
|
height?: number;
|
|
code?: string;
|
|
language?: string;
|
|
content?: string[][];
|
|
link?: string;
|
|
meta?: { url?: string; title?: string; description?: string; image?: string };
|
|
title?: string;
|
|
message?: string;
|
|
html?: string;
|
|
htmlCode?: string;
|
|
name?: string;
|
|
description?: string;
|
|
photo?: string;
|
|
linkText?: string;
|
|
linkUrl?: string;
|
|
type?: string;
|
|
}
|
|
|
|
interface EditorJSBlock {
|
|
id?: string;
|
|
type: string;
|
|
data: EditorJSBlockData;
|
|
}
|
|
|
|
interface EditorJSData {
|
|
time?: number;
|
|
blocks: EditorJSBlock[];
|
|
version?: string;
|
|
}
|
|
|
|
/**
|
|
* Convert EditorJS block to HTML
|
|
*/
|
|
function blockToHtml(block: EditorJSBlock, baseUrl?: string): string {
|
|
const { type, data } = block;
|
|
|
|
switch (type) {
|
|
case 'header': {
|
|
const level = data.level || 2;
|
|
const text = escapeHtml(data.text || '');
|
|
return `<h${level}>${text}</h${level}>`;
|
|
}
|
|
|
|
case 'paragraph': {
|
|
let text = data.text || '';
|
|
// Convert markdown-style links to HTML
|
|
text = text.replace(
|
|
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
'<a href="$2">$1</a>'
|
|
);
|
|
// Convert **bold** to <strong>
|
|
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
// Convert *italic* to <em>
|
|
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
// Convert `inline code` to <code>
|
|
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
// Convert line breaks
|
|
text = text.replace(/\n/g, '<br>');
|
|
return `<p>${text}</p>`;
|
|
}
|
|
|
|
case 'list': {
|
|
const items = data.items || [];
|
|
const style = data.style || 'unordered';
|
|
const tag = style === 'ordered' ? 'ol' : 'ul';
|
|
const itemsHtml = items
|
|
.map((item: { text?: string; checked?: boolean }) => {
|
|
const escapedItem = escapeHtml(item.text || '');
|
|
return `<li>${escapedItem}</li>`;
|
|
})
|
|
.join('');
|
|
return `<${tag}>${itemsHtml}</${tag}>`;
|
|
}
|
|
|
|
case 'image': {
|
|
const url = data.file?.url || data.url || '';
|
|
const caption = data.caption || '';
|
|
const withBorder = data.withBorder || false;
|
|
const withBackground = data.withBackground || false;
|
|
const stretched = data.stretched || false;
|
|
|
|
let imageUrl = url;
|
|
if (baseUrl && imageUrl && !imageUrl.startsWith('http')) {
|
|
if (imageUrl.startsWith('/')) {
|
|
imageUrl = `${baseUrl}${imageUrl}`;
|
|
} else {
|
|
imageUrl = `${baseUrl}/${imageUrl}`;
|
|
}
|
|
}
|
|
|
|
const classes: string[] = [];
|
|
if (withBorder) classes.push('with-border');
|
|
if (withBackground) classes.push('with-background');
|
|
if (stretched) classes.push('stretched');
|
|
|
|
const classAttr = classes.length > 0 ? ` class="${classes.join(' ')}"` : '';
|
|
const captionHtml = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : '';
|
|
|
|
return `<figure${classAttr}>
|
|
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(caption)}" />
|
|
${captionHtml}
|
|
</figure>`;
|
|
}
|
|
|
|
case 'quote': {
|
|
const text = escapeHtml(data.text || '');
|
|
const caption = escapeHtml(data.caption || '');
|
|
const alignment = data.alignment || 'left';
|
|
const captionHtml = caption ? `<cite>${caption}</cite>` : '';
|
|
|
|
return `<blockquote class="quote-${alignment}">
|
|
<p>${text}</p>
|
|
${captionHtml}
|
|
</blockquote>`;
|
|
}
|
|
|
|
case 'marker': {
|
|
const text = data.text || '';
|
|
// Marker typically highlights text, so we'll use <mark>
|
|
return `<mark>${escapeHtml(text)}</mark>`;
|
|
}
|
|
|
|
case 'embed': {
|
|
const service = data.service || '';
|
|
const embed = data.embed || '';
|
|
const width = data.width || 600;
|
|
const height = data.height || 400;
|
|
const caption = data.caption || '';
|
|
|
|
if (service === 'youtube' || service === 'vimeo') {
|
|
const captionHtml = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : '';
|
|
return `<figure class="embed-${service}">
|
|
<iframe src="${escapeHtml(embed)}" width="${width}" height="${height}" frameborder="0" allowfullscreen></iframe>
|
|
${captionHtml}
|
|
</figure>`;
|
|
}
|
|
|
|
// Fallback for other embed types
|
|
return `<div class="embed embed-${service}">
|
|
${embed}
|
|
${caption ? `<p class="embed-caption">${escapeHtml(caption)}</p>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
case 'code': {
|
|
const code = data.code || '';
|
|
const language = data.language || '';
|
|
const langAttr = language ? ` class="language-${language}"` : '';
|
|
return `<pre><code${langAttr}>${escapeHtml(code)}</code></pre>`;
|
|
}
|
|
|
|
case 'delimiter': {
|
|
return '<div class="delimiter">***</div>';
|
|
}
|
|
|
|
case 'table': {
|
|
const content = data.content || [];
|
|
if (content.length === 0) return '';
|
|
|
|
// First row is usually header
|
|
const headerRow = content[0] || [];
|
|
const headerCells = headerRow
|
|
.map((cell: string) => `<th>${escapeHtml(cell)}</th>`)
|
|
.join('');
|
|
|
|
// Rest are body rows
|
|
const bodyRows = content.slice(1)
|
|
.map((row: string[]) => {
|
|
const cells = row
|
|
.map((cell: string) => `<td>${escapeHtml(cell)}</td>`)
|
|
.join('');
|
|
return `<tr>${cells}</tr>`;
|
|
})
|
|
.join('');
|
|
|
|
return `<table>
|
|
${headerCells ? `<thead><tr>${headerCells}</tr></thead>` : ''}
|
|
<tbody>${bodyRows}</tbody>
|
|
</table>`;
|
|
}
|
|
|
|
case 'checklist': {
|
|
const items = data.items || [];
|
|
const itemsHtml = items
|
|
.map((item: { text?: string; checked?: boolean }) => {
|
|
const text = escapeHtml(item.text || '');
|
|
const checked = item.checked ? ' checked' : '';
|
|
return `<div class="checklist-item">
|
|
<input type="checkbox"${checked} disabled>
|
|
<label>${text}</label>
|
|
</div>`;
|
|
})
|
|
.join('');
|
|
return `<div class="checklist">${itemsHtml}</div>`;
|
|
}
|
|
|
|
case 'linkTool':
|
|
case 'link': {
|
|
const link = data.link || data.url || '';
|
|
const meta = data.meta || {};
|
|
const title = meta.title || data.title || link;
|
|
const description = meta.description || '';
|
|
const image = meta.image || '';
|
|
|
|
let imageHtml = '';
|
|
if (image) {
|
|
let imageUrl = image;
|
|
if (baseUrl && imageUrl && !imageUrl.startsWith('http')) {
|
|
imageUrl = imageUrl.startsWith('/') ? `${baseUrl}${imageUrl}` : `${baseUrl}/${imageUrl}`;
|
|
}
|
|
imageHtml = `<div class="link-image">
|
|
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(title)}" />
|
|
</div>`;
|
|
}
|
|
|
|
return `<div class="link-block">
|
|
<a href="${escapeHtml(link)}" target="_blank" rel="noopener noreferrer">
|
|
${imageHtml}
|
|
<div class="link-content">
|
|
<div class="link-title">${escapeHtml(title)}</div>
|
|
${description ? `<div class="link-description">${escapeHtml(description)}</div>` : ''}
|
|
<div class="link-url">${escapeHtml(link)}</div>
|
|
</div>
|
|
</a>
|
|
</div>`;
|
|
}
|
|
|
|
case 'inlineCode': {
|
|
const code = data.text || '';
|
|
return `<code class="inline-code">${escapeHtml(code)}</code>`;
|
|
}
|
|
|
|
case 'warning': {
|
|
const title = data.title || 'Warning';
|
|
const message = data.message || '';
|
|
return `<div class="warning-block">
|
|
<div class="warning-title">⚠️ ${escapeHtml(title)}</div>
|
|
<div class="warning-message">${escapeHtml(message)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
case 'alert': {
|
|
const type = data.type || 'info';
|
|
const title = data.title || '';
|
|
const message = data.message || '';
|
|
return `<div class="alert alert-${type}">
|
|
${title ? `<div class="alert-title">${escapeHtml(title)}</div>` : ''}
|
|
<div class="alert-message">${escapeHtml(message)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
case 'raw': {
|
|
const html = data.html || data.htmlCode || '';
|
|
// Return raw HTML (be careful with XSS)
|
|
return html;
|
|
}
|
|
|
|
case 'personality': {
|
|
const name = data.name || '';
|
|
const description = data.description || '';
|
|
const photo = data.photo || '';
|
|
const linkText = data.linkText || '';
|
|
const linkUrl = data.linkUrl || '';
|
|
|
|
let photoHtml = '';
|
|
if (photo) {
|
|
let photoUrl = photo;
|
|
if (baseUrl && photoUrl && !photoUrl.startsWith('http')) {
|
|
photoUrl = photoUrl.startsWith('/') ? `${baseUrl}${photoUrl}` : `${baseUrl}/${photoUrl}`;
|
|
}
|
|
photoHtml = `<div class="personality-photo">
|
|
<img src="${escapeHtml(photoUrl)}" alt="${escapeHtml(name)}" />
|
|
</div>`;
|
|
}
|
|
|
|
const linkHtml = linkUrl && linkText
|
|
? `<a href="${escapeHtml(linkUrl)}" class="personality-link">${escapeHtml(linkText)}</a>`
|
|
: '';
|
|
|
|
return `<div class="personality-block">
|
|
${photoHtml}
|
|
<div class="personality-content">
|
|
<div class="personality-name">${escapeHtml(name)}</div>
|
|
${description ? `<div class="personality-description">${escapeHtml(description)}</div>` : ''}
|
|
${linkHtml}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
default:
|
|
// Unknown block type - return empty or log warning
|
|
console.warn(`Unknown EditorJS block type: ${type}`);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escape HTML special characters
|
|
*/
|
|
function escapeHtml(text: string): string {
|
|
const map: { [key: string]: string } = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
};
|
|
|
|
return text.replace(/[&<>"']/g, (m) => map[m]);
|
|
}
|
|
|
|
/**
|
|
* Convert EditorJS JSON data to HTML string
|
|
* @param editorData - EditorJS JSON data (can be string or object)
|
|
* @param baseUrl - Base URL for images (optional)
|
|
* @returns HTML string
|
|
*/
|
|
export function editorjsToHtml(
|
|
editorData: string | EditorJSData | null | undefined,
|
|
baseUrl?: string
|
|
): string {
|
|
if (!editorData) {
|
|
return '';
|
|
}
|
|
|
|
// If it's a string, try to parse it as JSON
|
|
let parsedData: EditorJSData;
|
|
if (typeof editorData === 'string') {
|
|
try {
|
|
parsedData = JSON.parse(editorData);
|
|
} catch {
|
|
// If parsing fails, assume it's already HTML
|
|
return editorData;
|
|
}
|
|
} else {
|
|
parsedData = editorData;
|
|
}
|
|
|
|
// If it doesn't have blocks, assume it's already HTML
|
|
if (!parsedData.blocks || !Array.isArray(parsedData.blocks)) {
|
|
if (typeof editorData === 'string') {
|
|
return editorData;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// Convert each block to HTML
|
|
const htmlBlocks = parsedData.blocks
|
|
.map((block) => blockToHtml(block, baseUrl))
|
|
.filter((html) => html.length > 0);
|
|
|
|
return htmlBlocks.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Check if content is EditorJS format
|
|
*/
|
|
export function isEditorJSFormat(
|
|
content: string | EditorJSData | null | undefined
|
|
): boolean {
|
|
if (!content) return false;
|
|
|
|
try {
|
|
const parsed =
|
|
typeof content === 'string' ? JSON.parse(content) : content;
|
|
return (
|
|
parsed &&
|
|
typeof parsed === 'object' &&
|
|
Array.isArray(parsed.blocks) &&
|
|
parsed.blocks.length > 0
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|