import { Editor } from 'https://esm.sh/@tiptap/core@3'; import Document from 'https://esm.sh/@tiptap/extension-document@3'; import Paragraph from 'https://esm.sh/@tiptap/extension-paragraph@3'; import Text from 'https://esm.sh/@tiptap/extension-text@3'; import Heading from 'https://esm.sh/@tiptap/extension-heading@3'; import Bold from 'https://esm.sh/@tiptap/extension-bold@3'; import Italic from 'https://esm.sh/@tiptap/extension-italic@3'; import Underline from 'https://esm.sh/@tiptap/extension-underline@3'; import BulletList from 'https://esm.sh/@tiptap/extension-bullet-list@3'; import OrderedList from 'https://esm.sh/@tiptap/extension-ordered-list@3'; import ListItem from 'https://esm.sh/@tiptap/extension-list-item@3'; import CharacterCount from 'https://esm.sh/@tiptap/extension-character-count@3'; import History from 'https://esm.sh/@tiptap/extension-history@3'; const ARTICLE_TEXT_CHARACTER_LIMIT = 30000; (function () { const editorMounts = document.querySelectorAll('[data-news-editor="tiptap"]'); if (!editorMounts.length) { return; } const syncState = (editor, textarea) => { textarea.value = editor.getHTML(); }; const updateEditorA11yState = (mount, textareaId, editor) => { if (!mount || !textareaId || !editor) { return; } const errorNode = document.getElementById(`${textareaId}-errors`); const hasError = !!(errorNode && errorNode.textContent && errorNode.textContent.trim().length > 0); const plainTextLength = editor.storage?.characterCount?.characters?.() ?? 0; const isEmpty = plainTextLength === 0; mount.setAttribute('aria-invalid', hasError ? 'true' : 'false'); mount.setAttribute('aria-required', 'true'); const proseMirror = mount.querySelector('.ProseMirror'); if (!proseMirror) { return; } proseMirror.setAttribute('role', 'textbox'); proseMirror.setAttribute('aria-multiline', 'true'); proseMirror.setAttribute('aria-invalid', hasError ? 'true' : 'false'); proseMirror.setAttribute('aria-required', 'true'); proseMirror.setAttribute('aria-label', 'Artikeltext'); proseMirror.setAttribute('data-empty', isEmpty ? 'true' : 'false'); }; const updateCharacterCounter = (counterNode, editor) => { if (!counterNode || !editor?.storage?.characterCount) { return; } const used = editor.storage.characterCount.characters(); const words = editor.storage.characterCount.words(); const remaining = ARTICLE_TEXT_CHARACTER_LIMIT - used; counterNode.textContent = `${words.toLocaleString('de-DE')} Wörter · ${used.toLocaleString('de-DE')} / ${ARTICLE_TEXT_CHARACTER_LIMIT.toLocaleString('de-DE')} Zeichen`; counterNode.classList.toggle('is-over-limit', remaining < 0); }; const setupToolbarKeyboardNavigation = (toolbar) => { if (!toolbar) { return; } const getEnabledButtons = () => Array.from(toolbar.querySelectorAll('button[data-action]:not(:disabled)')); toolbar.addEventListener('keydown', (event) => { const activeElement = event.target.closest('button[data-action]'); if (!activeElement) { return; } const buttons = getEnabledButtons(); if (!buttons.length) { return; } const currentIndex = buttons.indexOf(activeElement); if (currentIndex < 0) { return; } let nextIndex = currentIndex; switch (event.key) { case 'ArrowRight': nextIndex = (currentIndex + 1) % buttons.length; break; case 'ArrowLeft': nextIndex = (currentIndex - 1 + buttons.length) % buttons.length; break; case 'Home': nextIndex = 0; break; case 'End': nextIndex = buttons.length - 1; break; case 'Enter': case ' ': event.preventDefault(); activeElement.click(); return; default: return; } event.preventDefault(); buttons[nextIndex].focus(); }); }; const updateToolbarState = (toolbar, editor) => { if (!toolbar) { return; } const canIndent = editor.can().chain().focus().sinkListItem('listItem').run(); const canOutdent = editor.can().chain().focus().liftListItem('listItem').run(); const canUndo = editor.can().chain().focus().undo().run(); const canRedo = editor.can().chain().focus().redo().run(); toolbar.querySelectorAll('button[data-action]').forEach((button) => { const action = button.getAttribute('data-action'); let isActive = false; let isDisabled = false; switch (action) { case 'paragraph': isActive = editor.isActive('paragraph'); break; case 'h2': isActive = editor.isActive('heading', { level: 2 }); break; case 'h3': isActive = editor.isActive('heading', { level: 3 }); break; case 'bold': isActive = editor.isActive('bold'); break; case 'italic': isActive = editor.isActive('italic'); break; case 'underline': isActive = editor.isActive('underline'); break; case 'bulletList': isActive = editor.isActive('bulletList'); break; case 'orderedList': isActive = editor.isActive('orderedList'); break; case 'indent': isDisabled = !canIndent; break; case 'outdent': isDisabled = !canOutdent; break; case 'undo': isDisabled = !canUndo; break; case 'redo': isDisabled = !canRedo; break; default: isActive = false; break; } button.classList.toggle('is-active', isActive); button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); button.disabled = isDisabled; button.setAttribute('aria-disabled', isDisabled ? 'true' : 'false'); }); }; editorMounts.forEach((mount) => { const textareaId = mount.getAttribute('data-textarea-id'); if (!textareaId) { return; } const textarea = document.getElementById(textareaId); if (!textarea) { return; } textarea.style.display = 'none'; const form = textarea.closest('form'); const toolbar = document.querySelector(`[data-news-editor-toolbar="${textareaId}"]`); const counterNode = document.querySelector(`[data-news-editor-counter-for="${textareaId}"]`); setupToolbarKeyboardNavigation(toolbar); const editor = new Editor({ element: mount, extensions: [ Document, Paragraph, Text, Heading.configure({ levels: [2, 3] }), Bold, Italic, Underline, BulletList, OrderedList, ListItem, History, CharacterCount.configure({ limit: ARTICLE_TEXT_CHARACTER_LIMIT, }), ], content: textarea.value || '
', onCreate({ editor: currentEditor }) { updateToolbarState(toolbar, currentEditor); updateCharacterCounter(counterNode, currentEditor); updateEditorA11yState(mount, textareaId, currentEditor); }, onUpdate({ editor: currentEditor }) { syncState(currentEditor, textarea); updateToolbarState(toolbar, currentEditor); updateCharacterCounter(counterNode, currentEditor); updateEditorA11yState(mount, textareaId, currentEditor); }, onSelectionUpdate({ editor: currentEditor }) { updateToolbarState(toolbar, currentEditor); updateEditorA11yState(mount, textareaId, currentEditor); }, }); editor.on('transaction', () => { updateToolbarState(toolbar, editor); updateCharacterCounter(counterNode, editor); updateEditorA11yState(mount, textareaId, editor); }); editor.on('focus', () => { updateToolbarState(toolbar, editor); updateCharacterCounter(counterNode, editor); updateEditorA11yState(mount, textareaId, editor); }); editor.on('blur', () => { updateToolbarState(toolbar, editor); updateCharacterCounter(counterNode, editor); updateEditorA11yState(mount, textareaId, editor); }); if (toolbar) { toolbar.addEventListener('click', (event) => { const target = event.target.closest('button[data-action]'); if (!target || target.disabled) { return; } event.preventDefault(); const action = target.getAttribute('data-action'); const chain = editor.chain().focus(); switch (action) { case 'paragraph': chain.setParagraph().run(); break; case 'h2': chain.toggleHeading({ level: 2 }).run(); break; case 'h3': chain.toggleHeading({ level: 3 }).run(); break; case 'bold': chain.toggleBold().run(); break; case 'italic': chain.toggleItalic().run(); break; case 'underline': chain.toggleUnderline().run(); break; case 'bulletList': chain.toggleBulletList().run(); break; case 'orderedList': chain.toggleOrderedList().run(); break; case 'indent': chain.sinkListItem('listItem').run(); break; case 'outdent': chain.liftListItem('listItem').run(); break; case 'undo': chain.undo().run(); break; case 'redo': chain.redo().run(); break; default: break; } syncState(editor, textarea); updateToolbarState(toolbar, editor); }); } if (form) { form.addEventListener('submit', () => { syncState(editor, textarea); updateEditorA11yState(mount, textareaId, editor); }); } updateEditorA11yState(mount, textareaId, editor); }); })();