Ich bin eben auf den tiptap gewechselt. Das geht deutlich besser -> siehe Testforum. Bei der Gelegenheit habe ich gleich Mentions mit eingebaut. Läuft auch.
Quote und Messageparser müssen noch angepasst werden. Da steht eine Entscheidung an. Bleibt es beim Quoten mit > pro Zeile oder wird doch gleich auf [q:....] umgestellt, was der Funktionsweise der Editoren entspricht, aber mit der Messagehistorie bricht. Da würden Zitate nicht mehr grau angezeigt, sondern nur noch mit den > davor. ...oder ein Migrationsscript muss her.
Über die Sache mit HTML direkt in der DB habe ich auch noch mal nachgedacht. Es hätte neben Sicherheitsaspekten noch einen anderen Nachteil. Wenn sich z.B. bei Youtube mal die Syntax ändert oder man es anders aufrufen will steht alles falsch in der DB. Ich denke ich bleibe bei der PXM Syntax. Läuft problemlos mit dem Editor.
Das als Anregung für die KI:
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import Underline from '@tiptap/extension-underline'
import Mention from '@tiptap/extension-mention'
import suggestion from './mention-suggestion.js'
/**
* Tiptap to PXM Converter
* Konvertiert Tiptap JSON zu PXM-Syntax
* Basierend auf der Stack-basierten PXM-Syntax:
* text → <b>text</b>
* text → <b><i>text</i></b>
*/
function tiptapToPxm(editor) {
const json = editor.getJSON();
const convertNode = (node) => {
// Text-Node mit Marks
if (node.type === 'text') {
let text = node.text;
// Marks von außen nach innen anwenden (Stack-basiert)
if (node.marks) {
// Reverse, damit innerste Marks zuerst applied werden
node.marks.slice().reverse().forEach(mark => {
switch(mark.type) {
case 'bold':
text = `${text}`;
break;
case 'italic':
text = `${text}`;
break;
case 'underline':
text = `${text}`;
break;
case 'strike':
text = `${text}`;
break;
case 'link':
// Link-Format: [url]
const href = mark.attrs.href;
if (href.startsWith('http://') || href.startsWith('https://')) {
text = `[${href}]`;
} else if (href.startsWith('www.')) {
text = `[${href}]`;
} else if (href.startsWith('mailto:')) {
text = `[${href}]`;
}
break;
}
});
}
return text;
}
// Mention-Node handling: Convert to PXM format [user:id]
if (node.type === 'mention') {
const userId = node.attrs?.id || 0;
return `[user:${userId}]`;
}
// Block-Nodes rekursiv verarbeiten
const content = node.content
? node.content.map(convertNode).join('')
: '';
switch(node.type) {
case 'paragraph':
// Paragraph = Content + Newline
return content + '\n';
case 'blockquote':
// Quote: Jede Zeile mit > prefixen
return content.split('\n')
.map(line => line ? `>${line}` : '')
.filter(line => line) // Leere Zeilen entfernen
.join('\n') + '\n';
case 'hardBreak':
return '\n';
case 'image':
// Bild-Format: [img:url]
const src = node.attrs?.src || '';
return `[img:${src}]`;
case 'doc':
return content;
default:
console.warn('Unbekannter Node-Typ:', node.type);
return content;
}
};
return convertNode(json).trim();
}
/**
* Editor-Instanzen werden außerhalb von Alpine's Reactive-System gespeichert,
* damit ProseMirror's State-Management nicht durch Alpine's Proxy gestört wird.
*/
const editorInstances = new WeakMap();
function getEditor(el) {
return editorInstances.get(el);
}
/**
* Alpine.js Component für Tiptap Editor
*/
let pxmEditorRegistered = false;
function initPxmEditor() {
if (pxmEditorRegistered) {
return;
}
if (typeof window.Alpine !== 'undefined') {
pxmEditorRegistered = true;
window.Alpine.data('pxmEditor', (initialHtmlContent = '') => ({
pxmContent: '',
loading: true,
error: null,
init() {
const el = this.$refs.editor;
if (!el || getEditor(el)) {
return;
}
try {
const component = this;
const editor = new Editor({
element: el,
extensions: [
StarterKit.configure({
link: false,
underline: false,
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer'
}
}),
Image.configure({
inline: false,
HTMLAttributes: {
class: 'max-w-full h-auto'
}
}),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: suggestion,
renderLabel({ node }) {
return `@${node.attrs.label}`;
}
})
],
content: initialHtmlContent,
onUpdate: ({ editor }) => {
component.pxmContent = tiptapToPxm(editor);
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[200px] p-4'
}
}
});
editorInstances.set(el, editor);
this.pxmContent = tiptapToPxm(editor);
this.loading = false;
} catch (error) {
console.error('Editor-Initialisierung fehlgeschlagen:', error);
this.error = 'Editor konnte nicht geladen werden. Fehler: ' + error.message;
this.loading = false;
}
},
destroy() {
const el = this.$refs.editor;
const editor = el && getEditor(el);
if (editor && !editor.isDestroyed) {
editor.destroy();
}
if (el) {
editorInstances.delete(el);
}
},
// Toolbar-Aktionen
toggleBold() {
const editor = getEditor(this.$refs.editor);
if (!editor || editor.isDestroyed) return;
editor.commands.toggleBold();
},
toggleItalic() {
const editor = getEditor(this.$refs.editor);
if (!editor || editor.isDestroyed) return;
editor.commands.toggleItalic();
},
toggleUnderline() {
const editor = getEditor(this.$refs.editor);
if (!editor || editor.isDestroyed) return;
editor.commands.toggleUnderline();
},
toggleStrike() {
const editor = getEditor(this.$refs.editor);
if (!editor || editor.isDestroyed) return;
editor.commands.toggleStrike();
},
setLink() {
const editor = getEditor(this.$refs.editor);
if (!editor || editor.isDestroyed) return;
const url = window.prompt('URL:');
if (url) {
editor.commands.setLink({ href: url });
}
},
unsetLink() {
const editor = getEditor(this.$refs.editor);
if (!editor || editor.isDestroyed) return;
editor.commands.unsetLink();
},
insertImage() {
const editor = getEditor(this.$refs.editor);
if (!editor || editor.isDestroyed) return;
const url = window.prompt('Bild-URL:');
if (url) {
editor.commands.setImage({ src: url });
}
},
toggleBlockquote() {
const editor = getEditor(this.$refs.editor);
if (!editor || editor.isDestroyed) return;
editor.commands.toggleBlockquote();
},
// Status-Prüfungen für Toolbar Buttons
isBold() {
return getEditor(this.$refs.editor)?.isActive('bold');
},
isItalic() {
return getEditor(this.$refs.editor)?.isActive('italic');
},
isUnderline() {
return getEditor(this.$refs.editor)?.isActive('underline');
},
isStrike() {
return getEditor(this.$refs.editor)?.isActive('strike');
},
isLink() {
return getEditor(this.$refs.editor)?.isActive('link');
},
isBlockquote() {
return getEditor(this.$refs.editor)?.isActive('blockquote');
}
}));
}
}
// Versuche mehrere Event-Strategien, aber registriere nur einmal
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPxmEditor);
} else {
initPxmEditor();
}
// Fallback: Alpine-spezifisches Event
document.addEventListener('alpine:init', initPxmEditor);