A first version that has some working standard features.
This commit is contained in:
@@ -10,8 +10,15 @@ 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';
|
||||
|
||||
class TpRichTextBox extends LitElement {
|
||||
|
||||
import { FormElement } from '@tp/helpers/form-element.js';
|
||||
|
||||
class TpRichTextBox extends FormElement(LitElement) {
|
||||
static get styles() {
|
||||
return [
|
||||
css`
|
||||
@@ -19,15 +26,9 @@ class TpRichTextBox extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #ccc;
|
||||
min-height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#editor {
|
||||
padding: 8px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.custom-floating-menu {
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
@@ -44,6 +45,27 @@ class TpRichTextBox extends LitElement {
|
||||
.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;
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
@@ -51,22 +73,33 @@ class TpRichTextBox extends LitElement {
|
||||
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`
|
||||
<div class="custom-floating-menu" hidden>
|
||||
<!-- Selection-based floating menu with all controls -->
|
||||
<div class="custom-floating-menu" ?hidden=${this.menuMode !== 'selection'}>
|
||||
<slot @slotchange=${this._handleSlotChange}></slot>
|
||||
</div>
|
||||
<div id="editor"></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 }
|
||||
extensions: { type: Array },
|
||||
menuMode: { type: String, reflect: true },
|
||||
activeSuggestionType: { type: String }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,7 +123,7 @@ class TpRichTextBox extends LitElement {
|
||||
}
|
||||
|
||||
this.editor = new Editor({
|
||||
element: this.shadowRoot.querySelector('#editor'),
|
||||
element: this.querySelector('[slot="content"]'),
|
||||
extensions: this.extensions,
|
||||
content: '<p>Hello World!</p>',
|
||||
});
|
||||
@@ -109,69 +142,88 @@ class TpRichTextBox extends LitElement {
|
||||
|
||||
_handleSelectionUpdate() {
|
||||
const { editor } = this;
|
||||
const menu = this.shadowRoot.querySelector('.custom-floating-menu');
|
||||
|
||||
if (!editor || !menu) {
|
||||
console.log('Editor or menu element not found.');
|
||||
|
||||
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) {
|
||||
menu.hidden = true;
|
||||
this.hideMenu();
|
||||
console.log('Selection is empty or a caret. Hiding menu.');
|
||||
return;
|
||||
}
|
||||
|
||||
menu.hidden = false;
|
||||
// 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(() => {
|
||||
this._positionMenu(menu, editor);
|
||||
console.log('Text selected. Showing menu.');
|
||||
const menu = this.shadowRoot.querySelector('.custom-floating-menu');
|
||||
if (menu) {
|
||||
MenuPositioner.positionMenu(menu, this.editor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_positionMenu(menuElement, editorInstance) {
|
||||
const { view } = editorInstance;
|
||||
const { selection } = editorInstance.state;
|
||||
const { from, to } = selection;
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get the coordinates of the selection
|
||||
const start = view.coordsAtPos(from);
|
||||
const end = view.coordsAtPos(to);
|
||||
|
||||
// Calculate the center of the selection
|
||||
let left = (start.left + end.left) / 2;
|
||||
console.log('Menu offsetHeight:', menuElement.offsetHeight);
|
||||
let top = start.top - menuElement.offsetHeight - 10; // 10px above selection
|
||||
|
||||
// If the calculated top is negative, position the menu below the selection
|
||||
if (top < 0) {
|
||||
top = end.bottom + 10; // 10px below selection
|
||||
}
|
||||
|
||||
// Adjust left position if it goes out of viewport on the right
|
||||
const viewportWidth = window.innerWidth;
|
||||
const menuWidth = menuElement.offsetWidth;
|
||||
if (left + menuWidth > viewportWidth) {
|
||||
left = viewportWidth - menuWidth - 10; // 10px padding from right edge
|
||||
}
|
||||
|
||||
// Ensure left is not negative
|
||||
if (left < 0) {
|
||||
left = 10; // 10px padding from left edge
|
||||
}
|
||||
|
||||
// Position the menu
|
||||
menuElement.style.left = `${left}px`;
|
||||
menuElement.style.top = `${top}px`;
|
||||
console.log(`Menu positioned at: left=${left}px, top=${top}px`);
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -191,15 +243,24 @@ class TpRichTextBox extends LitElement {
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
HardBreak
|
||||
HardBreak,
|
||||
UndoRedo,
|
||||
];
|
||||
|
||||
// Get all extension components
|
||||
// Collect suggestion configurations from child components
|
||||
const suggestions = [];
|
||||
const children = Array.from(this.children);
|
||||
|
||||
children.forEach(child => {
|
||||
// If the child has a getExtension method, it's an extension component
|
||||
if (child.getExtension && typeof child.getExtension === 'function') {
|
||||
// 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);
|
||||
@@ -207,6 +268,36 @@ class TpRichTextBox extends LitElement {
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
@@ -222,6 +313,34 @@ class TpRichTextBox extends LitElement {
|
||||
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);
|
||||
|
Reference in New Issue
Block a user