600 lines
17 KiB
JavaScript
600 lines
17 KiB
JavaScript
/**
|
|
* Editor.js Entry Point
|
|
* Self-hosted Gutenberg-like editor
|
|
*/
|
|
|
|
import EditorJS from '@editorjs/editorjs';
|
|
import Header from '@editorjs/header';
|
|
import List from '@editorjs/list';
|
|
import Quote from '@editorjs/quote';
|
|
import Code from '@editorjs/code';
|
|
import Table from '@editorjs/table';
|
|
import Delimiter from '@editorjs/delimiter';
|
|
import ImageTool from '@editorjs/image';
|
|
import LinkTool from '@editorjs/link';
|
|
|
|
// Initialize Editor.js when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const editorContainer = document.getElementById('editorjs');
|
|
if (!editorContainer) {
|
|
console.warn('Editor.js container not found');
|
|
return;
|
|
}
|
|
|
|
// Get hidden inputs
|
|
const contentJsonInput = document.getElementById('content_json');
|
|
const contentHtmlInput = document.getElementById('content_html');
|
|
const contentInput = document.getElementById('content');
|
|
const excerptInput = document.getElementById('excerpt');
|
|
const featuredImageInput = document.getElementById('featured_image');
|
|
const statusInput = document.getElementById('status');
|
|
|
|
// Convert HTML to Editor.js format
|
|
function convertHtmlToEditorJs(html) {
|
|
if (!html || html.trim() === '') {
|
|
return null;
|
|
}
|
|
|
|
// Create temporary div to parse HTML
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = html.trim();
|
|
|
|
const blocks = [];
|
|
let blockId = 0;
|
|
|
|
// Process all child nodes
|
|
function processNode(node) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const text = node.textContent.trim();
|
|
if (text) {
|
|
blocks.push({
|
|
type: 'paragraph',
|
|
data: {
|
|
text: text
|
|
},
|
|
id: `block-${blockId++}`
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
return;
|
|
}
|
|
|
|
const tagName = node.tagName.toLowerCase();
|
|
|
|
switch (tagName) {
|
|
case 'p':
|
|
const pText = node.textContent.trim();
|
|
if (pText) {
|
|
blocks.push({
|
|
type: 'paragraph',
|
|
data: {
|
|
text: pText
|
|
},
|
|
id: `block-${blockId++}`
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'h1':
|
|
case 'h2':
|
|
case 'h3':
|
|
case 'h4':
|
|
case 'h5':
|
|
case 'h6':
|
|
const level = parseInt(tagName.charAt(1));
|
|
const headerText = node.textContent.trim();
|
|
if (headerText) {
|
|
blocks.push({
|
|
type: 'header',
|
|
data: {
|
|
text: headerText,
|
|
level: level
|
|
},
|
|
id: `block-${blockId++}`
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'ul':
|
|
case 'ol':
|
|
const items = [];
|
|
const listItems = node.querySelectorAll('li');
|
|
listItems.forEach(li => {
|
|
const itemText = li.textContent.trim();
|
|
if (itemText) {
|
|
items.push(itemText);
|
|
}
|
|
});
|
|
if (items.length > 0) {
|
|
blocks.push({
|
|
type: 'list',
|
|
data: {
|
|
style: tagName === 'ol' ? 'ordered' : 'unordered',
|
|
items: items
|
|
},
|
|
id: `block-${blockId++}`
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'blockquote':
|
|
const quoteText = node.querySelector('p')?.textContent.trim() || node.textContent.trim();
|
|
const quoteCaption = node.querySelector('cite')?.textContent.trim() || '';
|
|
if (quoteText) {
|
|
blocks.push({
|
|
type: 'quote',
|
|
data: {
|
|
text: quoteText,
|
|
caption: quoteCaption
|
|
},
|
|
id: `block-${blockId++}`
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'pre':
|
|
const codeText = node.querySelector('code')?.textContent || node.textContent.trim();
|
|
if (codeText) {
|
|
blocks.push({
|
|
type: 'code',
|
|
data: {
|
|
code: codeText
|
|
},
|
|
id: `block-${blockId++}`
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'table':
|
|
const rows = [];
|
|
const tableRows = node.querySelectorAll('tr');
|
|
tableRows.forEach(tr => {
|
|
const cells = [];
|
|
const tableCells = tr.querySelectorAll('td, th');
|
|
tableCells.forEach(cell => {
|
|
cells.push(cell.textContent.trim());
|
|
});
|
|
if (cells.length > 0) {
|
|
rows.push(cells);
|
|
}
|
|
});
|
|
if (rows.length > 0) {
|
|
blocks.push({
|
|
type: 'table',
|
|
data: {
|
|
content: rows
|
|
},
|
|
id: `block-${blockId++}`
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'hr':
|
|
blocks.push({
|
|
type: 'delimiter',
|
|
data: {},
|
|
id: `block-${blockId++}`
|
|
});
|
|
break;
|
|
|
|
case 'figure':
|
|
case 'img':
|
|
const img = node.tagName === 'img' ? node : node.querySelector('img');
|
|
if (img && img.src) {
|
|
const caption = node.querySelector('figcaption')?.textContent.trim() || img.alt || '';
|
|
blocks.push({
|
|
type: 'image',
|
|
data: {
|
|
file: {
|
|
url: img.src
|
|
},
|
|
caption: caption
|
|
},
|
|
id: `block-${blockId++}`
|
|
});
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// For other elements, process children
|
|
Array.from(node.childNodes).forEach(child => {
|
|
processNode(child);
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Process all direct children
|
|
Array.from(tempDiv.childNodes).forEach(child => {
|
|
processNode(child);
|
|
});
|
|
|
|
// If no blocks created, create empty paragraph
|
|
if (blocks.length === 0) {
|
|
blocks.push({
|
|
type: 'paragraph',
|
|
data: {
|
|
text: ''
|
|
},
|
|
id: `block-${blockId++}`
|
|
});
|
|
}
|
|
|
|
return {
|
|
time: Date.now(),
|
|
blocks: blocks,
|
|
version: '2.31.0'
|
|
};
|
|
}
|
|
|
|
// Parse initial data
|
|
let initialData = null;
|
|
|
|
// Try to load from content_json first
|
|
if (contentJsonInput && contentJsonInput.value) {
|
|
try {
|
|
initialData = JSON.parse(contentJsonInput.value);
|
|
} catch (e) {
|
|
console.error('Failed to parse initial JSON:', e);
|
|
}
|
|
}
|
|
|
|
// If no JSON data, try to convert from HTML
|
|
if (!initialData) {
|
|
const htmlContent = (contentHtmlInput && contentHtmlInput.value)
|
|
? contentHtmlInput.value
|
|
: (contentInput && contentInput.value)
|
|
? contentInput.value
|
|
: '';
|
|
|
|
if (htmlContent) {
|
|
console.log('Converting HTML to Editor.js format...');
|
|
initialData = convertHtmlToEditorJs(htmlContent);
|
|
|
|
// Update content_json input with converted data
|
|
if (initialData && contentJsonInput) {
|
|
contentJsonInput.value = JSON.stringify(initialData);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize Editor.js
|
|
const editor = new EditorJS({
|
|
holder: 'editorjs',
|
|
data: initialData,
|
|
placeholder: 'Mulai menulis konten...',
|
|
tools: {
|
|
header: {
|
|
class: Header,
|
|
config: {
|
|
placeholder: 'Masukkan heading',
|
|
levels: [1, 2, 3, 4, 5, 6],
|
|
defaultLevel: 2,
|
|
},
|
|
inlineToolbar: true,
|
|
},
|
|
list: {
|
|
class: List,
|
|
inlineToolbar: true,
|
|
config: {
|
|
defaultStyle: 'unordered',
|
|
},
|
|
},
|
|
quote: {
|
|
class: Quote,
|
|
inlineToolbar: true,
|
|
shortcut: 'CMD+SHIFT+O',
|
|
config: {
|
|
quotePlaceholder: 'Masukkan kutipan',
|
|
captionPlaceholder: 'Penulis kutipan',
|
|
},
|
|
},
|
|
code: {
|
|
class: Code,
|
|
config: {
|
|
placeholder: 'Masukkan kode',
|
|
},
|
|
},
|
|
table: {
|
|
class: Table,
|
|
inlineToolbar: true,
|
|
config: {
|
|
rows: 2,
|
|
cols: 2,
|
|
},
|
|
},
|
|
delimiter: Delimiter,
|
|
image: {
|
|
class: ImageTool,
|
|
config: {
|
|
endpoints: {
|
|
byFile: window.uploadEndpoint || '/admin/upload',
|
|
},
|
|
field: 'image',
|
|
types: 'image/jpeg,image/png,image/webp',
|
|
captionPlaceholder: 'Masukkan caption gambar',
|
|
buttonContent: 'Pilih gambar',
|
|
uploader: {
|
|
async uploadByFile(file) {
|
|
const formData = new FormData();
|
|
formData.append('image', file);
|
|
formData.append(window.csrfTokenName, window.csrfTokenValue);
|
|
|
|
const response = await fetch(window.uploadEndpoint || '/admin/upload', {
|
|
method: 'POST',
|
|
headers: {
|
|
[window.csrfHeaderName]: window.csrfTokenValue,
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Upload failed');
|
|
}
|
|
|
|
const result = await response.json();
|
|
return {
|
|
success: 1,
|
|
file: {
|
|
url: result.url,
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
},
|
|
linkTool: {
|
|
class: LinkTool,
|
|
config: {
|
|
endpoint: window.linkPreviewEndpoint || '/admin/link-preview',
|
|
},
|
|
},
|
|
},
|
|
autofocus: false,
|
|
onChange: async () => {
|
|
// Autosave every 10 seconds
|
|
clearTimeout(window.autosaveTimeout);
|
|
window.autosaveTimeout = setTimeout(async () => {
|
|
await saveEditorContent(true);
|
|
}, 10000);
|
|
},
|
|
});
|
|
|
|
// Save function
|
|
async function saveEditorContent(isAutosave = false) {
|
|
try {
|
|
const outputData = await editor.save();
|
|
const jsonString = JSON.stringify(outputData);
|
|
|
|
// Update hidden inputs
|
|
if (contentJsonInput) {
|
|
contentJsonInput.value = jsonString;
|
|
}
|
|
|
|
// Render to HTML (client-side preview)
|
|
const htmlContent = renderEditorJsToHtml(outputData.blocks);
|
|
if (contentHtmlInput) {
|
|
contentHtmlInput.value = htmlContent;
|
|
}
|
|
|
|
// Extract excerpt from first paragraph
|
|
const firstParagraph = outputData.blocks.find(b => b.type === 'paragraph');
|
|
if (firstParagraph && excerptInput) {
|
|
const excerpt = firstParagraph.data.text.substring(0, 160).replace(/<[^>]*>/g, '');
|
|
excerptInput.value = excerpt;
|
|
}
|
|
|
|
// Autosave to server
|
|
if (isAutosave && window.pageId) {
|
|
const formData = new FormData();
|
|
formData.append('content_json', jsonString);
|
|
formData.append('content_html', htmlContent);
|
|
formData.append(window.csrfTokenName, window.csrfTokenValue);
|
|
|
|
await fetch(`/admin/pages/autosave/${window.pageId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
[window.csrfHeaderName]: window.csrfTokenValue,
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
// Show autosave indicator
|
|
const indicator = document.getElementById('autosave-indicator');
|
|
if (indicator) {
|
|
indicator.textContent = 'Disimpan otomatis';
|
|
indicator.classList.remove('hidden');
|
|
setTimeout(() => {
|
|
indicator.classList.add('hidden');
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
return outputData;
|
|
} catch (error) {
|
|
console.error('Error saving editor content:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Render Editor.js blocks to HTML
|
|
function renderEditorJsToHtml(blocks) {
|
|
let html = '';
|
|
|
|
blocks.forEach(block => {
|
|
switch(block.type) {
|
|
case 'paragraph':
|
|
html += `<p>${escapeHtml(block.data.text)}</p>`;
|
|
break;
|
|
case 'header':
|
|
html += `<h${block.data.level}>${escapeHtml(block.data.text)}</h${block.data.level}>`;
|
|
break;
|
|
case 'list':
|
|
const listTag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
|
html += `<${listTag}>`;
|
|
block.data.items.forEach(item => {
|
|
html += `<li>${escapeHtml(item)}</li>`;
|
|
});
|
|
html += `</${listTag}>`;
|
|
break;
|
|
case 'quote':
|
|
html += `<blockquote><p>${escapeHtml(block.data.text)}</p>`;
|
|
if (block.data.caption) {
|
|
html += `<cite>${escapeHtml(block.data.caption)}</cite>`;
|
|
}
|
|
html += `</blockquote>`;
|
|
break;
|
|
case 'code':
|
|
html += `<pre><code>${escapeHtml(block.data.code)}</code></pre>`;
|
|
break;
|
|
case 'table':
|
|
html += '<table><tbody>';
|
|
block.data.content.forEach(row => {
|
|
html += '<tr>';
|
|
row.forEach(cell => {
|
|
html += `<td>${escapeHtml(cell)}</td>`;
|
|
});
|
|
html += '</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
break;
|
|
case 'delimiter':
|
|
html += '<hr>';
|
|
break;
|
|
case 'image':
|
|
html += `<figure><img src="${escapeHtml(block.data.file.url)}" alt="${escapeHtml(block.data.caption || '')}">`;
|
|
if (block.data.caption) {
|
|
html += `<figcaption>${escapeHtml(block.data.caption)}</figcaption>`;
|
|
}
|
|
html += '</figure>';
|
|
break;
|
|
case 'linkTool':
|
|
html += `<div class="link-tool"><a href="${escapeHtml(block.data.link)}" target="_blank">${escapeHtml(block.data.meta?.title || block.data.link)}</a></div>`;
|
|
break;
|
|
default:
|
|
// Unknown block type - ignore safely
|
|
console.warn('Unknown block type:', block.type);
|
|
}
|
|
});
|
|
|
|
return html;
|
|
}
|
|
|
|
// Escape HTML
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Handle form submit
|
|
const form = editorContainer.closest('form');
|
|
if (form) {
|
|
const submitHandler = async function(e) {
|
|
e.preventDefault();
|
|
|
|
try {
|
|
await saveEditorContent(false);
|
|
|
|
// Remove event listener to avoid infinite loop
|
|
form.removeEventListener('submit', submitHandler);
|
|
|
|
// Use requestSubmit if available (modern browsers), otherwise create submit button
|
|
if (form.requestSubmit) {
|
|
form.requestSubmit();
|
|
} else {
|
|
// Fallback: create temporary submit button
|
|
const submitButton = document.createElement('button');
|
|
submitButton.type = 'submit';
|
|
submitButton.style.display = 'none';
|
|
form.appendChild(submitButton);
|
|
submitButton.click();
|
|
form.removeChild(submitButton);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving before submit:', error);
|
|
alert('Error saat menyimpan konten. Silakan coba lagi.');
|
|
}
|
|
};
|
|
|
|
form.addEventListener('submit', submitHandler);
|
|
}
|
|
|
|
// Preview button handler
|
|
const previewBtn = document.getElementById('preview-btn');
|
|
if (previewBtn) {
|
|
previewBtn.addEventListener('click', async function() {
|
|
try {
|
|
const outputData = await editor.save();
|
|
const htmlContent = renderEditorJsToHtml(outputData.blocks);
|
|
|
|
// Open preview in new window
|
|
const previewWindow = window.open('', '_blank');
|
|
previewWindow.document.write(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Preview</title>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; }
|
|
img { max-width: 100%; height: auto; }
|
|
table { border-collapse: collapse; width: 100%; }
|
|
table td, table th { border: 1px solid #ddd; padding: 8px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${htmlContent}
|
|
</body>
|
|
</html>
|
|
`);
|
|
} catch (error) {
|
|
console.error('Error generating preview:', error);
|
|
alert('Error saat membuat preview.');
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('Editor.js initialized successfully');
|
|
|
|
// Fix toolbar z-index to stay below header
|
|
setTimeout(() => {
|
|
const style = document.createElement('style');
|
|
style.id = 'editorjs-zindex-fix';
|
|
style.textContent = `
|
|
.ce-toolbar,
|
|
.ce-inline-toolbar,
|
|
.ce-popover,
|
|
.ce-conversion-toolbar,
|
|
.ce-settings,
|
|
.ce-block-settings,
|
|
.ce-toolbar__plus,
|
|
.ce-toolbar__settings-btn,
|
|
.ce-popover__item,
|
|
.ce-popover__items,
|
|
.ce-settings__button,
|
|
.ce-toolbar__content,
|
|
.ce-toolbar__actions {
|
|
z-index: 10 !important;
|
|
}
|
|
header,
|
|
header[class*="sticky"],
|
|
header[class*="fixed"],
|
|
header.sticky,
|
|
header.fixed {
|
|
z-index: 99999 !important;
|
|
}
|
|
`;
|
|
// Remove existing fix if any
|
|
const existingFix = document.getElementById('editorjs-zindex-fix');
|
|
if (existingFix) {
|
|
existingFix.remove();
|
|
}
|
|
document.head.appendChild(style);
|
|
}, 100);
|
|
});
|
|
|