Files
tp-rich-text-box/tp-rich-text-box.js

371 lines
9.5 KiB
JavaScript

/**
@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;
}
`
];
}
render() {
return html`
<!-- Selection-based floating menu with all controls -->
<div class="custom-floating-menu" ?hidden=${this.menuMode !== 'selection'}>
<slot @slotchange=${this._handleSlotChange}></slot>
</div>
<!-- Suggestion-based menu for emoji/mention triggers -->
<div class="suggestion-menu" ?hidden=${this.menuMode !== 'suggestion'}>
<slot name="suggestion-content" @slotchange=${this._handleSuggestionSlotChange}></slot>
</div>
<slot name="content"></slot>
`;
}
static get properties() {
return {
editor: { type: Object },
extensions: { type: Array },
menuMode: { type: String, reflect: true },
activeSuggestionType: { type: String },
asHtml: { type: Boolean }
};
}
constructor() {
super();
this.extensions = [];
this.menuMode = 'hidden'; // 'hidden', 'selection', 'suggestion'
this.activeSuggestionType = null; // 'emoji', 'user', null
this.asHtml = false;
this._slotObserver = new MutationObserver(this._processSlotChanges.bind(this));
}
firstUpdated() {
// Get initial extensions from slot
this._processChildExtensions();
// Initialize the editor with collected extensions
this._initEditor();
// Observe future slot changes
this._slotObserver.observe(this, {
childList: true,
subtree: false
});
}
_initEditor() {
if (this.editor) {
this.editor.destroy();
}
this.editor = new Editor({
element: this.querySelector('[slot="content"]'),
extensions: this.extensions,
content: '<p>Hello World!</p>',
});
// 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.asHtml ? this.editor.getHTML() : this.editor.getJSON();
}
return this.asHtml ? '' : {};
}
set value(content) {
this.updateComplete.then(() => {
if (this.editor) {
this.editor.commands.setContent(content);
}
});
}
/**
* Get the editor content as JSON
* @returns {Object} The editor content as JSON object
*/
getJson() {
if (this.editor) {
return this.editor.getJSON();
}
return {};
}
/**
* Get the editor content as HTML
* @returns {string} The editor content as HTML string
*/
getHtml() {
if (this.editor) {
return this.editor.getHTML();
}
return '';
}
}
window.customElements.define('tp-rich-text-box', TpRichTextBox);