Add TipTap editor and switch tags to event/organization edit
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
(function () {
|
||||
const mounts = document.querySelectorAll('[data-mm-editor="tiptap"]');
|
||||
|
||||
if (!mounts.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runFallback = (mount) => {
|
||||
if (!mount || mount.querySelector('.ProseMirror') || mount.getAttribute('data-mm-fallback-ready') === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const textareaId = mount.getAttribute('data-textarea-id');
|
||||
|
||||
if (!textareaId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.getElementById(textareaId);
|
||||
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbar = document.querySelector('[data-mm-editor-toolbar="' + textareaId + '"]');
|
||||
const form = textarea.closest('form');
|
||||
|
||||
mount.setAttribute('contenteditable', 'true');
|
||||
mount.setAttribute('data-mm-fallback-ready', '1');
|
||||
mount.classList.add('mm-fallback-editor');
|
||||
mount.innerHTML = textarea.value || '<p></p>';
|
||||
textarea.style.display = 'none';
|
||||
|
||||
const syncState = () => {
|
||||
textarea.value = mount.innerHTML;
|
||||
};
|
||||
|
||||
const focusMount = () => {
|
||||
mount.focus();
|
||||
};
|
||||
|
||||
mount.addEventListener('input', syncState);
|
||||
mount.addEventListener('blur', syncState);
|
||||
|
||||
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');
|
||||
|
||||
focusMount();
|
||||
|
||||
switch (action) {
|
||||
case 'paragraph':
|
||||
document.execCommand('formatBlock', false, 'p');
|
||||
break;
|
||||
case 'h2':
|
||||
document.execCommand('formatBlock', false, 'h2');
|
||||
break;
|
||||
case 'h3':
|
||||
document.execCommand('formatBlock', false, 'h3');
|
||||
break;
|
||||
case 'bold':
|
||||
document.execCommand('bold', false);
|
||||
break;
|
||||
case 'italic':
|
||||
document.execCommand('italic', false);
|
||||
break;
|
||||
case 'underline':
|
||||
document.execCommand('underline', false);
|
||||
break;
|
||||
case 'bulletList':
|
||||
document.execCommand('insertUnorderedList', false);
|
||||
break;
|
||||
case 'orderedList':
|
||||
document.execCommand('insertOrderedList', false);
|
||||
break;
|
||||
case 'indent':
|
||||
document.execCommand('indent', false);
|
||||
break;
|
||||
case 'outdent':
|
||||
document.execCommand('outdent', false);
|
||||
break;
|
||||
case 'undo':
|
||||
document.execCommand('undo', false);
|
||||
break;
|
||||
case 'redo':
|
||||
document.execCommand('redo', false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
syncState();
|
||||
});
|
||||
}
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', syncState);
|
||||
}
|
||||
};
|
||||
|
||||
window.setTimeout(() => {
|
||||
mounts.forEach(runFallback);
|
||||
}, 900);
|
||||
})();
|
||||
@@ -0,0 +1,330 @@
|
||||
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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user