/** @license Copyright (c) 2025 trading_peter This program is available under Apache License Version 2.0 */ import { LitElement, html, css } from 'lit'; import { Editor } from '@tiptap/core'; import Document from '@tiptap/extension-document'; import Paragraph from '@tiptap/extension-paragraph'; import Text from '@tiptap/extension-text'; import HardBreak from '@tiptap/extension-hard-break'; import { UndoRedo } from '@tiptap/extensions'; import Mention from '@tiptap/extension-mention'; import { MenuPositioner } from './menu-positioner.js'; import './tp-rtb-emoji-suggestion.js'; import { FormElement } from '@tp/helpers/form-element.js'; class TpRichTextBox extends FormElement(LitElement) { static get styles() { return [ css` :host { display: flex; flex-direction: column; border: 1px solid #ccc; position: relative; } .custom-floating-menu { display: flex; padding: 4px; background-color: #fff; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); position: absolute; top: 0; left: 0; z-index: 100; } .custom-floating-menu[hidden] { display: none; } .suggestion-menu { display: block; padding: 0; background-color: #fff; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); position: absolute; top: 0; left: 0; z-index: 101; } .suggestion-menu[hidden] { display: none; } slot[name="content"]::slotted(*) { white-space: pre-wrap; } ` ]; } constructor() { super(); this.extensions = []; this.menuMode = 'hidden'; // 'hidden', 'selection', 'suggestion' this.activeSuggestionType = null; // 'emoji', 'user', null this._slotObserver = new MutationObserver(this._processSlotChanges.bind(this)); } render() { return html`
Hello World!
', }); // Notify child extensions that the editor is ready const children = Array.from(this.children); children.forEach(child => { if (child._editorReady && typeof child._editorReady === 'function') { child._editorReady(this.editor); } }); this.editor.on('selectionUpdate', this._handleSelectionUpdate.bind(this)); this.editor.on('blur', this._handleSelectionUpdate.bind(this)); } _handleSelectionUpdate() { const { editor } = this; if (!editor) { console.log('Editor not found.'); return; } const { empty, from, to } = editor.state.selection; // Hide menu if selection is empty or a caret if (empty || from === to) { this.hideMenu(); console.log('Selection is empty or a caret. Hiding menu.'); return; } // Show selection menu for text selection this.showSelectionMenu(); console.log('Text selected. Showing selection menu.'); } /** * Show the selection-based floating menu */ showSelectionMenu() { this.menuMode = 'selection'; requestAnimationFrame(() => { const menu = this.shadowRoot.querySelector('.custom-floating-menu'); if (menu) { MenuPositioner.positionMenu(menu, this.editor); } }); } /** * Show the suggestion-based menu (for emoji/mention triggers) */ showSuggestionMenu(clientRect = null, suggestionType = null) { this.menuMode = 'suggestion'; this.activeSuggestionType = suggestionType; // Update component visibility this._updateSuggestionComponentVisibility(); requestAnimationFrame(() => { const menu = this.shadowRoot.querySelector('.suggestion-menu'); if (menu) { MenuPositioner.positionMenuAdvanced(menu, this.editor, clientRect); } }); } /** * Hide all menus */ hideMenu() { this.menuMode = 'hidden'; this.activeSuggestionType = null; // Reset all suggestion component visibility this._updateSuggestionComponentVisibility(); } _handleSlotChange(e) { this._processChildExtensions(); } _handleSuggestionSlotChange(e) { // Update visibility of suggestion components based on activeSuggestionType this._updateSuggestionComponentVisibility(); } _updateSuggestionComponentVisibility() { const emojiComponent = this.querySelector('tp-rtb-emoji-suggestion[slot="suggestion-content"]'); const userComponent = this.querySelector('tp-rtb-user-mention[slot="suggestion-content"]'); if (emojiComponent) { emojiComponent.style.display = this.activeSuggestionType === 'emoji' ? 'block' : 'none'; } if (userComponent) { userComponent.style.display = this.activeSuggestionType === 'user' ? 'block' : 'none'; } } _processSlotChanges(mutations) { let needsUpdate = false; mutations.forEach(mutation => { if (mutation.type === 'childList') { needsUpdate = true; } }); if (needsUpdate) { this._processChildExtensions(); } } _processChildExtensions() { this.extensions = [ Document, Paragraph, Text, HardBreak, UndoRedo, ]; // Collect suggestion configurations from child components const suggestions = []; const children = Array.from(this.children); children.forEach(child => { // If the child has a getSuggestionConfig method, it provides suggestion configuration if (child.getSuggestionConfig && typeof child.getSuggestionConfig === 'function') { const suggestionConfig = child.getSuggestionConfig(); if (suggestionConfig) { suggestions.push(suggestionConfig); } } // If the child has a getExtension method but not getSuggestionConfig, it's a regular extension else if (child.getExtension && typeof child.getExtension === 'function') { const extension = child.getExtension(); if (extension) { this.extensions.push(extension); } } }); // If we have suggestions, add a single Mention extension with all suggestions if (suggestions.length > 0) { this.extensions.push( Mention.extend({ renderText({ options, node }) { // Return just the label without the trigger character return node.attrs.label || node.attrs.id; }, renderHTML({ options, node }) { return [ 'span', { class: 'mention', 'data-type': 'mention', 'data-id': node.attrs.id, 'data-label': node.attrs.label, contenteditable: 'false' }, node.attrs.label || node.attrs.id ]; }, }).configure({ HTMLAttributes: { class: 'mention', }, suggestions: suggestions }) ); } // Re-initialize the editor with new extensions if it already exists if (this.editor) { this._initEditor(); } } disconnectedCallback() { super.disconnectedCallback(); if (this._slotObserver) { this._slotObserver.disconnect(); } if (this.editor) { this.editor.destroy(); } } undo() { if (this.editor) { this.editor.chain().focus().undo().run(); } } redo() { if (this.editor) { this.editor.chain().focus().redo().run(); } } get value() { if (this.editor) { return this.editor.getJSON(); } return {}; } set value(jsonContent) { this.updateComplete.then(() => { if (this.editor) { this.editor.commands.setContent(jsonContent); } }); } } window.customElements.define('tp-rich-text-box', TpRichTextBox);