la_bloger/resources/js/admin/block-editor.js
2026-05-22 17:42:10 +02:00

281 lines
10 KiB
JavaScript

import { Editor } from '@tiptap/core';
import { createExtensions } from './tiptap-extensions';
export default function blockEditor(initialBlocks) {
const editors = {};
return {
blocks: initialBlocks.map(b => ({ ...b, _key: String(b.id ?? Math.random()) })),
editorStates: {},
// ---- Focus mode ----
focusedBlockKey: null,
// ---- Confirm delete ----
confirmDelete: null,
getEditor(key) {
return editors[String(key)];
},
editorState(key) {
return this.editorStates[String(key)] ?? {};
},
isFocused(key) {
return this.focusedBlockKey === String(key);
},
mountTiptap(el, block) {
if (el._tiptap) return;
const key = String(block._key);
const self = this;
this.editorStates[key] = {
bold: false, italic: false, strike: false, code: false,
paragraph: true,
h2: false, h3: false, h4: false, h5: false, h6: false,
bulletList: false, orderedList: false,
blockquote: false, codeBlock: false,
link: false, image: false, table: false,
canUndo: false, canRedo: false,
currentImageSize: null,
activeHeadingLevel: null,
selectionEmpty: true,
};
let initialContent = '';
try {
initialContent = block.content.content
? JSON.parse(block.content.content)
: '';
} catch {
initialContent = block.content.content || '';
}
const editor = new Editor({
element: el,
extensions: createExtensions(),
content: initialContent,
onFocus() {
self.focusedBlockKey = key;
self.$dispatch('focus-mode-changed', { active: true });
},
onBlur({ event }) {
setTimeout(() => {
if (self.focusedBlockKey === key) {
self.focusedBlockKey = null;
self.$dispatch('focus-mode-changed', { active: false });
}
}, 150);
},
onUpdate({ editor }) {
block.content.content = JSON.stringify(editor.getJSON());
},
onTransaction({ editor }) {
const s = self.editorStates[key];
if (!s) return;
s.bold = editor.isActive('bold');
s.italic = editor.isActive('italic');
s.strike = editor.isActive('strike');
s.code = editor.isActive('code');
s.paragraph = editor.isActive('paragraph');
s.h2 = editor.isActive('heading', { level: 2 });
s.h3 = editor.isActive('heading', { level: 3 });
s.h4 = editor.isActive('heading', { level: 4 });
s.h5 = editor.isActive('heading', { level: 5 });
s.h6 = editor.isActive('heading', { level: 6 });
s.bulletList = editor.isActive('bulletList');
s.orderedList = editor.isActive('orderedList');
s.blockquote = editor.isActive('blockquote');
s.codeBlock = editor.isActive('codeBlock');
s.link = editor.isActive('link');
s.image = editor.isActive('image');
s.table = editor.isActive('table');
s.canUndo = editor.can().undo();
s.canRedo = editor.can().redo();
s.currentImageSize = editor.isActive('image')
? (editor.getAttributes('image').sizeClass || null)
: null;
if (s.h2) s.activeHeadingLevel = 2;
else if (s.h3) s.activeHeadingLevel = 3;
else if (s.h4) s.activeHeadingLevel = 4;
else if (s.h5) s.activeHeadingLevel = 5;
else if (s.h6) s.activeHeadingLevel = 6;
else s.activeHeadingLevel = null;
s.selectionEmpty = editor.state.selection.empty;
},
});
el._tiptap = editor;
editors[key] = editor;
},
// ---- Block actions ----
addBlock(type) {
const defaults = {
rich_text: { content: '' },
image: { url: '', alt: '', caption: '', style: 'full' },
video: { url: '', title: '', caption: '' },
taxonomy_list: { taxonomy_term_id: '', limit: 10 },
posts_carousel: { title: '', limit: 6 },
};
const newBlock = {
id: null,
type,
content: { ...(defaults[type] ?? {}) },
_key: String(Math.random()),
};
this.blocks.push(newBlock);
if (type === 'rich_text') {
this.$nextTick(() => {
const editor = editors[String(newBlock._key)];
if (editor) {
editor.commands.focus('end');
}
});
}
},
removeBlock(index) {
this.confirmDelete = index;
},
confirmRemoveBlock() {
const index = this.confirmDelete;
if (index === null) return;
const block = this.blocks[index];
if (block.type === 'rich_text') {
const key = String(block._key);
editors[key]?.destroy();
delete editors[key];
delete this.editorStates[key];
if (this.focusedBlockKey === key) {
this.focusedBlockKey = null;
}
}
this.blocks.splice(index, 1);
this.confirmDelete = null;
},
cancelRemoveBlock() {
this.confirmDelete = null;
},
// ---- Drag-and-drop reorder ----
dragIndex: null,
onDragStart(index, event) {
this.dragIndex = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', '');
},
onDragOver(index, event) {
if (this.dragIndex === null || this.dragIndex === index) return;
event.dataTransfer.dropEffect = 'move';
},
onDrop(index) {
if (this.dragIndex === null || this.dragIndex === index) return;
this.moveBlock(this.dragIndex, index);
},
onDragEnd() {
this.dragIndex = null;
},
moveBlock(fromIndex, toIndex) {
const [block] = this.blocks.splice(fromIndex, 1);
this.blocks.splice(toIndex, 0, block);
},
// ---- Formatting actions ----
editorAction(key, action) {
const editor = this.getEditor(key);
if (!editor) return;
editor.view.focus();
switch (action) {
case 'undo': editor.chain().undo().run(); break;
case 'redo': editor.chain().redo().run(); break;
case 'bold': editor.chain().toggleBold().run(); break;
case 'italic': editor.chain().toggleItalic().run(); break;
case 'strike': editor.chain().toggleStrike().run(); break;
case 'code': editor.chain().toggleCode().run(); break;
case 'h2': editor.chain().toggleHeading({ level: 2 }).run(); break;
case 'h3': editor.chain().toggleHeading({ level: 3 }).run(); break;
case 'bullet': editor.chain().toggleBulletList().run(); break;
case 'ordered': editor.chain().toggleOrderedList().run(); break;
case 'blockquote': editor.chain().toggleBlockquote().run(); break;
case 'codeBlock': editor.chain().toggleCodeBlock().run(); break;
case 'clearMarks': editor.chain().unsetAllMarks().run(); break;
case 'paragraph': editor.chain().setParagraph().run(); break;
}
},
setLink(key) {
const editor = this.getEditor(key);
if (!editor) return;
const prev = editor.getAttributes('link').href;
const url = window.prompt('URL:', prev ?? '');
if (url === null) return;
editor.view.focus();
if (url === '') {
editor.chain().extendMarkRange('link').unsetLink().run();
} else {
editor.chain().extendMarkRange('link').setLink({ href: url }).run();
}
},
insertImage(key) {
const editor = this.getEditor(key);
if (!editor) return;
const url = window.prompt('Image URL:')?.trim();
if (!url) return;
if (!/^https?:\/\//i.test(url)) {
window.alert('Please enter a valid URL starting with http:// or https://');
return;
}
const alt = window.prompt('Alt text:') || '';
editor.view.focus();
editor.chain().setImage({ src: url, alt }).run();
},
setImageSize(key, sizeClass) {
this.getEditor(key)?.chain().updateAttributes('image', { sizeClass }).run();
},
removeImage(key) {
this.getEditor(key)?.chain().deleteSelection().run();
},
exitFocusMode() {
const editor = this.getEditor(this.focusedBlockKey);
if (editor) {
editor.commands.blur();
}
this.focusedBlockKey = null;
this.$dispatch('focus-mode-changed', { active: false });
},
init() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.focusedBlockKey) {
this.exitFocusMode();
}
});
this.$el.closest('form')?.addEventListener('submit', () => {
Object.entries(editors).forEach(([key, editor]) => {
const block = this.blocks.find(b => String(b._key) === key);
if (block) block.content.content = JSON.stringify(editor.getJSON());
});
});
},
};
}