281 lines
10 KiB
JavaScript
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());
|
|
});
|
|
});
|
|
},
|
|
};
|
|
}
|