417 lines
11 KiB
JavaScript
417 lines
11 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;
|
|
}
|
|
|
|
.selection-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: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
:host([menu-position="static"]) .selection-menu {
|
|
position: static;
|
|
margin-bottom: 8px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
border-radius: 0;
|
|
background-color: #f8f9fa;
|
|
box-shadow: none;
|
|
display: flex;
|
|
}
|
|
|
|
.selection-menu[hidden] {
|
|
display: none;
|
|
}
|
|
|
|
:host([menu-position="static"]) .selection-menu[hidden] {
|
|
display: flex;
|
|
}
|
|
|
|
.selection-menu[data-disabled="true"] {
|
|
opacity: 0.5;
|
|
pointer-events: 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: fixed;
|
|
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 menu with all controls -->
|
|
<div
|
|
class="selection-menu"
|
|
?hidden=${this.menuMode !== 'selection' && this.menuPosition !== 'static'}
|
|
?data-disabled=${this.menuPosition === 'static' && 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 },
|
|
menuPosition: { type: String, reflect: true }
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.extensions = [];
|
|
this.menuMode = 'hidden'; // 'hidden', 'selection', 'suggestion'
|
|
this.activeSuggestionType = null; // 'emoji', 'user', null
|
|
this.menuPosition = 'floating'; // 'floating', 'static'
|
|
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: '',
|
|
});
|
|
|
|
// 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;
|
|
const hasSelection = !empty && from !== to;
|
|
|
|
// In static mode, always keep menu visible but update its state
|
|
if (this.menuPosition === 'static') {
|
|
if (hasSelection) {
|
|
this.menuMode = 'selection';
|
|
} else {
|
|
this.menuMode = 'hidden'; // This will disable the menu but keep it visible
|
|
}
|
|
// Trigger a re-render to update the data-disabled attribute
|
|
this.requestUpdate();
|
|
return;
|
|
}
|
|
|
|
// In floating mode, hide menu if selection is empty or a caret
|
|
if (!hasSelection) {
|
|
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 menu
|
|
*/
|
|
showSelectionMenu() {
|
|
this.menuMode = 'selection';
|
|
|
|
// Only position the menu if it's floating
|
|
if (this.menuPosition === 'floating') {
|
|
requestAnimationFrame(() => {
|
|
const menu = this.shadowRoot.querySelector('.selection-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() {
|
|
// In static mode, don't change the menu mode (it stays visible but disabled)
|
|
if (this.menuPosition === 'static') {
|
|
this.activeSuggestionType = null;
|
|
this._updateSuggestionComponentVisibility();
|
|
return;
|
|
}
|
|
|
|
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"]');
|
|
const commandComponent = this.querySelector('dp-rtb-command[slot="suggestion-content"]');
|
|
|
|
if (emojiComponent) {
|
|
emojiComponent.style.display = this.activeSuggestionType === 'emoji' ? 'block' : 'none';
|
|
}
|
|
if (userComponent) {
|
|
userComponent.style.display = this.activeSuggestionType === 'user' ? 'block' : 'none';
|
|
}
|
|
if (commandComponent) {
|
|
commandComponent.style.display = this.activeSuggestionType === 'command' ? '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.configure({
|
|
suggestions: suggestions,
|
|
renderHTML({ options, node, suggestion }) {
|
|
if (suggestion && suggestion.renderHTML) {
|
|
return suggestion.renderHTML({ options, node, suggestion });
|
|
}
|
|
|
|
console.warn('No renderHTML defined for mention suggestion. Using default rendering.');
|
|
|
|
return [
|
|
'span',
|
|
{
|
|
'data-type': 'mention',
|
|
contenteditable: 'false'
|
|
},
|
|
node.attrs.label || node.attrs.id
|
|
];
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
// 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 { html: this.editor.getHTML(), json: this.editor.getJSON() };
|
|
}
|
|
|
|
return { html: '', json: {} };
|
|
}
|
|
|
|
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);
|