Files
breadcrumb-the-shire/web/js/components/app-page-editor.js

239 lines
7.0 KiB
JavaScript

/**
* Editor.js integration — lifecycle-managed page editor module.
*/
import EditorJS from '../../vendor/editorjs/editorjs.mjs';
import { postForm } from '../core/app-http.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const parseData = (value) => {
if (!value) {
return { blocks: [] };
}
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === 'object') {
if (!Array.isArray(parsed.blocks)) {
parsed.blocks = [];
}
return parsed;
}
} catch {
// ignore invalid JSON
}
return { blocks: [] };
};
const resolveTools = (form) => {
const HeaderTool = window.Header || window.EditorJSHeader || null;
const ListTool = window.EditorjsList || window.List || null;
const ChecklistTool = window.Checklist || window.EditorJSChecklist || null;
const TableTool = window.Table || window.EditorJSTable || null;
const ColumnsTool = window.editorjsColumns || window.Columns || null;
const MarkerTool = window.Marker || null;
const tools = {};
if (HeaderTool) {
tools.header = {
class: HeaderTool,
inlineToolbar: true,
config: {
placeholder: form.dataset.headerPlaceholder || 'Heading',
levels: [1, 2, 3, 4],
defaultLevel: 1,
},
};
}
if (ListTool) {
tools.list = {
class: ListTool,
inlineToolbar: true,
config: {
defaultStyle: 'unordered',
},
};
}
if (ChecklistTool) {
tools.checklist = {
class: ChecklistTool,
inlineToolbar: true,
};
}
if (TableTool) {
tools.table = {
class: TableTool,
inlineToolbar: true,
config: {
rows: 2,
cols: 2,
},
};
}
if (ColumnsTool) {
const innerTools = { ...tools };
tools.columns = {
class: ColumnsTool,
config: {
EditorJsLibrary: EditorJS,
tools: innerTools,
},
};
}
if (MarkerTool) {
tools.marker = {
class: MarkerTool,
shortcut: 'CMD+SHIFT+M',
};
}
return {
tools,
inlineTools: MarkerTool ? ['marker', 'bold', 'italic', 'link'] : ['bold', 'italic', 'link'],
};
};
export function initPageEditor(root = document, config = {}) {
const host = resolveHost(root);
const formSelector = String(config.selector || '[data-page-editor]').trim() || '[data-page-editor]';
const form = host.matches?.(formSelector) ? host : host.querySelector(formSelector);
if (!(form instanceof HTMLFormElement)) {
return { destroy: () => {} };
}
if (form.dataset.pageEditorBound === '1' && form._pageEditorApi) {
return form._pageEditorApi;
}
const holderSelector = String(config.holderSelector || '[data-editor-holder]').trim() || '[data-editor-holder]';
const contentFieldSelector = String(config.contentFieldSelector || '#page-content').trim() || '#page-content';
const controlsRootSelector = String(config.controlsRootSelector || '').trim();
const toggleSelector = String(config.toggleSelector || '[data-editor-toggle]').trim() || '[data-editor-toggle]';
const saveSelector = String(config.saveSelector || '[data-editor-save]').trim() || '[data-editor-save]';
const holder = form.querySelector(holderSelector);
if (!(holder instanceof HTMLElement)) {
warnOnce('UI_EL_MISSING', 'Missing editor holder', { module: 'page-editor' });
return { destroy: () => {} };
}
const contentField = form.querySelector(contentFieldSelector);
if (!(contentField instanceof HTMLTextAreaElement)) {
warnOnce('UI_EL_MISSING', `Missing page editor content field: ${contentFieldSelector}`, { module: 'page-editor' });
return { destroy: () => {} };
}
const controlsRoot = controlsRootSelector !== ''
? document.querySelector(controlsRootSelector)
: document;
const controlsHost = controlsRoot && typeof controlsRoot.querySelector === 'function'
? controlsRoot
: document;
const toggleButton = controlsHost.querySelector(toggleSelector);
const saveButton = controlsHost.querySelector(saveSelector);
const canEdit = form.dataset.canEdit === '1';
let mode = form.dataset.editMode === 'edit' && canEdit ? 'edit' : 'view';
form.dataset.editMode = mode;
form.dataset.pageEditorBound = '1';
const setToggleLabel = (nextMode) => {
if (!(toggleButton instanceof HTMLElement)) {
return;
}
const viewLabel = toggleButton.dataset.viewLabel || '';
const editLabel = toggleButton.dataset.editLabel || '';
const text = nextMode === 'edit' ? viewLabel : editLabel;
const icon = toggleButton.querySelector('[data-editor-icon]');
if (icon instanceof HTMLElement) {
icon.className = nextMode === 'edit' ? 'bi bi-eye-fill' : 'bi bi-pencil-square';
}
toggleButton.setAttribute('aria-label', text);
toggleButton.dataset.tooltip = text;
};
setToggleLabel(mode);
if (saveButton instanceof HTMLButtonElement) {
saveButton.disabled = mode !== 'edit';
}
const toolConfig = resolveTools(form);
const editor = new EditorJS({
holder,
data: parseData(contentField.value),
readOnly: mode !== 'edit',
autofocus: mode === 'edit',
placeholder: form.dataset.placeholder || undefined,
tools: toolConfig.tools,
inlineToolbar: toolConfig.inlineTools,
});
const toggleMode = async (nextMode) => {
if (!canEdit || mode === nextMode) {
return;
}
mode = nextMode;
form.dataset.editMode = mode;
setToggleLabel(mode);
if (editor.readOnly && typeof editor.readOnly.toggle === 'function') {
await editor.readOnly.toggle(mode !== 'edit');
}
if (saveButton instanceof HTMLButtonElement) {
saveButton.disabled = mode !== 'edit';
}
};
const onToggleClick = () => {
void toggleMode(mode === 'edit' ? 'view' : 'edit');
};
const onSubmit = async (event) => {
if (form.dataset.submitting === '1') {
return;
}
event.preventDefault();
const output = await editor.save();
contentField.value = JSON.stringify(output);
form.dataset.submitting = '1';
try {
const data = await postForm(form.action || window.location.pathname, new FormData(form));
if (data && typeof data === 'object' && typeof data.redirect === 'string' && data.redirect.trim() !== '') {
window.location.href = data.redirect;
return;
}
window.location.reload();
} catch {
form.submit();
}
};
if (toggleButton instanceof HTMLElement && canEdit) {
toggleButton.addEventListener('click', onToggleClick);
}
if (canEdit) {
form.addEventListener('submit', onSubmit);
}
let destroyed = false;
const destroy = () => {
if (destroyed) {
return;
}
destroyed = true;
if (toggleButton instanceof HTMLElement && canEdit) {
toggleButton.removeEventListener('click', onToggleClick);
}
if (canEdit) {
form.removeEventListener('submit', onSubmit);
}
if (typeof editor.destroy === 'function') {
editor.destroy();
}
delete form.dataset.pageEditorBound;
delete form._pageEditorApi;
};
const api = { destroy };
form._pageEditorApi = api;
return api;
}