From 52f7478b5fe57c7f4155fce40ca1c80c50cbe256 Mon Sep 17 00:00:00 2001 From: pk Date: Thu, 20 Nov 2025 15:23:20 +0100 Subject: [PATCH] Fixes --- demo/src/the-app.js | 2 +- tp-rich-text-box.js | 118 ++++++++++++++++++++---------- tp-rtb-emoji-suggestion.js | 108 ++++++++++++++++++---------- tp-rtb-user-mention.js | 142 ++++++++++++++++++++++++++----------- 4 files changed, 255 insertions(+), 115 deletions(-) diff --git a/demo/src/the-app.js b/demo/src/the-app.js index 9cd0c04..1de1c85 100644 --- a/demo/src/the-app.js +++ b/demo/src/the-app.js @@ -43,7 +43,7 @@ class TheApp extends LitElement { render() { return html` - +
diff --git a/tp-rich-text-box.js b/tp-rich-text-box.js index 2824b81..ba192d5 100644 --- a/tp-rich-text-box.js +++ b/tp-rich-text-box.js @@ -29,23 +29,42 @@ class TpRichTextBox extends FormElement(LitElement) { position: relative; } - .custom-floating-menu { + .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: absolute; + position: fixed; top: 0; left: 0; z-index: 100; } - .custom-floating-menu[hidden] { + :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; @@ -53,7 +72,7 @@ class TpRichTextBox extends FormElement(LitElement) { border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - position: absolute; + position: fixed; top: 0; left: 0; z-index: 101; @@ -72,8 +91,12 @@ class TpRichTextBox extends FormElement(LitElement) { render() { return html` - -
+ +
@@ -92,7 +115,7 @@ class TpRichTextBox extends FormElement(LitElement) { extensions: { type: Array }, menuMode: { type: String, reflect: true }, activeSuggestionType: { type: String }, - asHtml: { type: Boolean } + menuPosition: { type: String, reflect: true } }; } @@ -101,7 +124,7 @@ class TpRichTextBox extends FormElement(LitElement) { this.extensions = []; this.menuMode = 'hidden'; // 'hidden', 'selection', 'suggestion' this.activeSuggestionType = null; // 'emoji', 'user', null - this.asHtml = false; + this.menuPosition = 'floating'; // 'floating', 'static' this._slotObserver = new MutationObserver(this._processSlotChanges.bind(this)); } @@ -127,7 +150,7 @@ class TpRichTextBox extends FormElement(LitElement) { this.editor = new Editor({ element: this.querySelector('[slot="content"]'), extensions: this.extensions, - content: '

Hello World!

', + content: '', }); // Notify child extensions that the editor is ready @@ -151,9 +174,22 @@ class TpRichTextBox extends FormElement(LitElement) { } const { empty, from, to } = editor.state.selection; + const hasSelection = !empty && from !== to; - // Hide menu if selection is empty or a caret - if (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; @@ -165,16 +201,20 @@ class TpRichTextBox extends FormElement(LitElement) { } /** - * Show the selection-based floating menu + * Show the selection-based menu */ showSelectionMenu() { this.menuMode = 'selection'; - requestAnimationFrame(() => { - const menu = this.shadowRoot.querySelector('.custom-floating-menu'); - if (menu) { - MenuPositioner.positionMenu(menu, this.editor); - } - }); + + // 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); + } + }); + } } /** @@ -199,6 +239,13 @@ class TpRichTextBox extends FormElement(LitElement) { * 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 @@ -217,6 +264,7 @@ class TpRichTextBox extends FormElement(LitElement) { _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'; @@ -224,6 +272,9 @@ class TpRichTextBox extends FormElement(LitElement) { if (userComponent) { userComponent.style.display = this.activeSuggestionType === 'user' ? 'block' : 'none'; } + if (commandComponent) { + commandComponent.style.display = this.activeSuggestionType === 'command' ? 'block' : 'none'; + } } _processSlotChanges(mutations) { @@ -273,29 +324,24 @@ class TpRichTextBox extends FormElement(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 }) { + 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', { - 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 + } }) ); } @@ -330,10 +376,10 @@ class TpRichTextBox extends FormElement(LitElement) { get value() { if (this.editor) { - return this.asHtml ? this.editor.getHTML() : this.editor.getJSON(); + return { html: this.editor.getHTML(), json: this.editor.getJSON() }; } - - return this.asHtml ? '' : {}; + + return { html: '', json: {} }; } set value(content) { diff --git a/tp-rtb-emoji-suggestion.js b/tp-rtb-emoji-suggestion.js index b47d0e8..5bc3e75 100644 --- a/tp-rtb-emoji-suggestion.js +++ b/tp-rtb-emoji-suggestion.js @@ -1,6 +1,5 @@ -import { LitElement, html, css } from 'lit'; +import { html, css } from 'lit'; import { TpRtbBaseExtension } from './tp-rtb-base-extension.js'; -import Mention from '@tiptap/extension-mention'; import { closest } from '@tp/helpers/closest.js'; const emojis = [ @@ -254,18 +253,6 @@ const emojis = [ ]; export class TpRtbEmojiSuggestion extends TpRtbBaseExtension { - static getEmojis() { - return emojis; - } - - static get properties() { - return { - ...super.properties, - items: { type: Array }, - selectedIndex: { type: Number }, - }; - } - static get styles() { return [ super.styles, @@ -292,12 +279,52 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension { ]; } + render() { + return html` + ${this.items.length > 0 + ? this.items.map( + (item, index) => html` +
this._handleItemMouseDown(e, index)} + @click=${(e) => this._selectItem(e, index)} + > + ${item.emoji} ${item.name} +
+ ` + ) + : html`
No result
`} + `; + } + + static getEmojis() { + return emojis; + } + + static get properties() { + return { + ...super.properties, + items: { type: Array }, + selectedIndex: { type: Number }, + }; + } + constructor() { super(); this.items = []; this.selectedIndex = 0; this.label = 'Emoji'; } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('mousedown', this._handleContainerMouseDown); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('mousedown', this._handleContainerMouseDown); + } getSuggestionConfig() { return { @@ -308,6 +335,19 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension { console.log('Emoji query:', query); return TpRtbEmojiSuggestion.getEmojis().filter(item => item.name.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10); }, + renderHTML({ options, node, suggestion }) { + return [ + 'span', + { + class: 'emoji', + 'data-type': 'emoji', + 'data-id': node.attrs.id, + 'data-char': ':', + contenteditable: 'false' + }, + node.attrs.label || node.attrs.id + ]; + }, render: () => { let component; let parentEditor; @@ -315,8 +355,6 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension { return { onStart: props => { - console.log('Emoji suggestion onStart called', props); - console.log('Props clientRect:', props.clientRect); // Find the parent editor using tp/helpers closest parentEditor = closest(this, 'tp-rich-text-box', true); if (!parentEditor) { @@ -378,7 +416,6 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension { return component.onKeyDown(props); }, onExit: () => { - console.log('Emoji suggestion onExit called'); if (component && selectItemHandler) { component.removeEventListener('select-item', selectItemHandler); component.items = []; @@ -403,23 +440,6 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension { // This method can be left empty or used for other purposes } - render() { - return html` - ${this.items.length > 0 - ? this.items.map( - (item, index) => html` -
- ${item.emoji} ${item.name} -
- ` - ) - : html`
No result
`} - `; - } - updated(changedProperties) { if (changedProperties.has('selectedIndex')) { const selectedElement = this.shadowRoot.querySelector('.item.is-selected'); @@ -432,13 +452,27 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension { } } - _selectItem(index) { + _selectItem(e, index) { + e.stopPropagation(); + e.preventDefault(); + const item = this.items[index]; if (item) { this.dispatchEvent(new CustomEvent('select-item', { detail: item })); } } + _handleItemMouseDown(e, index) { + // Prevent blur event from firing on the editor + e.preventDefault(); + } + + _handleContainerMouseDown(event) { + // Prevent blur event from firing when clicking anywhere in the suggestion popup + // This includes scrollbars, empty areas, etc. + event.preventDefault(); + } + onKeyDown({ event }) { if (event.key === 'ArrowUp') { this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length; @@ -451,7 +485,9 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension { } if (event.key === 'Enter') { - this._selectItem(this.selectedIndex); + event.preventDefault(); + event.stopPropagation(); + this._selectItem(event, this.selectedIndex); return true; } diff --git a/tp-rtb-user-mention.js b/tp-rtb-user-mention.js index 7106ec5..7a0a07d 100644 --- a/tp-rtb-user-mention.js +++ b/tp-rtb-user-mention.js @@ -1,6 +1,5 @@ -import { LitElement, html, css } from 'lit'; +import { html, css } from 'lit'; import { TpRtbBaseExtension } from './tp-rtb-base-extension.js'; -import Mention from '@tiptap/extension-mention'; import { closest } from '@tp/helpers/closest.js'; const sampleUsers = [ @@ -17,18 +16,6 @@ const sampleUsers = [ ]; export class TpRtbUserMention extends TpRtbBaseExtension { - static getUsers() { - return sampleUsers; - } - - static get properties() { - return { - ...super.properties, - items: { type: Array }, - selectedIndex: { type: Number }, - }; - } - static get styles() { return [ super.styles, @@ -45,6 +32,7 @@ export class TpRtbUserMention extends TpRtbBaseExtension { z-index: 9999; min-width: 200px; } + .item { padding: 8px; cursor: pointer; @@ -53,24 +41,30 @@ export class TpRtbUserMention extends TpRtbBaseExtension { gap: 8px; border-radius: 4px; } + .item.is-selected { background-color: #e3f2fd; } + .item:hover { background-color: #f5f5f5; } + .avatar { font-size: 20px; } + .user-info { display: flex; flex-direction: column; flex-grow: 1; } + .name { font-weight: 500; font-size: 14px; } + .username { font-size: 12px; color: #666; @@ -79,11 +73,67 @@ export class TpRtbUserMention extends TpRtbBaseExtension { ]; } + render() { + return html` +
+ ${this.items.length > 0 + ? this.items.map( + (item, index) => html` +
this._handleItemMouseDown(e, index)} + @click=${(e) => this._handleItemClick(e, index)} + > + ${item.avatar} + +
+ ` + ) + : html`
No users found
`} +
+ `; + } + + static get properties() { + return { + items: { type: Array }, + selectedIndex: { type: Number }, + getUsersCallback: { type: Function, state: true }, + }; + } + constructor() { super(); this.items = []; this.selectedIndex = 0; this.label = 'User Mention'; + this.getUsersCallback = null; + } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('mousedown', this._handleContainerMouseDown); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('mousedown', this._handleContainerMouseDown); + } + + _getFilteredUsers(query) { + // Use callback if provided, otherwise fall back to sample users + if (this.getUsersCallback && typeof this.getUsersCallback === 'function') { + return this.getUsersCallback(query); + } + + // Fallback to sample users with default filtering + return sampleUsers.filter(user => + user.name.toLowerCase().includes(query.toLowerCase()) || + user.username.toLowerCase().includes(query.toLowerCase()) + ).slice(0, 10); } getSuggestionConfig() { @@ -92,11 +142,21 @@ export class TpRtbUserMention extends TpRtbBaseExtension { allowSpaces: false, startOfLine: false, items: ({ query }) => { - console.log('User mention query:', query); - return TpRtbUserMention.getUsers().filter(user => - user.name.toLowerCase().includes(query.toLowerCase()) || - user.username.toLowerCase().includes(query.toLowerCase()) - ).slice(0, 10); + return this._getFilteredUsers(query); + }, + renderHTML({ options, node, suggestion }) { + return [ + 'span', + { + class: 'user-mention', + 'data-type': 'mention', + 'data-id': node.attrs.id, + 'data-label': node.attrs.label, + 'data-mention-suggestion-char': '@', + contenteditable: 'false' + }, + node.attrs.label || node.attrs.id + ]; }, render: () => { let component; @@ -193,27 +253,6 @@ export class TpRtbUserMention extends TpRtbBaseExtension { // This method can be left empty or used for other purposes } - render() { - return html` - ${this.items.length > 0 - ? this.items.map( - (item, index) => html` -
- ${item.avatar} - -
- ` - ) - : html`
No users found
`} - `; - } - updated(changedProperties) { if (changedProperties.has('selectedIndex')) { const selectedElement = this.shadowRoot.querySelector('.item.is-selected'); @@ -233,18 +272,37 @@ export class TpRtbUserMention extends TpRtbBaseExtension { } } + _handleItemClick(event, index) { + event.stopPropagation(); + event.preventDefault(); + this._selectItem(index); + } + + _handleItemMouseDown(event, index) { + // Prevent blur event from firing on the editor + event.preventDefault(); + } + + _handleContainerMouseDown(event) { + // Prevent blur event from firing when clicking anywhere in the suggestion popup + // This includes scrollbars, empty areas, etc. + event.preventDefault(); + } + onKeyDown({ event }) { if (event.key === 'ArrowUp') { this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length; return true; } - + if (event.key === 'ArrowDown') { this.selectedIndex = (this.selectedIndex + 1) % this.items.length; return true; } - + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); this._selectItem(this.selectedIndex); return true; }