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