Files
eventmanager-bundle/public/editor.js
T

331 lines
10 KiB
JavaScript

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 CHARACTER_LIMIT = 30000;
(function () {
const editorMounts = document.querySelectorAll('[data-mm-editor="tiptap"]');
if (!editorMounts.length) {
return;
}
const syncState = (editor, textarea) => {
textarea.value = editor.getHTML();
};
const updateEditorA11yState = (mount, textarea, textareaId, editor) => {
if (!mount || !textareaId || !editor || !textarea) {
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;
const label = mount.getAttribute('data-editor-label') || textarea.getAttribute('aria-label') || 'Text';
mount.setAttribute('aria-invalid', hasError ? 'true' : 'false');
mount.setAttribute('aria-required', textarea.required ? 'true' : 'false');
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', textarea.required ? 'true' : 'false');
proseMirror.setAttribute('aria-label', label);
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 = CHARACTER_LIMIT - used;
counterNode.textContent = `${words.toLocaleString('de-DE')} Wörter · ${used.toLocaleString('de-DE')} / ${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:
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-mm-editor-toolbar="${textareaId}"]`);
const counterNode = document.querySelector(`[data-mm-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: CHARACTER_LIMIT,
}),
],
content: textarea.value || '<p></p>',
onCreate({ editor: currentEditor }) {
updateToolbarState(toolbar, currentEditor);
updateCharacterCounter(counterNode, currentEditor);
updateEditorA11yState(mount, textarea, textareaId, currentEditor);
},
onUpdate({ editor: currentEditor }) {
syncState(currentEditor, textarea);
updateToolbarState(toolbar, currentEditor);
updateCharacterCounter(counterNode, currentEditor);
updateEditorA11yState(mount, textarea, textareaId, currentEditor);
},
onSelectionUpdate({ editor: currentEditor }) {
updateToolbarState(toolbar, currentEditor);
updateEditorA11yState(mount, textarea, textareaId, currentEditor);
},
});
editor.on('transaction', () => {
updateToolbarState(toolbar, editor);
updateCharacterCounter(counterNode, editor);
updateEditorA11yState(mount, textarea, textareaId, editor);
});
editor.on('focus', () => {
updateToolbarState(toolbar, editor);
updateCharacterCounter(counterNode, editor);
updateEditorA11yState(mount, textarea, textareaId, editor);
});
editor.on('blur', () => {
updateToolbarState(toolbar, editor);
updateCharacterCounter(counterNode, editor);
updateEditorA11yState(mount, textarea, 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, textarea, textareaId, editor);
});
}
updateEditorA11yState(mount, textarea, textareaId, editor);
});
})();