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()); }); }); }, }; }