From 9bf9ae9c371904c770f1ecdbab6c024aec4a9937 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 9 Dec 2025 16:56:34 +0000 Subject: [PATCH] Mentions: Added new endpoint, Built editor list display --- .../Controllers/UserSearchController.php | 38 +++- resources/js/wysiwyg/index.ts | 2 +- .../wysiwyg/lexical/core/LexicalCommands.ts | 2 + .../js/wysiwyg/lexical/core/LexicalEvents.ts | 6 +- .../js/wysiwyg/lexical/core/LexicalUtils.ts | 4 + resources/js/wysiwyg/services/mentions.ts | 176 +++++++++++++++++- .../views/form/user-mention-list.blade.php | 16 ++ routes/web.php | 1 + 8 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 resources/views/form/user-mention-list.blade.php diff --git a/app/Users/Controllers/UserSearchController.php b/app/Users/Controllers/UserSearchController.php index a2543b7ee..bc0543cab 100644 --- a/app/Users/Controllers/UserSearchController.php +++ b/app/Users/Controllers/UserSearchController.php @@ -5,6 +5,7 @@ namespace BookStack\Users\Controllers; use BookStack\Http\Controller; use BookStack\Permissions\Permission; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; class UserSearchController extends Controller @@ -34,8 +35,43 @@ class UserSearchController extends Controller $query->where('name', 'like', '%' . $search . '%'); } + /** @var Collection $users */ + $users = $query->get(); + return view('form.user-select-list', [ - 'users' => $query->get(), + 'users' => $users, + ]); + } + + /** + * Search users in the system, with the response formatted + * for use in a list of mentions. + */ + public function forMentions(Request $request) + { + $hasPermission = !user()->isGuest() && ( + userCan(Permission::CommentCreateAll) + || userCan(Permission::CommentUpdate) + ); + + if (!$hasPermission) { + $this->showPermissionError(); + } + + $search = $request->get('search', ''); + $query = User::query() + ->orderBy('name', 'asc') + ->take(20); + + if (!empty($search)) { + $query->where('name', 'like', '%' . $search . '%'); + } + + /** @var Collection $users */ + $users = $query->get(); + + return view('form.user-mention-list', [ + 'users' => $users, ]); } } diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 173cb18e7..13cc350fa 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -160,7 +160,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent: registerHistory(editor, createEmptyHistoryState(), 300), registerShortcuts(context), registerAutoLinks(editor), - registerMentions(editor), + registerMentions(context), ); // Register toolbars, modals & decorators diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts index f995237a0..1b378b4a0 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts @@ -78,6 +78,8 @@ export const KEY_ESCAPE_COMMAND: LexicalCommand = createCommand('KEY_ESCAPE_COMMAND'); export const KEY_DELETE_COMMAND: LexicalCommand = createCommand('KEY_DELETE_COMMAND'); +export const KEY_AT_COMMAND: LexicalCommand = + createCommand('KEY_AT_COMMAND'); export const KEY_TAB_COMMAND: LexicalCommand = createCommand('KEY_TAB_COMMAND'); export const INSERT_TAB_COMMAND: LexicalCommand = diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts index 26cf25a80..2d197ccc2 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts @@ -67,7 +67,7 @@ import { SELECTION_CHANGE_COMMAND, UNDO_COMMAND, } from '.'; -import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands'; +import {KEY_AT_COMMAND, KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands'; import { COMPOSITION_START_CHAR, DOM_ELEMENT_TYPE, @@ -97,7 +97,7 @@ import { getEditorPropertyFromDOMNode, getEditorsToPropagate, getNearestEditorFromDOMNode, - getWindow, + getWindow, isAt, isBackspace, isBold, isCopy, @@ -1062,6 +1062,8 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void { } else if (isDeleteLineForward(key, metaKey)) { event.preventDefault(); dispatchCommand(editor, DELETE_LINE_COMMAND, false); + } else if (isAt(key)) { + dispatchCommand(editor, KEY_AT_COMMAND, event); } else if (isBold(key, altKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold'); diff --git a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts index 71096b19d..b0bf2f180 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts @@ -1056,6 +1056,10 @@ export function isDelete(key: string): boolean { return key === 'Delete'; } +export function isAt(key: string): boolean { + return key === '@'; +} + export function isSelectAll( key: string, metaKey: boolean, diff --git a/resources/js/wysiwyg/services/mentions.ts b/resources/js/wysiwyg/services/mentions.ts index d8dc643f5..e41457b8a 100644 --- a/resources/js/wysiwyg/services/mentions.ts +++ b/resources/js/wysiwyg/services/mentions.ts @@ -1,17 +1,175 @@ -import {LexicalEditor, TextNode} from "lexical"; +import { + $createTextNode, + $getSelection, $isRangeSelection, + COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode +} from "lexical"; +import {KEY_AT_COMMAND} from "lexical/LexicalCommands"; +import {$createMentionNode} from "@lexical/link/LexicalMentionNode"; +import {el, htmlToDom} from "../utils/dom"; +import {EditorUiContext} from "../ui/framework/core"; +import {debounce} from "../../services/util"; +import {removeLoading, showLoading} from "../../services/dom"; -export function registerMentions(editor: LexicalEditor): () => void { +function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) { + const textNode = selection.getNodes()[0] as TextNode; + const selectionPos = selection.getStartEndPoints(); + if (!selectionPos) { + return; + } - const unregisterTransform = editor.registerNodeTransform(TextNode, (node: TextNode) =>{ - console.log(node); - // TODO - If last character is @, show autocomplete selector list of users. - // Filter list by any extra characters entered. - // On enter, replace with name mention element. - // On space/escape, hide autocomplete list. + const offset = selectionPos[0].offset; + + // Ignore if the @ sign is not after a space or the start of the line + const atStart = offset === 0; + const afterSpace = textNode.getTextContent().charAt(offset - 1) === ' '; + if (!atStart && !afterSpace) { + return; + } + + const split = textNode.splitText(offset); + const newNode = split[atStart ? 0 : 1]; + + const mention = $createMentionNode(0, '', ''); + newNode.replace(mention); + mention.select(); + + const revertEditorMention = () => { + context.editor.update(() => { + const text = $createTextNode('@'); + mention.replace(text); + text.selectEnd(); + }); + }; + + requestAnimationFrame(() => { + const mentionDOM = context.editor.getElementByKey(mention.getKey()); + if (!mentionDOM) { + revertEditorMention(); + return; + } + + const selectList = buildAndShowUserSelectorAtElement(context, mentionDOM); + handleUserListLoading(selectList); + handleUserSelectCancel(context, selectList, revertEditorMention); }); + + // TODO - On enter, replace with name mention element. +} + +function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, revertEditorMention: () => void) { + const controller = new AbortController(); + + const onCancel = () => { + revertEditorMention(); + selectList.remove(); + controller.abort(); + } + + selectList.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + onCancel(); + } + }, {signal: controller.signal}); + + const input = selectList.querySelector('input') as HTMLInputElement; + input.addEventListener('keydown', (event) => { + if (event.key === 'Backspace' && input.value === '') { + onCancel(); + event.preventDefault(); + event.stopPropagation(); + } + }, {signal: controller.signal}); + + context.editorDOM.addEventListener('click', (event) => { + onCancel() + }, {signal: controller.signal}); + context.editorDOM.addEventListener('keydown', (event) => { + onCancel(); + }, {signal: controller.signal}); +} + +function handleUserListLoading(selectList: HTMLElement) { + const cache = new Map(); + + const updateUserList = async (searchTerm: string) => { + // Empty list + for (const child of [...selectList.children].slice(1)) { + child.remove(); + } + + // Fetch new content + let responseHtml = ''; + if (cache.has(searchTerm)) { + responseHtml = cache.get(searchTerm) || ''; + } else { + const loadingWrap = el('li'); + showLoading(loadingWrap); + selectList.appendChild(loadingWrap); + + const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`); + responseHtml = resp.data as string; + cache.set(searchTerm, responseHtml); + loadingWrap.remove(); + } + + const doc = htmlToDom(responseHtml); + const toInsert = doc.querySelectorAll('li'); + 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 updateUserListDebounced = debounce(updateUserList, 200, false); + input.addEventListener('input', () => { + const searchTerm = input.value; + updateUserListDebounced(searchTerm); + }); +} + +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]); + + context.containerDOM.appendChild(userSelect); + + userSelect.style.display = 'block'; + userSelect.style.top = '0'; + userSelect.style.left = '0'; + const mentionPos = mentionDOM.getBoundingClientRect(); + const userSelectPos = userSelect.getBoundingClientRect(); + userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`; + userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`; + + searchInput.focus(); + + return userSelect; +} + +export function registerMentions(context: EditorUiContext): () => void { + const editor = context.editor; + + const unregisterCommand = editor.registerCommand(KEY_AT_COMMAND, function (event: KeyboardEvent): boolean { + const selection = $getSelection(); + if ($isRangeSelection(selection) && selection.isCollapsed()) { + window.setTimeout(() => { + editor.update(() => { + enterUserSelectMode(context, selection); + }); + }, 1); + } + return false; + }, COMMAND_PRIORITY_NORMAL); + return (): void => { - unregisterTransform(); + unregisterCommand(); }; } \ 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 new file mode 100644 index 000000000..66971d4ee --- /dev/null +++ b/resources/views/form/user-mention-list.blade.php @@ -0,0 +1,16 @@ +@if($users->isEmpty()) +
  • + {{ trans('common.no_items') }} +
  • +@endif +@foreach($users as $user) +
  • + + {{ $user->name }} + {{ $user->name }} + +
  • +@endforeach \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index ea3efe1ac..a20c0a3d3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -198,6 +198,7 @@ Route::middleware('auth')->group(function () { // User Search Route::get('/search/users/select', [UserControllers\UserSearchController::class, 'forSelect']); + Route::get('/search/users/mention', [UserControllers\UserSearchController::class, 'forMentions']); // Template System Route::get('/templates', [EntityControllers\PageTemplateController::class, 'list']);