This commit is contained in:
2025-11-20 15:23:20 +01:00
parent 980048b6ab
commit 52f7478b5f
4 changed files with 255 additions and 115 deletions

View File

@@ -43,7 +43,7 @@ class TheApp extends LitElement {
render() { render() {
return html` return html`
<tp-rich-text-box> <tp-rich-text-box menu-position="static">
<div slot="content" id="editor-content-element"></div> <div slot="content" id="editor-content-element"></div>
<!-- Selection Menu Controls --> <!-- Selection Menu Controls -->
<tp-rtb-bold></tp-rtb-bold> <tp-rtb-bold></tp-rtb-bold>

View File

@@ -29,23 +29,42 @@ class TpRichTextBox extends FormElement(LitElement) {
position: relative; position: relative;
} }
.custom-floating-menu { .selection-menu {
display: flex; display: flex;
padding: 4px; padding: 4px;
background-color: #fff; background-color: #fff;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
z-index: 100; 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; display: none;
} }
:host([menu-position="static"]) .selection-menu[hidden] {
display: flex;
}
.selection-menu[data-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.suggestion-menu { .suggestion-menu {
display: block; display: block;
padding: 0; padding: 0;
@@ -53,7 +72,7 @@ class TpRichTextBox extends FormElement(LitElement) {
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
z-index: 101; z-index: 101;
@@ -72,8 +91,12 @@ class TpRichTextBox extends FormElement(LitElement) {
render() { render() {
return html` return html`
<!-- Selection-based floating menu with all controls --> <!-- Selection-based menu with all controls -->
<div class="custom-floating-menu" ?hidden=${this.menuMode !== 'selection'}> <div
class="selection-menu"
?hidden=${this.menuMode !== 'selection' && this.menuPosition !== 'static'}
?data-disabled=${this.menuPosition === 'static' && this.menuMode !== 'selection'}
>
<slot @slotchange=${this._handleSlotChange}></slot> <slot @slotchange=${this._handleSlotChange}></slot>
</div> </div>
@@ -92,7 +115,7 @@ class TpRichTextBox extends FormElement(LitElement) {
extensions: { type: Array }, extensions: { type: Array },
menuMode: { type: String, reflect: true }, menuMode: { type: String, reflect: true },
activeSuggestionType: { type: String }, activeSuggestionType: { type: String },
asHtml: { type: Boolean } menuPosition: { type: String, reflect: true }
}; };
} }
@@ -101,7 +124,7 @@ class TpRichTextBox extends FormElement(LitElement) {
this.extensions = []; this.extensions = [];
this.menuMode = 'hidden'; // 'hidden', 'selection', 'suggestion' this.menuMode = 'hidden'; // 'hidden', 'selection', 'suggestion'
this.activeSuggestionType = null; // 'emoji', 'user', null this.activeSuggestionType = null; // 'emoji', 'user', null
this.asHtml = false; this.menuPosition = 'floating'; // 'floating', 'static'
this._slotObserver = new MutationObserver(this._processSlotChanges.bind(this)); this._slotObserver = new MutationObserver(this._processSlotChanges.bind(this));
} }
@@ -127,7 +150,7 @@ class TpRichTextBox extends FormElement(LitElement) {
this.editor = new Editor({ this.editor = new Editor({
element: this.querySelector('[slot="content"]'), element: this.querySelector('[slot="content"]'),
extensions: this.extensions, extensions: this.extensions,
content: '<p>Hello World!</p>', content: '',
}); });
// Notify child extensions that the editor is ready // 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 { empty, from, to } = editor.state.selection;
const hasSelection = !empty && from !== to;
// Hide menu if selection is empty or a caret // In static mode, always keep menu visible but update its state
if (empty || from === to) { 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(); this.hideMenu();
console.log('Selection is empty or a caret. Hiding menu.'); console.log('Selection is empty or a caret. Hiding menu.');
return; return;
@@ -165,16 +201,20 @@ class TpRichTextBox extends FormElement(LitElement) {
} }
/** /**
* Show the selection-based floating menu * Show the selection-based menu
*/ */
showSelectionMenu() { showSelectionMenu() {
this.menuMode = 'selection'; this.menuMode = 'selection';
requestAnimationFrame(() => {
const menu = this.shadowRoot.querySelector('.custom-floating-menu'); // Only position the menu if it's floating
if (menu) { if (this.menuPosition === 'floating') {
MenuPositioner.positionMenu(menu, this.editor); 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 * Hide all menus
*/ */
hideMenu() { 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.menuMode = 'hidden';
this.activeSuggestionType = null; this.activeSuggestionType = null;
// Reset all suggestion component visibility // Reset all suggestion component visibility
@@ -217,6 +264,7 @@ class TpRichTextBox extends FormElement(LitElement) {
_updateSuggestionComponentVisibility() { _updateSuggestionComponentVisibility() {
const emojiComponent = this.querySelector('tp-rtb-emoji-suggestion[slot="suggestion-content"]'); const emojiComponent = this.querySelector('tp-rtb-emoji-suggestion[slot="suggestion-content"]');
const userComponent = this.querySelector('tp-rtb-user-mention[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) { if (emojiComponent) {
emojiComponent.style.display = this.activeSuggestionType === 'emoji' ? 'block' : 'none'; emojiComponent.style.display = this.activeSuggestionType === 'emoji' ? 'block' : 'none';
@@ -224,6 +272,9 @@ class TpRichTextBox extends FormElement(LitElement) {
if (userComponent) { if (userComponent) {
userComponent.style.display = this.activeSuggestionType === 'user' ? 'block' : 'none'; userComponent.style.display = this.activeSuggestionType === 'user' ? 'block' : 'none';
} }
if (commandComponent) {
commandComponent.style.display = this.activeSuggestionType === 'command' ? 'block' : 'none';
}
} }
_processSlotChanges(mutations) { _processSlotChanges(mutations) {
@@ -273,29 +324,24 @@ class TpRichTextBox extends FormElement(LitElement) {
// If we have suggestions, add a single Mention extension with all suggestions // If we have suggestions, add a single Mention extension with all suggestions
if (suggestions.length > 0) { if (suggestions.length > 0) {
this.extensions.push( this.extensions.push(
Mention.extend({ Mention.configure({
renderText({ options, node }) { suggestions: suggestions,
// Return just the label without the trigger character renderHTML({ options, node, suggestion }) {
return node.attrs.label || node.attrs.id; if (suggestion && suggestion.renderHTML) {
}, return suggestion.renderHTML({ options, node, suggestion });
renderHTML({ options, node }) { }
console.warn('No renderHTML defined for mention suggestion. Using default rendering.');
return [ return [
'span', 'span',
{ {
class: 'mention',
'data-type': 'mention', 'data-type': 'mention',
'data-id': node.attrs.id,
'data-label': node.attrs.label,
contenteditable: 'false' contenteditable: 'false'
}, },
node.attrs.label || node.attrs.id node.attrs.label || node.attrs.id
]; ];
}, }
}).configure({
HTMLAttributes: {
class: 'mention',
},
suggestions: suggestions
}) })
); );
} }
@@ -330,10 +376,10 @@ class TpRichTextBox extends FormElement(LitElement) {
get value() { get value() {
if (this.editor) { 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) { set value(content) {

View File

@@ -1,6 +1,5 @@
import { LitElement, html, css } from 'lit'; import { html, css } from 'lit';
import { TpRtbBaseExtension } from './tp-rtb-base-extension.js'; import { TpRtbBaseExtension } from './tp-rtb-base-extension.js';
import Mention from '@tiptap/extension-mention';
import { closest } from '@tp/helpers/closest.js'; import { closest } from '@tp/helpers/closest.js';
const emojis = [ const emojis = [
@@ -254,18 +253,6 @@ const emojis = [
]; ];
export class TpRtbEmojiSuggestion extends TpRtbBaseExtension { export class TpRtbEmojiSuggestion extends TpRtbBaseExtension {
static getEmojis() {
return emojis;
}
static get properties() {
return {
...super.properties,
items: { type: Array },
selectedIndex: { type: Number },
};
}
static get styles() { static get styles() {
return [ return [
super.styles, super.styles,
@@ -292,12 +279,52 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension {
]; ];
} }
render() {
return html`
${this.items.length > 0
? this.items.map(
(item, index) => html`
<div
class="item ${index === this.selectedIndex ? 'is-selected' : ''}"
@mousedown=${(e) => this._handleItemMouseDown(e, index)}
@click=${(e) => this._selectItem(e, index)}
>
${item.emoji} ${item.name}
</div>
`
)
: html`<div class="item">No result</div>`}
`;
}
static getEmojis() {
return emojis;
}
static get properties() {
return {
...super.properties,
items: { type: Array },
selectedIndex: { type: Number },
};
}
constructor() { constructor() {
super(); super();
this.items = []; this.items = [];
this.selectedIndex = 0; this.selectedIndex = 0;
this.label = 'Emoji'; this.label = 'Emoji';
} }
connectedCallback() {
super.connectedCallback();
this.addEventListener('mousedown', this._handleContainerMouseDown);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('mousedown', this._handleContainerMouseDown);
}
getSuggestionConfig() { getSuggestionConfig() {
return { return {
@@ -308,6 +335,19 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension {
console.log('Emoji query:', query); console.log('Emoji query:', query);
return TpRtbEmojiSuggestion.getEmojis().filter(item => item.name.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10); 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: () => { render: () => {
let component; let component;
let parentEditor; let parentEditor;
@@ -315,8 +355,6 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension {
return { return {
onStart: props => { onStart: props => {
console.log('Emoji suggestion onStart called', props);
console.log('Props clientRect:', props.clientRect);
// Find the parent editor using tp/helpers closest // Find the parent editor using tp/helpers closest
parentEditor = closest(this, 'tp-rich-text-box', true); parentEditor = closest(this, 'tp-rich-text-box', true);
if (!parentEditor) { if (!parentEditor) {
@@ -378,7 +416,6 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension {
return component.onKeyDown(props); return component.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
console.log('Emoji suggestion onExit called');
if (component && selectItemHandler) { if (component && selectItemHandler) {
component.removeEventListener('select-item', selectItemHandler); component.removeEventListener('select-item', selectItemHandler);
component.items = []; component.items = [];
@@ -403,23 +440,6 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension {
// This method can be left empty or used for other purposes // This method can be left empty or used for other purposes
} }
render() {
return html`
${this.items.length > 0
? this.items.map(
(item, index) => html`
<div
class="item ${index === this.selectedIndex ? 'is-selected' : ''}"
@click="${() => this._selectItem(index)}"
>
${item.emoji} ${item.name}
</div>
`
)
: html`<div class="item">No result</div>`}
`;
}
updated(changedProperties) { updated(changedProperties) {
if (changedProperties.has('selectedIndex')) { if (changedProperties.has('selectedIndex')) {
const selectedElement = this.shadowRoot.querySelector('.item.is-selected'); 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]; const item = this.items[index];
if (item) { if (item) {
this.dispatchEvent(new CustomEvent('select-item', { detail: 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 }) { onKeyDown({ event }) {
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length; this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length;
@@ -451,7 +485,9 @@ export class TpRtbEmojiSuggestion extends TpRtbBaseExtension {
} }
if (event.key === 'Enter') { if (event.key === 'Enter') {
this._selectItem(this.selectedIndex); event.preventDefault();
event.stopPropagation();
this._selectItem(event, this.selectedIndex);
return true; return true;
} }

View File

@@ -1,6 +1,5 @@
import { LitElement, html, css } from 'lit'; import { html, css } from 'lit';
import { TpRtbBaseExtension } from './tp-rtb-base-extension.js'; import { TpRtbBaseExtension } from './tp-rtb-base-extension.js';
import Mention from '@tiptap/extension-mention';
import { closest } from '@tp/helpers/closest.js'; import { closest } from '@tp/helpers/closest.js';
const sampleUsers = [ const sampleUsers = [
@@ -17,18 +16,6 @@ const sampleUsers = [
]; ];
export class TpRtbUserMention extends TpRtbBaseExtension { export class TpRtbUserMention extends TpRtbBaseExtension {
static getUsers() {
return sampleUsers;
}
static get properties() {
return {
...super.properties,
items: { type: Array },
selectedIndex: { type: Number },
};
}
static get styles() { static get styles() {
return [ return [
super.styles, super.styles,
@@ -45,6 +32,7 @@ export class TpRtbUserMention extends TpRtbBaseExtension {
z-index: 9999; z-index: 9999;
min-width: 200px; min-width: 200px;
} }
.item { .item {
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;
@@ -53,24 +41,30 @@ export class TpRtbUserMention extends TpRtbBaseExtension {
gap: 8px; gap: 8px;
border-radius: 4px; border-radius: 4px;
} }
.item.is-selected { .item.is-selected {
background-color: #e3f2fd; background-color: #e3f2fd;
} }
.item:hover { .item:hover {
background-color: #f5f5f5; background-color: #f5f5f5;
} }
.avatar { .avatar {
font-size: 20px; font-size: 20px;
} }
.user-info { .user-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
} }
.name { .name {
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 14px;
} }
.username { .username {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
@@ -79,11 +73,67 @@ export class TpRtbUserMention extends TpRtbBaseExtension {
]; ];
} }
render() {
return html`
<div>
${this.items.length > 0
? this.items.map(
(item, index) => html`
<div
class="item ${index === this.selectedIndex ? 'is-selected' : ''}"
@mousedown=${(e) => this._handleItemMouseDown(e, index)}
@click=${(e) => this._handleItemClick(e, index)}
>
<span class="avatar">${item.avatar}</span>
<div class="user-info">
<span class="name">${item.name}</span>
<span class="username">@${item.username}</span>
</div>
</div>
`
)
: html`<div class="item">No users found</div>`}
</div>
`;
}
static get properties() {
return {
items: { type: Array },
selectedIndex: { type: Number },
getUsersCallback: { type: Function, state: true },
};
}
constructor() { constructor() {
super(); super();
this.items = []; this.items = [];
this.selectedIndex = 0; this.selectedIndex = 0;
this.label = 'User Mention'; 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() { getSuggestionConfig() {
@@ -92,11 +142,21 @@ export class TpRtbUserMention extends TpRtbBaseExtension {
allowSpaces: false, allowSpaces: false,
startOfLine: false, startOfLine: false,
items: ({ query }) => { items: ({ query }) => {
console.log('User mention query:', query); return this._getFilteredUsers(query);
return TpRtbUserMention.getUsers().filter(user => },
user.name.toLowerCase().includes(query.toLowerCase()) || renderHTML({ options, node, suggestion }) {
user.username.toLowerCase().includes(query.toLowerCase()) return [
).slice(0, 10); '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: () => { render: () => {
let component; let component;
@@ -193,27 +253,6 @@ export class TpRtbUserMention extends TpRtbBaseExtension {
// This method can be left empty or used for other purposes // This method can be left empty or used for other purposes
} }
render() {
return html`
${this.items.length > 0
? this.items.map(
(item, index) => html`
<div
class="item ${index === this.selectedIndex ? 'is-selected' : ''}"
@click="${() => this._selectItem(index)}"
>
<span class="avatar">${item.avatar}</span>
<div class="user-info">
<span class="name">${item.name}</span>
<span class="username">@${item.username}</span>
</div>
</div>
`
)
: html`<div class="item">No users found</div>`}
`;
}
updated(changedProperties) { updated(changedProperties) {
if (changedProperties.has('selectedIndex')) { if (changedProperties.has('selectedIndex')) {
const selectedElement = this.shadowRoot.querySelector('.item.is-selected'); 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 }) { onKeyDown({ event }) {
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length; this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length;
return true; return true;
} }
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length; this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
return true; return true;
} }
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
this._selectItem(this.selectedIndex); this._selectItem(this.selectedIndex);
return true; return true;
} }