2026-02-13 10:56:23 +08:00
|
|
|
<script src="{{ asset('vendor/easymde/easymde.min.js') }}"></script>
|
2026-02-12 17:10:36 +08:00
|
|
|
<script>
|
|
|
|
|
(function () {
|
|
|
|
|
const previewEndpoint = '{{ route('admin.markdown.preview') }}';
|
|
|
|
|
const uploadEndpoint = '{{ route('admin.uploads.markdown-image') }}';
|
|
|
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
|
|
|
|
const instances = new Map();
|
|
|
|
|
|
|
|
|
|
const slugify = (value) => {
|
|
|
|
|
return String(value || '')
|
|
|
|
|
.normalize('NFKD')
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
|
|
|
.replace(/[^a-z0-9\u4e00-\u9fa5\s-]/g, '')
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(/[\s_]+/g, '-')
|
|
|
|
|
.replace(/-+/g, '-')
|
|
|
|
|
.replace(/^-|-$/g, '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const debounce = (callback, wait = 350) => {
|
|
|
|
|
let timer = null;
|
|
|
|
|
return (...args) => {
|
|
|
|
|
window.clearTimeout(timer);
|
|
|
|
|
timer = window.setTimeout(() => callback(...args), wait);
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderPreview = debounce(async (editor, previewElement) => {
|
|
|
|
|
if (!(previewElement instanceof HTMLElement)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const markdown = editor.value();
|
|
|
|
|
if (!markdown || markdown.trim() === '') {
|
2026-02-13 10:56:23 +08:00
|
|
|
previewElement.innerHTML = '<div class="preview-placeholder">Input Markdown on the left. Preview renders here.</div>';
|
2026-02-12 17:10:36 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 10:56:23 +08:00
|
|
|
previewElement.innerHTML = '<div class="text-muted">Generating preview...</div>';
|
2026-02-12 17:10:36 +08:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(previewEndpoint, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
|
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ markdown }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('preview-failed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
if (!payload || payload.success !== true) {
|
|
|
|
|
throw new Error('preview-failed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
previewElement.innerHTML = payload.html && payload.html.trim() !== ''
|
|
|
|
|
? payload.html
|
2026-02-13 10:56:23 +08:00
|
|
|
: '<div class="preview-placeholder">No preview content.</div>';
|
2026-02-12 17:10:36 +08:00
|
|
|
} catch (_) {
|
2026-02-13 10:56:23 +08:00
|
|
|
previewElement.innerHTML = '<div class="text-danger">Preview failed. Please retry.</div>';
|
2026-02-12 17:10:36 +08:00
|
|
|
}
|
|
|
|
|
}, 320);
|
|
|
|
|
|
|
|
|
|
const uploadImage = async (file) => {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('image', file);
|
|
|
|
|
|
|
|
|
|
const response = await fetch(uploadEndpoint, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
|
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
|
|
|
},
|
|
|
|
|
body: formData,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('upload-failed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
if (!payload || payload.success !== true || !payload.url) {
|
|
|
|
|
throw new Error('upload-failed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return payload;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const markdownForImage = (payload, fileName = 'image') => {
|
|
|
|
|
if (typeof payload.markdown === 'string' && payload.markdown.trim() !== '') {
|
|
|
|
|
return `\n${payload.markdown.trim()}\n`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const alt = fileName.replace(/\.[^.]+$/, '');
|
|
|
|
|
return `\n\n`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-13 10:56:23 +08:00
|
|
|
const syncBodyFullscreenClass = () => {
|
|
|
|
|
const hasFullscreen = document.querySelector('.editor-shell.is-fullscreen') !== null;
|
|
|
|
|
document.body.classList.toggle('editor-fullscreen-open', hasFullscreen);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const refreshEditorLayout = (editor) => {
|
|
|
|
|
if (!editor || !editor.codemirror) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
editor.codemirror.refresh();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateFullscreenButton = (button, isFullscreen) => {
|
|
|
|
|
if (!(button instanceof HTMLButtonElement)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
button.innerHTML = isFullscreen
|
|
|
|
|
? '<i class="bi bi-fullscreen-exit me-1"></i>Exit Fullscreen'
|
|
|
|
|
: '<i class="bi bi-arrows-fullscreen me-1"></i>Fullscreen';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const initShellControls = (textarea, editor, previewElement) => {
|
|
|
|
|
const shell = textarea.closest('.editor-shell');
|
|
|
|
|
if (!(shell instanceof HTMLElement) || shell.dataset.initialized === 'true') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tabButtons = Array.from(shell.querySelectorAll('.js-md-tab-btn'));
|
|
|
|
|
const panels = Array.from(shell.querySelectorAll('.editor-panel'));
|
|
|
|
|
const fullscreenButton = shell.querySelector('.js-md-fullscreen-btn');
|
|
|
|
|
|
|
|
|
|
const activateTab = (tab) => {
|
|
|
|
|
tabButtons.forEach((button) => {
|
|
|
|
|
const active = button.getAttribute('data-tab') === tab;
|
|
|
|
|
button.classList.toggle('is-active', active);
|
|
|
|
|
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
panels.forEach((panel) => {
|
|
|
|
|
const active = panel.getAttribute('data-panel') === tab;
|
|
|
|
|
panel.classList.toggle('is-active', active);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (tab === 'preview') {
|
|
|
|
|
renderPreview(editor, previewElement);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
refreshEditorLayout(editor);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
tabButtons.forEach((button) => {
|
|
|
|
|
button.addEventListener('click', () => {
|
|
|
|
|
activateTab(button.getAttribute('data-tab') || 'edit');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (fullscreenButton instanceof HTMLButtonElement) {
|
|
|
|
|
fullscreenButton.addEventListener('click', () => {
|
|
|
|
|
const willEnterFullscreen = !shell.classList.contains('is-fullscreen');
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.editor-shell.is-fullscreen').forEach((openedShell) => {
|
|
|
|
|
if (openedShell !== shell) {
|
|
|
|
|
openedShell.classList.remove('is-fullscreen');
|
|
|
|
|
const button = openedShell.querySelector('.js-md-fullscreen-btn');
|
|
|
|
|
updateFullscreenButton(button, false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
shell.classList.toggle('is-fullscreen', willEnterFullscreen);
|
|
|
|
|
updateFullscreenButton(fullscreenButton, willEnterFullscreen);
|
|
|
|
|
syncBodyFullscreenClass();
|
|
|
|
|
refreshEditorLayout(editor);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
shell.dataset.initialized = 'true';
|
|
|
|
|
activateTab('edit');
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-12 17:10:36 +08:00
|
|
|
const initEditor = (textarea) => {
|
|
|
|
|
if (!(textarea instanceof HTMLTextAreaElement) || instances.has(textarea.name)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const easyMde = new EasyMDE({
|
|
|
|
|
element: textarea,
|
|
|
|
|
spellChecker: false,
|
|
|
|
|
autoDownloadFontAwesome: false,
|
|
|
|
|
forceSync: true,
|
|
|
|
|
status: ['lines', 'words'],
|
2026-02-13 10:56:23 +08:00
|
|
|
placeholder: textarea.placeholder || 'Write Markdown here',
|
2026-02-12 17:10:36 +08:00
|
|
|
toolbar: [
|
|
|
|
|
'bold',
|
|
|
|
|
'italic',
|
|
|
|
|
'heading',
|
|
|
|
|
'|',
|
|
|
|
|
'quote',
|
|
|
|
|
'unordered-list',
|
|
|
|
|
'ordered-list',
|
|
|
|
|
'|',
|
|
|
|
|
'link',
|
|
|
|
|
'image',
|
|
|
|
|
'table',
|
|
|
|
|
'|',
|
|
|
|
|
'guide',
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const previewSelector = textarea.getAttribute('data-preview-target');
|
|
|
|
|
const previewElement = previewSelector ? document.querySelector(previewSelector) : null;
|
|
|
|
|
|
|
|
|
|
easyMde.codemirror.on('change', () => {
|
|
|
|
|
renderPreview(easyMde, previewElement);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
renderPreview(easyMde, previewElement);
|
2026-02-13 10:56:23 +08:00
|
|
|
initShellControls(textarea, easyMde, previewElement);
|
2026-02-12 17:10:36 +08:00
|
|
|
instances.set(textarea.name, easyMde);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const connectUploadButtons = () => {
|
|
|
|
|
document.querySelectorAll('.js-md-upload-advanced-btn').forEach((button) => {
|
|
|
|
|
button.addEventListener('click', () => {
|
|
|
|
|
const fieldName = button.getAttribute('data-editor-target');
|
|
|
|
|
const editor = fieldName ? instances.get(fieldName) : null;
|
|
|
|
|
if (!editor) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const input = document.createElement('input');
|
|
|
|
|
input.type = 'file';
|
|
|
|
|
input.accept = 'image/*';
|
|
|
|
|
|
|
|
|
|
input.onchange = async () => {
|
|
|
|
|
const file = input.files?.[0];
|
|
|
|
|
if (!file) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const originHtml = button.innerHTML;
|
|
|
|
|
button.disabled = true;
|
2026-02-13 10:56:23 +08:00
|
|
|
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Uploading...';
|
2026-02-12 17:10:36 +08:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const payload = await uploadImage(file);
|
|
|
|
|
editor.codemirror.replaceSelection(markdownForImage(payload, file.name || 'image'));
|
|
|
|
|
} catch (_) {
|
2026-02-13 10:56:23 +08:00
|
|
|
alert('Image upload failed. Please retry.');
|
2026-02-12 17:10:36 +08:00
|
|
|
} finally {
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
button.innerHTML = originHtml;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
input.click();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const initSlugAutoFill = () => {
|
|
|
|
|
const pairs = [
|
|
|
|
|
['input[name="name"]', 'input[name="slug"]'],
|
|
|
|
|
['input[name="title"]', 'input[name="slug"]'],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const [sourceSelector, slugSelector] of pairs) {
|
|
|
|
|
const sourceInput = document.querySelector(sourceSelector);
|
|
|
|
|
const slugInput = document.querySelector(slugSelector);
|
|
|
|
|
if (!(sourceInput instanceof HTMLInputElement) || !(slugInput instanceof HTMLInputElement)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let manualEdited = slugInput.value.trim() !== '';
|
|
|
|
|
slugInput.addEventListener('input', () => {
|
|
|
|
|
manualEdited = slugInput.value.trim() !== '';
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
sourceInput.addEventListener('input', () => {
|
|
|
|
|
if (manualEdited && slugInput.value.trim() !== '') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const suggested = slugify(sourceInput.value);
|
|
|
|
|
if (suggested !== '') {
|
|
|
|
|
slugInput.value = suggested;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-13 10:56:23 +08:00
|
|
|
document.addEventListener('keydown', (event) => {
|
|
|
|
|
if (event.key !== 'Escape') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.editor-shell.is-fullscreen').forEach((shell) => {
|
|
|
|
|
shell.classList.remove('is-fullscreen');
|
|
|
|
|
const button = shell.querySelector('.js-md-fullscreen-btn');
|
|
|
|
|
updateFullscreenButton(button, false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
syncBodyFullscreenClass();
|
|
|
|
|
instances.forEach((editor) => refreshEditorLayout(editor));
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-12 17:10:36 +08:00
|
|
|
document.querySelectorAll('textarea.js-md-editor-modern').forEach(initEditor);
|
|
|
|
|
connectUploadButtons();
|
|
|
|
|
initSlugAutoFill();
|
|
|
|
|
}());
|
|
|
|
|
</script>
|