diff --git a/dev/build/livereload.js b/dev/build/livereload.js index b4bf38e6d..c2d8ac620 100644 --- a/dev/build/livereload.js +++ b/dev/build/livereload.js @@ -20,7 +20,7 @@ function listen() { if (url.pathname.endsWith(name)) { const next = link.cloneNode(); - next.href = name + '?' + Math.random().toString(36).slice(2); + next.href = name + '?version=' + Math.random().toString(36).slice(2); next.onload = function() { link.remove(); }; diff --git a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts index 62213a42c..9010b3c78 100644 --- a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts +++ b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts @@ -1,7 +1,7 @@ import { DecoratorNode, DOMConversion, - DOMConversionMap, DOMConversionOutput, DOMExportOutput, + DOMConversionMap, DOMConversionOutput, type EditorConfig, LexicalEditor, LexicalNode, SerializedLexicalNode, @@ -38,6 +38,10 @@ export class MentionNode extends DecoratorNode { self.__user_slug = userSlug; } + hasUserSet(): boolean { + return this.__user_id > 0; + } + isInline(): boolean { return true; } @@ -58,8 +62,8 @@ export class MentionNode extends DecoratorNode { element.setAttribute('target', '_blank'); element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug)); element.setAttribute('data-mention-user-id', String(this.__user_id)); + element.setAttribute('title', '@' + this.__user_name); element.textContent = '@' + this.__user_name; - // element.setAttribute('contenteditable', 'false'); return element; } @@ -67,12 +71,6 @@ export class MentionNode extends DecoratorNode { return prevNode.__user_id !== this.__user_id; } - exportDOM(editor: LexicalEditor): DOMExportOutput { - const element = this.createDOM(editor._config, editor); - // element.removeAttribute('contenteditable'); - return {element}; - } - static importDOM(): DOMConversionMap|null { return { a(node: HTMLElement): DOMConversion|null { diff --git a/resources/js/wysiwyg/services/mentions.ts b/resources/js/wysiwyg/services/mentions.ts index 87a1f4b8b..59fd02b1d 100644 --- a/resources/js/wysiwyg/services/mentions.ts +++ b/resources/js/wysiwyg/services/mentions.ts @@ -1,9 +1,9 @@ import { $getSelection, $isRangeSelection, - COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode + COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, RangeSelection, TextNode } from "lexical"; import {KEY_AT_COMMAND} from "lexical/LexicalCommands"; -import {$createMentionNode} from "@lexical/link/LexicalMentionNode"; +import {$createMentionNode, $isMentionNode, MentionNode} from "@lexical/link/LexicalMentionNode"; import {EditorUiContext} from "../ui/framework/core"; import {MentionDecorator} from "../ui/decorators/MentionDecorator"; @@ -38,6 +38,20 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection }); } +function selectMention(context: EditorUiContext, event: KeyboardEvent): boolean { + const selected = $getSelection()?.getNodes() || []; + if (selected.length === 1 && $isMentionNode(selected[0])) { + const mention = selected[0] as MentionNode; + const decorator = context.manager.getDecoratorByNodeKey(mention.getKey()) as MentionDecorator; + decorator.showSelection(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + + return false; +} + export function registerMentions(context: EditorUiContext): () => void { const editor = context.editor; @@ -53,7 +67,12 @@ export function registerMentions(context: EditorUiContext): () => void { return false; }, COMMAND_PRIORITY_NORMAL); + const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, function (event: KeyboardEvent): boolean { + return selectMention(context, event); + }, COMMAND_PRIORITY_NORMAL); + return (): void => { unregisterCommand(); + unregisterEnter(); }; } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts index df2d0a227..a2786de00 100644 --- a/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts +++ b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts @@ -5,6 +5,9 @@ import {showLoading} from "../../../services/dom"; import {MentionNode} from "@lexical/link/LexicalMentionNode"; import {debounce} from "../../../services/util"; import {$createTextNode} from "lexical"; +import {KeyboardNavigationHandler} from "../../../services/keyboard-navigation"; + +import searchIcon from "@icons/search.svg"; function userClickHandler(onSelect: (id: number, name: string, slug: string)=>void): (event: PointerEvent) => void { return (event: PointerEvent) => { @@ -51,7 +54,7 @@ function handleUserListLoading(selectList: HTMLElement) { const updateUserList = async (searchTerm: string) => { // Empty list - for (const child of [...selectList.children].slice(1)) { + for (const child of [...selectList.children]) { child.remove(); } @@ -60,7 +63,7 @@ function handleUserListLoading(selectList: HTMLElement) { if (cache.has(searchTerm)) { responseHtml = cache.get(searchTerm) || ''; } else { - const loadingWrap = el('li'); + const loadingWrap = el('div', {class: 'flex-container-row items-center dropdown-search-item'}); showLoading(loadingWrap); selectList.appendChild(loadingWrap); @@ -71,18 +74,17 @@ function handleUserListLoading(selectList: HTMLElement) { } const doc = htmlToDom(responseHtml); - const toInsert = doc.querySelectorAll('li'); + const toInsert = doc.body.children; for (const listEl of toInsert) { const adopted = window.document.adoptNode(listEl) as HTMLElement; selectList.appendChild(adopted); } - }; // Initial load updateUserList(''); - const input = selectList.querySelector('input') as HTMLInputElement; + const input = selectList.parentElement?.querySelector('input') as HTMLInputElement; const updateUserListDebounced = debounce(updateUserList, 200, false); input.addEventListener('input', () => { const searchTerm = input.value; @@ -92,8 +94,15 @@ function handleUserListLoading(selectList: HTMLElement) { function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement { const searchInput = el('input', {type: 'text'}); - const searchItem = el('li', {}, [searchInput]); - const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]); + const list = el('div', {class: 'dropdown-search-list'}); + const iconWrap = el('div'); + iconWrap.innerHTML = searchIcon; + const icon = iconWrap.children[0] as HTMLElement; + icon.classList.add('svg-icon'); + const userSelect = el('div', {class: 'dropdown-search-dropdown compact card'}, [ + el('div', {class: 'dropdown-search-search'}, [icon, searchInput]), + list, + ]); context.containerDOM.appendChild(userSelect); @@ -111,28 +120,32 @@ function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: } export class MentionDecorator extends EditorDecorator { - protected completedSetup: boolean = false; protected abortController: AbortController | null = null; - protected selectList: HTMLElement | null = null; + protected dropdownContainer: HTMLElement | null = null; protected mentionElement: HTMLElement | null = null; setup(element: HTMLElement) { this.mentionElement = element; - this.completedSetup = true; + + element.addEventListener('click', (event: PointerEvent) => { + this.showSelection(); + event.preventDefault(); + event.stopPropagation(); + }); } showSelection() { - if (!this.mentionElement) { + if (!this.mentionElement || this.dropdownContainer) { return; } this.hideSelection(); this.abortController = new AbortController(); - this.selectList = buildAndShowUserSelectorAtElement(this.context, this.mentionElement); - handleUserListLoading(this.selectList); + this.dropdownContainer = buildAndShowUserSelectorAtElement(this.context, this.mentionElement); + handleUserListLoading(this.dropdownContainer.querySelector('.dropdown-search-list') as HTMLElement); - this.selectList.addEventListener('click', userClickHandler((id, name, slug) => { + this.dropdownContainer.addEventListener('click', userClickHandler((id, name, slug) => { this.context.editor.update(() => { const mentionNode = this.getNode() as MentionNode; this.hideSelection(); @@ -141,12 +154,22 @@ export class MentionDecorator extends EditorDecorator { }); }), {signal: this.abortController.signal}); - handleUserSelectCancel(this.context, this.selectList, this.abortController, this.revertMention.bind(this)); + handleUserSelectCancel(this.context, this.dropdownContainer, this.abortController, () => { + if ((this.getNode() as MentionNode).hasUserSet()) { + this.hideSelection() + } else { + this.revertMention(); + } + }); + + new KeyboardNavigationHandler(this.dropdownContainer); } hideSelection() { this.abortController?.abort(); - this.selectList?.remove(); + this.dropdownContainer?.remove(); + this.abortController = null; + this.dropdownContainer = null; } revertMention() { @@ -158,15 +181,7 @@ export class MentionDecorator extends EditorDecorator { }); } - update() { - // - } - render(element: HTMLElement): void { - if (this.completedSetup) { - this.update(); - } else { - this.setup(element); - } + this.setup(element); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index 9302e7bed..d6af99638 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -243,7 +243,7 @@ export const contextToolbars: Record = { content: () => [new EditorButton(media)], }, link: { - selector: 'a', + selector: 'a:not([data-mention-user-id])', content() { return [ new EditorButton(link), diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 8ea15de80..0f374fb80 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -746,7 +746,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { @include mixins.lightDark(border-color, #DDD, #444); margin-inline-start: vars.$xs; width: vars.$l; - height: calc(100% - vars.$m); + height: calc(100% - #{vars.$m}); } .comment-reference-indicator-wrap a { @@ -982,6 +982,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .dropdown-search-item { padding: vars.$s vars.$m; + font-size: 0.8rem; &:hover,&:focus { background-color: #F2F2F2; text-decoration: none; @@ -996,6 +997,21 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { input:focus { outline: 0; } + .svg-icon { + font-size: vars.$fs-m; + } + &.compact { + .dropdown-search-list { + max-height: 320px; + } + .dropdown-search-item { + padding: vars.$xs vars.$s; + } + .avatar { + width: 22px; + height: 22px; + } + } } @include mixins.smaller-than(vars.$bp-l) { diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index 98548609e..e77ea6330 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -200,11 +200,30 @@ body .page-content img, } } +/** + * Mention Links + */ + a[data-mention-user-id] { display: inline-block; position: relative; color: var(--color-link); - padding: 0.1em 0.2em; + padding: 0.1em 0.4em; + display: -webkit-inline-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.92em; + margin-inline: 0.2em; + vertical-align: middle; + border-radius: 3px; + border: 1px solid transparent; + &:hover { + text-decoration: none; + border-color: currentColor; + } &:after { content: ''; background-color: currentColor; @@ -215,6 +234,5 @@ a[data-mention-user-id] { width: 100%; height: 100%; display: block; - border-radius: 0.2em; } } \ No newline at end of file diff --git a/resources/views/form/user-mention-list.blade.php b/resources/views/form/user-mention-list.blade.php index 66971d4ee..020cfb35b 100644 --- a/resources/views/form/user-mention-list.blade.php +++ b/resources/views/form/user-mention-list.blade.php @@ -1,16 +1,14 @@ @if($users->isEmpty()) -
  • - {{ trans('common.no_items') }} -
  • + @endif @foreach($users as $user) -
  • - - {{ $user->name }} - {{ $user->name }} - -
  • + + {{ $user->name }} + {{ $user->name }} + @endforeach \ No newline at end of file