diff --git a/app/Users/Controllers/UserSearchController.php b/app/Users/Controllers/UserSearchController.php index a2543b7eed3..bc0543cab16 100644 --- a/app/Users/Controllers/UserSearchController.php +++ b/app/Users/Controllers/UserSearchController.php @@ -5,6 +5,7 @@ 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 @@ public function forSelect(Request $request) $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/components/page-comment.ts b/resources/js/components/page-comment.ts index 8334ebb8a09..68cd46f041c 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -2,7 +2,7 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom'; import {PageCommentReference} from "./page-comment-reference"; import {HttpError} from "../services/http"; -import {SimpleWysiwygEditorInterface} from "../wysiwyg"; +import {createCommentEditorInstance, SimpleWysiwygEditorInterface} from "../wysiwyg"; import {el} from "../wysiwyg/utils/dom"; export interface PageCommentReplyEventData { @@ -104,7 +104,7 @@ export class PageComment extends Component { this.input.parentElement?.appendChild(container); this.input.hidden = true; - this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, editorContent, { + this.wysiwygEditor = wysiwygModule.createCommentEditorInstance(container as HTMLElement, editorContent, { darkMode: document.documentElement.classList.contains('dark-mode'), textDirection: this.$opts.textDirection, translations: (window as unknown as Record).editor_translations, diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts index a1eeda1f9d9..707ca3f6936 100644 --- a/resources/js/components/page-comments.ts +++ b/resources/js/components/page-comments.ts @@ -5,7 +5,7 @@ import {PageCommentReference} from "./page-comment-reference"; import {scrollAndHighlightElement} from "../services/util"; import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment"; import {el} from "../wysiwyg/utils/dom"; -import {SimpleWysiwygEditorInterface} from "../wysiwyg"; +import {createCommentEditorInstance, SimpleWysiwygEditorInterface} from "../wysiwyg"; export class PageComments extends Component { @@ -200,7 +200,7 @@ export class PageComments extends Component { this.formInput.parentElement?.appendChild(container); this.formInput.hidden = true; - this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '

', { + this.wysiwygEditor = wysiwygModule.createCommentEditorInstance(container as HTMLElement, '

', { darkMode: document.documentElement.classList.contains('dark-mode'), textDirection: this.wysiwygTextDirection, translations: (window as unknown as Record).editor_translations, diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 5d1762ff867..13cc350fa0a 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -2,7 +2,12 @@ import {createEditor} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; -import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; +import { + getNodesForBasicEditor, + getNodesForCommentEditor, + getNodesForPageEditor, + registerCommonNodeMutationListeners +} from './nodes'; import {buildEditorUI} from "./ui"; import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; @@ -22,6 +27,7 @@ import {DiagramDecorator} from "./ui/decorators/diagram"; import {registerMouseHandling} from "./services/mouse-handling"; import {registerSelectionHandling} from "./services/selection-handling"; import {EditorApi} from "./api/api"; +import {registerMentions} from "./services/mentions"; const theme = { text: { @@ -136,6 +142,43 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s return new SimpleWysiwygEditorInterface(context); } +export function createCommentEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { + const editor = createEditor({ + namespace: 'BookStackCommentEditor', + nodes: getNodesForCommentEditor(), + onError: console.error, + theme: theme, + }); + + // TODO - Dedupe this with the basic editor instance + // Changed elements: namespace, registerMentions, toolbar, public event usage + const context: EditorUiContext = buildEditorUI(container, editor, options); + editor.setRootElement(context.editorDOM); + + const editorTeardown = mergeRegister( + registerRichText(editor), + registerHistory(editor, createEmptyHistoryState(), 300), + registerShortcuts(context), + registerAutoLinks(editor), + registerMentions(context), + ); + + // Register toolbars, modals & decorators + context.manager.setToolbar(getBasicEditorToolbar(context)); + context.manager.registerContextToolbar('link', contextToolbars.link); + context.manager.registerModal('link', modals.link); + context.manager.onTeardown(editorTeardown); + + setEditorContentFromHtml(editor, htmlContent); + + window.$events.emitPublic(container, 'editor-wysiwyg::post-init', { + usage: 'comment-editor', + api: new EditorApi(context), + }); + + return new SimpleWysiwygEditorInterface(context); +} + export class SimpleWysiwygEditorInterface { protected context: EditorUiContext; protected onChangeListeners: (() => void)[] = []; diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts index f995237a0cf..1b378b4a010 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 26cf25a800d..2d197ccc27a 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 71096b19dce..b0bf2f180bc 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/lexical/link/LexicalMentionNode.ts b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts new file mode 100644 index 00000000000..a57173208b3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts @@ -0,0 +1,107 @@ +import { + DOMConversion, + DOMConversionMap, DOMConversionOutput, + type EditorConfig, + ElementNode, + LexicalEditor, LexicalNode, + SerializedElementNode, + Spread +} from "lexical"; + +export type SerializedMentionNode = Spread<{ + user_id: number; + user_name: string; + user_slug: string; +}, SerializedElementNode> + +export class MentionNode extends ElementNode { + __user_id: number = 0; + __user_name: string = ''; + __user_slug: string = ''; + + static getType(): string { + return 'mention'; + } + + static clone(node: MentionNode): MentionNode { + const newNode = new MentionNode(node.__key); + newNode.__user_id = node.__user_id; + newNode.__user_name = node.__user_name; + newNode.__user_slug = node.__user_slug; + return newNode; + } + + setUserDetails(userId: number, userName: string, userSlug: string): void { + const self = this.getWritable(); + self.__user_id = userId; + self.__user_name = userName; + self.__user_slug = userSlug; + } + + isInline(): boolean { + return true; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const element = document.createElement('a'); + element.setAttribute('target', '_blank'); + element.setAttribute('href', window.baseUrl('/users/' + this.__user_slug)); + element.setAttribute('data-user-mention-id', String(this.__user_id)); + element.textContent = '@' + this.__user_name; + return element; + } + + updateDOM(prevNode: MentionNode): boolean { + return prevNode.__user_id !== this.__user_id; + } + + static importDOM(): DOMConversionMap|null { + return { + a(node: HTMLElement): DOMConversion|null { + if (node.hasAttribute('data-user-mention-id')) { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + const node = new MentionNode(); + node.setUserDetails( + Number(element.getAttribute('data-user-mention-id') || '0'), + element.innerText.replace(/^@/, ''), + element.getAttribute('href')?.split('/user/')[1] || '' + ); + + return { + node, + }; + }, + priority: 4, + }; + } + return null; + }, + }; + } + + exportJSON(): SerializedMentionNode { + return { + ...super.exportJSON(), + type: 'mention', + version: 1, + user_id: this.__user_id, + user_name: this.__user_name, + user_slug: this.__user_slug, + }; + } + + static importJSON(serializedNode: SerializedMentionNode): MentionNode { + return $createMentionNode(serializedNode.user_id, serializedNode.user_name, serializedNode.user_slug); + } +} + +export function $createMentionNode(userId: number, userName: string, userSlug: string) { + const node = new MentionNode(); + node.setUserDetails(userId, userName, userSlug); + return node; +} + +export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode { + return node instanceof MentionNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index 413e2c4cd3f..7c1a71579d9 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -19,6 +19,7 @@ import {MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {CaptionNode} from "@lexical/table/LexicalCaptionNode"; +import {MentionNode} from "@lexical/link/LexicalMentionNode"; export function getNodesForPageEditor(): (KlassConstructor | LexicalNodeReplacement)[] { return [ @@ -51,6 +52,13 @@ export function getNodesForBasicEditor(): (KlassConstructor ]; } +export function getNodesForCommentEditor(): (KlassConstructor | LexicalNodeReplacement)[] { + return [ + ...getNodesForBasicEditor(), + MentionNode, + ]; +} + export function registerCommonNodeMutationListeners(context: EditorUiContext): void { const decorated = [ImageNode, CodeBlockNode, DiagramNode]; diff --git a/resources/js/wysiwyg/services/mentions.ts b/resources/js/wysiwyg/services/mentions.ts new file mode 100644 index 00000000000..e41457b8a29 --- /dev/null +++ b/resources/js/wysiwyg/services/mentions.ts @@ -0,0 +1,175 @@ +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"; + + +function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) { + const textNode = selection.getNodes()[0] as TextNode; + const selectionPos = selection.getStartEndPoints(); + if (!selectionPos) { + return; + } + + 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 => { + 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 00000000000..66971d4ee2b --- /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 ea3efe1ac77..a20c0a3d3d0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -198,6 +198,7 @@ // 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']);