From 50540e23a1c2f97182799a5c163b3c67ea0ebad8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 8 Dec 2025 15:52:21 +0000 Subject: [PATCH 01/11] Lexical: Created mention node, started mention service, split comment editor out --- resources/js/components/page-comment.ts | 4 +- resources/js/components/page-comments.ts | 4 +- resources/js/wysiwyg/index.ts | 45 +++++++- .../lexical/link/LexicalMentionNode.ts | 107 ++++++++++++++++++ resources/js/wysiwyg/nodes.ts | 8 ++ resources/js/wysiwyg/services/mentions.ts | 17 +++ 6 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts create mode 100644 resources/js/wysiwyg/services/mentions.ts 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..173cb18e7b9 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(editor), + ); + + // 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/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..d8dc643f590 --- /dev/null +++ b/resources/js/wysiwyg/services/mentions.ts @@ -0,0 +1,17 @@ +import {LexicalEditor, TextNode} from "lexical"; + + +export function registerMentions(editor: LexicalEditor): () => void { + + 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. + }); + + return (): void => { + unregisterTransform(); + }; +} \ No newline at end of file From 9bf9ae9c371904c770f1ecdbab6c024aec4a9937 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 9 Dec 2025 16:56:34 +0000 Subject: [PATCH 02/11] 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 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/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 173cb18e7b9..13cc350fa0a 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 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/services/mentions.ts b/resources/js/wysiwyg/services/mentions.ts index d8dc643f590..e41457b8a29 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 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']); From 1e768ce33f0aebc3af717ada38fa42222c6e6137 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 13 Dec 2025 17:03:48 +0000 Subject: [PATCH 03/11] Lexical: Changed mention to be a decorator node Allows better selection. Also updated existing decorator file names to align with classes so they're easier to find. Also aligned/fixed decorator constuctor/setup methods. --- app/Util/HtmlDescriptionFilter.php | 2 +- resources/js/wysiwyg/index.ts | 8 +- .../lexical/link/LexicalMentionNode.ts | 42 +++-- resources/js/wysiwyg/services/mentions.ts | 124 +------------ .../{code-block.ts => CodeBlockDecorator.ts} | 16 +- .../{diagram.ts => DiagramDecorator.ts} | 18 +- .../wysiwyg/ui/decorators/MentionDecorator.ts | 172 ++++++++++++++++++ .../js/wysiwyg/ui/framework/decorator.ts | 2 +- resources/js/wysiwyg/ui/framework/manager.ts | 4 +- resources/sass/_content.scss | 19 ++ 10 files changed, 252 insertions(+), 155 deletions(-) rename resources/js/wysiwyg/ui/decorators/{code-block.ts => CodeBlockDecorator.ts} (84%) rename resources/js/wysiwyg/ui/decorators/{diagram.ts => DiagramDecorator.ts} (70%) create mode 100644 resources/js/wysiwyg/ui/decorators/MentionDecorator.ts diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php index d4f7d2c8fa2..1baa11ffcfa 100644 --- a/app/Util/HtmlDescriptionFilter.php +++ b/app/Util/HtmlDescriptionFilter.php @@ -19,7 +19,7 @@ class HtmlDescriptionFilter */ protected static array $allowedAttrsByElements = [ 'p' => [], - 'a' => ['href', 'title', 'target'], + 'a' => ['href', 'title', 'target', 'data-mention-user-id'], 'ol' => [], 'ul' => [], 'li' => [], diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 13cc350fa0a..273657c47e6 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -22,12 +22,13 @@ import {registerKeyboardHandling} from "./services/keyboard-handling"; import {registerAutoLinks} from "./services/auto-links"; import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars"; import {modals} from "./ui/defaults/modals"; -import {CodeBlockDecorator} from "./ui/decorators/code-block"; -import {DiagramDecorator} from "./ui/decorators/diagram"; +import {CodeBlockDecorator} from "./ui/decorators/CodeBlockDecorator"; +import {DiagramDecorator} from "./ui/decorators/DiagramDecorator"; import {registerMouseHandling} from "./services/mouse-handling"; import {registerSelectionHandling} from "./services/selection-handling"; import {EditorApi} from "./api/api"; import {registerMentions} from "./services/mentions"; +import {MentionDecorator} from "./ui/decorators/MentionDecorator"; const theme = { text: { @@ -151,7 +152,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent: }); // TODO - Dedupe this with the basic editor instance - // Changed elements: namespace, registerMentions, toolbar, public event usage + // Changed elements: namespace, registerMentions, toolbar, public event usage, mentioned decorator const context: EditorUiContext = buildEditorUI(container, editor, options); editor.setRootElement(context.editorDOM); @@ -168,6 +169,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent: context.manager.registerContextToolbar('link', contextToolbars.link); context.manager.registerModal('link', modals.link); context.manager.onTeardown(editorTeardown); + context.manager.registerDecoratorType('mention', MentionDecorator); setEditorContentFromHtml(editor, htmlContent); diff --git a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts index a57173208b3..62213a42cbd 100644 --- a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts +++ b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts @@ -1,20 +1,21 @@ import { + DecoratorNode, DOMConversion, - DOMConversionMap, DOMConversionOutput, + DOMConversionMap, DOMConversionOutput, DOMExportOutput, type EditorConfig, - ElementNode, LexicalEditor, LexicalNode, - SerializedElementNode, + SerializedLexicalNode, Spread } from "lexical"; +import {EditorDecoratorAdapter} from "../../ui/framework/decorator"; export type SerializedMentionNode = Spread<{ user_id: number; user_name: string; user_slug: string; -}, SerializedElementNode> +}, SerializedLexicalNode> -export class MentionNode extends ElementNode { +export class MentionNode extends DecoratorNode { __user_id: number = 0; __user_name: string = ''; __user_slug: string = ''; @@ -22,7 +23,6 @@ export class MentionNode extends ElementNode { static getType(): string { return 'mention'; } - static clone(node: MentionNode): MentionNode { const newNode = new MentionNode(node.__key); newNode.__user_id = node.__user_id; @@ -42,12 +42,24 @@ export class MentionNode extends ElementNode { return true; } + isParentRequired(): boolean { + return true; + } + + decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { + return { + type: 'mention', + getNode: () => this, + }; + } + 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.setAttribute('href', window.baseUrl('/user/' + this.__user_slug)); + element.setAttribute('data-mention-user-id', String(this.__user_id)); element.textContent = '@' + this.__user_name; + // element.setAttribute('contenteditable', 'false'); return element; } @@ -55,21 +67,30 @@ export class MentionNode extends ElementNode { 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 { - if (node.hasAttribute('data-user-mention-id')) { + if (node.hasAttribute('data-mention-user-id')) { return { conversion: (element: HTMLElement): DOMConversionOutput|null => { const node = new MentionNode(); node.setUserDetails( - Number(element.getAttribute('data-user-mention-id') || '0'), + Number(element.getAttribute('data-mention-user-id') || '0'), element.innerText.replace(/^@/, ''), element.getAttribute('href')?.split('/user/')[1] || '' ); return { node, + after(childNodes): LexicalNode[] { + return []; + } }; }, priority: 4, @@ -82,7 +103,6 @@ export class MentionNode extends ElementNode { exportJSON(): SerializedMentionNode { return { - ...super.exportJSON(), type: 'mention', version: 1, user_id: this.__user_id, diff --git a/resources/js/wysiwyg/services/mentions.ts b/resources/js/wysiwyg/services/mentions.ts index e41457b8a29..87a1f4b8b13 100644 --- a/resources/js/wysiwyg/services/mentions.ts +++ b/resources/js/wysiwyg/services/mentions.ts @@ -1,14 +1,11 @@ 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"; +import {MentionDecorator} from "../ui/decorators/MentionDecorator"; function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) { @@ -32,128 +29,15 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection 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 mentionDecorator = context.manager.getDecoratorByNodeKey(mention.getKey()); + if (mentionDecorator instanceof MentionDecorator) { + mentionDecorator.showSelection() } - - 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; diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts similarity index 84% rename from resources/js/wysiwyg/ui/decorators/code-block.ts rename to resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts index daae32e1982..d95185e0b79 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts @@ -14,7 +14,7 @@ export class CodeBlockDecorator extends EditorDecorator { // @ts-ignore protected editor: any = null; - setup(context: EditorUiContext, element: HTMLElement) { + setup(element: HTMLElement) { const codeNode = this.getNode() as CodeBlockNode; const preEl = element.querySelector('pre'); if (!preEl) { @@ -35,24 +35,24 @@ export class CodeBlockDecorator extends EditorDecorator { element.addEventListener('click', event => { requestAnimationFrame(() => { - context.editor.update(() => { + this.context.editor.update(() => { $selectSingleNode(this.getNode()); }); }); }); element.addEventListener('dblclick', event => { - context.editor.getEditorState().read(() => { - $openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode)); + this.context.editor.getEditorState().read(() => { + $openCodeEditorForNode(this.context.editor, (this.getNode() as CodeBlockNode)); }); }); const selectionChange = (selection: BaseSelection|null): void => { element.classList.toggle('selected', $selectionContainsNode(selection, codeNode)); }; - context.manager.onSelectionChange(selectionChange); + this.context.manager.onSelectionChange(selectionChange); this.onDestroy(() => { - context.manager.offSelectionChange(selectionChange); + this.context.manager.offSelectionChange(selectionChange); }); // @ts-ignore @@ -89,11 +89,11 @@ export class CodeBlockDecorator extends EditorDecorator { } } - render(context: EditorUiContext, element: HTMLElement): void { + render(element: HTMLElement): void { if (this.completedSetup) { this.update(); } else { - this.setup(context, element); + this.setup(element); } } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts similarity index 70% rename from resources/js/wysiwyg/ui/decorators/diagram.ts rename to resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts index 52a73ad7223..e46dcc312ad 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts @@ -9,33 +9,33 @@ import {$openDrawingEditorForNode} from "../../utils/diagrams"; export class DiagramDecorator extends EditorDecorator { protected completedSetup: boolean = false; - setup(context: EditorUiContext, element: HTMLElement) { + setup(element: HTMLElement) { const diagramNode = this.getNode(); element.classList.add('editor-diagram'); - context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => { + this.context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => { if (!element.contains(event.target as HTMLElement)) { return false; } - context.editor.update(() => { + this.context.editor.update(() => { $selectSingleNode(this.getNode()); }); return true; }, COMMAND_PRIORITY_NORMAL); element.addEventListener('dblclick', event => { - context.editor.getEditorState().read(() => { - $openDrawingEditorForNode(context, (this.getNode() as DiagramNode)); + this.context.editor.getEditorState().read(() => { + $openDrawingEditorForNode(this.context, (this.getNode() as DiagramNode)); }); }); const selectionChange = (selection: BaseSelection|null): void => { element.classList.toggle('selected', $selectionContainsNode(selection, diagramNode)); }; - context.manager.onSelectionChange(selectionChange); + this.context.manager.onSelectionChange(selectionChange); this.onDestroy(() => { - context.manager.offSelectionChange(selectionChange); + this.context.manager.offSelectionChange(selectionChange); }); this.completedSetup = true; @@ -45,11 +45,11 @@ export class DiagramDecorator extends EditorDecorator { // } - render(context: EditorUiContext, element: HTMLElement): void { + render(element: HTMLElement): void { if (this.completedSetup) { this.update(); } else { - this.setup(context, element); + this.setup(element); } } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts new file mode 100644 index 00000000000..df2d0a227bb --- /dev/null +++ b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts @@ -0,0 +1,172 @@ +import {EditorDecorator} from "../framework/decorator"; +import {EditorUiContext} from "../framework/core"; +import {el, htmlToDom} from "../../utils/dom"; +import {showLoading} from "../../../services/dom"; +import {MentionNode} from "@lexical/link/LexicalMentionNode"; +import {debounce} from "../../../services/util"; +import {$createTextNode} from "lexical"; + +function userClickHandler(onSelect: (id: number, name: string, slug: string)=>void): (event: PointerEvent) => void { + return (event: PointerEvent) => { + const userItem = (event.target as HTMLElement).closest('a[data-id]') as HTMLAnchorElement | null; + if (!userItem) { + return; + } + + const id = Number(userItem.dataset.id || '0'); + const name = userItem.dataset.name || ''; + const slug = userItem.dataset.slug || ''; + + onSelect(id, name, slug); + event.preventDefault(); + }; +} + +function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, controller: AbortController, onCancel: () => void): void { + 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 class MentionDecorator extends EditorDecorator { + protected completedSetup: boolean = false; + protected abortController: AbortController | null = null; + protected selectList: HTMLElement | null = null; + protected mentionElement: HTMLElement | null = null; + + setup(element: HTMLElement) { + this.mentionElement = element; + this.completedSetup = true; + } + + showSelection() { + if (!this.mentionElement) { + return; + } + + this.hideSelection(); + this.abortController = new AbortController(); + + this.selectList = buildAndShowUserSelectorAtElement(this.context, this.mentionElement); + handleUserListLoading(this.selectList); + + this.selectList.addEventListener('click', userClickHandler((id, name, slug) => { + this.context.editor.update(() => { + const mentionNode = this.getNode() as MentionNode; + this.hideSelection(); + mentionNode.setUserDetails(id, name, slug); + mentionNode.selectNext(); + }); + }), {signal: this.abortController.signal}); + + handleUserSelectCancel(this.context, this.selectList, this.abortController, this.revertMention.bind(this)); + } + + hideSelection() { + this.abortController?.abort(); + this.selectList?.remove(); + } + + revertMention() { + this.hideSelection(); + this.context.editor.update(() => { + const text = $createTextNode('@'); + this.getNode().replace(text); + text.selectEnd(); + }); + } + + update() { + // + } + + render(element: HTMLElement): void { + if (this.completedSetup) { + this.update(); + } else { + this.setup(element); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts index 6ea0b8b3934..2f46a19ef82 100644 --- a/resources/js/wysiwyg/ui/framework/decorator.ts +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -42,7 +42,7 @@ export abstract class EditorDecorator { * If an element is returned, this will be appended to the element * that is being decorated. */ - abstract render(context: EditorUiContext, decorated: HTMLElement): HTMLElement|void; + abstract render(decorated: HTMLElement): HTMLElement|void; /** * Destroy this decorator. Used for tear-down operations upon destruction diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 1adc0b619de..cbe3cca194c 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -90,7 +90,7 @@ export class EditorUIManager { } // @ts-ignore - const decorator = new decoratorClass(nodeKey); + const decorator = new decoratorClass(this.getContext()); this.decoratorInstancesByNodeKey[nodeKey] = decorator; return decorator; } @@ -262,7 +262,7 @@ export class EditorUIManager { const adapter = decorators[key]; const decorator = this.getDecorator(adapter.type, key); decorator.setNode(adapter.getNode()); - const decoratorEl = decorator.render(this.getContext(), decoratedEl); + const decoratorEl = decorator.render(decoratedEl); if (decoratorEl) { decoratedEl.append(decoratorEl); } diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index aba1556a983..98548609ef2 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -198,4 +198,23 @@ body .page-content img, color: inherit; text-decoration: underline; } +} + +a[data-mention-user-id] { + display: inline-block; + position: relative; + color: var(--color-link); + padding: 0.1em 0.2em; + &:after { + content: ''; + background-color: currentColor; + opacity: 0.2; + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + display: block; + border-radius: 0.2em; + } } \ No newline at end of file From 147ff00c7a5f326ae4a321fe21d89873d27b0d0a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 14 Dec 2025 16:25:19 +0000 Subject: [PATCH 04/11] JS: Swapped livereload lib for esbuild livereload setup --- dev/build/esbuild.js | 50 ++++++++++++++++++++++++++------ dev/build/livereload.js | 35 ++++++++++++++++++++++ package-lock.json | 64 ----------------------------------------- package.json | 7 ++--- 4 files changed, 79 insertions(+), 77 deletions(-) create mode 100644 dev/build/livereload.js diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js index 63387d612ce..cb9abc24628 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.js @@ -1,12 +1,16 @@ #!/usr/bin/env node -const esbuild = require('esbuild'); -const path = require('path'); -const fs = require('fs'); +import * as esbuild from 'esbuild'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as process from "node:process"; + // Check if we're building for production // (Set via passing `production` as first argument) -const isProd = process.argv[2] === 'production'; +const mode = process.argv[2]; +const isProd = mode === 'production'; +const __dirname = import.meta.dirname; // Gather our input files const entryPoints = { @@ -17,11 +21,16 @@ const entryPoints = { wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'), }; +// Watch styles so we can reload on change +if (mode === 'watch') { + entryPoints['styles-dummy'] = path.join(__dirname, '../../public/dist/styles.css'); +} + // Locate our output directory const outdir = path.join(__dirname, '../../public/dist'); -// Build via esbuild -esbuild.build({ +// Define the options for esbuild +const options = { bundle: true, metafile: true, entryPoints, @@ -33,6 +42,7 @@ esbuild.build({ minify: isProd, logLevel: 'info', loader: { + '.html': 'copy', '.svg': 'text', }, absWorkingDir: path.join(__dirname, '../..'), @@ -45,6 +55,28 @@ esbuild.build({ js: '// See the "/licenses" URI for full package license details', css: '/* See the "/licenses" URI for full package license details */', }, -}).then(result => { - fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile)); -}).catch(() => process.exit(1)); +}; + +if (mode === 'watch') { + options.inject = [ + path.join(__dirname, './livereload.js'), + ]; +} + +const ctx = await esbuild.context(options); + +if (mode === 'watch') { + // Watch for changes and rebuild on change + ctx.watch({}); + let {hosts, port} = await ctx.serve({ + servedir: path.join(__dirname, '../../public'), + cors: { + origin: '*', + } + }); +} else { + // Build with meta output for analysis + ctx.rebuild().then(result => { + fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile)); + }).catch(() => process.exit(1)); +} diff --git a/dev/build/livereload.js b/dev/build/livereload.js new file mode 100644 index 00000000000..b4bf38e6da0 --- /dev/null +++ b/dev/build/livereload.js @@ -0,0 +1,35 @@ +if (!window.__dev_reload_listening) { + listen(); + window.__dev_reload_listening = true; +} + + +function listen() { + console.log('Listening for livereload events...'); + new EventSource("http://127.0.0.1:8000/esbuild").addEventListener('change', e => { + const { added, removed, updated } = JSON.parse(e.data); + + if (!added.length && !removed.length && updated.length > 0) { + const updatedPath = updated.filter(path => path.endsWith('.css'))[0] + if (!updatedPath) return; + + const links = [...document.querySelectorAll("link[rel='stylesheet']")]; + for (const link of links) { + const url = new URL(link.href); + const name = updatedPath.replace('-dummy', ''); + + if (url.pathname.endsWith(name)) { + const next = link.cloneNode(); + next.href = name + '?' + Math.random().toString(36).slice(2); + next.onload = function() { + link.remove(); + }; + link.after(next); + return + } + } + } + + location.reload() + }); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b85d1f5e28f..e8a1493d42f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "eslint-plugin-import": "^2.32.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", - "livereload": "^0.10.3", "npm-run-all": "^4.1.5", "sass": "^1.94.2", "ts-jest": "^29.4.5", @@ -7027,62 +7026,6 @@ "uc.micro": "^2.0.0" } }, - "node_modules/livereload": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.10.3.tgz", - "integrity": "sha512-llSb8HrtSH7ByPFMc8WTTeW3oy++smwgSA8JVGzEn8KiDPESq6jt1M4ZKKkhKTrhn2wvUOadQq4ip10E5daZ3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.3", - "livereload-js": "^4.0.2", - "opts": "^2.0.2", - "ws": "^8.4.3" - }, - "bin": { - "livereload": "bin/livereload.js" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/livereload-js": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-4.0.2.tgz", - "integrity": "sha512-Fy7VwgQNiOkynYyNBTo3v9hQUhcW5pFAheJN148+DTgpShjsy/22pLHKKwDK5v0kOsZsJBK+6q1PMgLvRmrwFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/livereload/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/livereload/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -7771,13 +7714,6 @@ "node": ">= 0.8.0" } }, - "node_modules/opts": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz", - "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", diff --git a/package.json b/package.json index 964e3ee7ec8..1ae14e976ac 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "private": true, + "type": "module", "scripts": { "build:css:dev": "sass ./resources/sass:./public/dist --embed-sources", "build:css:watch": "sass ./resources/sass:./public/dist --watch --embed-sources", "build:css:production": "sass ./resources/sass:./public/dist -s compressed", "build:js:dev": "node dev/build/esbuild.js", - "build:js:watch": "chokidar --initial \"./resources/**/*.js\" \"./resources/**/*.mjs\" \"./resources/**/*.ts\" -c \"npm run build:js:dev\"", + "build:js:watch": "node dev/build/esbuild.js watch", "build:js:production": "node dev/build/esbuild.js production", "build": "npm-run-all --parallel build:*:dev", "production": "npm-run-all --parallel build:*:production", - "dev": "npm-run-all --parallel watch livereload", + "dev": "npm-run-all --parallel build:*:watch", "watch": "npm-run-all --parallel build:*:watch", - "livereload": "livereload ./public/dist/", "permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads", "lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"", "fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"", @@ -29,7 +29,6 @@ "eslint-plugin-import": "^2.32.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", - "livereload": "^0.10.3", "npm-run-all": "^4.1.5", "sass": "^1.94.2", "ts-jest": "^29.4.5", From e2f91c2bbbc3b4de1c8576b7cc64f905314debc7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 14 Dec 2025 17:19:08 +0000 Subject: [PATCH 05/11] Comment Mentions: Added keyboard nav, worked on design --- dev/build/livereload.js | 2 +- .../lexical/link/LexicalMentionNode.ts | 14 ++-- resources/js/wysiwyg/services/mentions.ts | 23 ++++++- .../wysiwyg/ui/decorators/MentionDecorator.ts | 65 ++++++++++++------- resources/js/wysiwyg/ui/defaults/toolbars.ts | 2 +- resources/sass/_components.scss | 18 ++++- resources/sass/_content.scss | 22 ++++++- .../views/form/user-mention-list.blade.php | 22 +++---- 8 files changed, 116 insertions(+), 52 deletions(-) diff --git a/dev/build/livereload.js b/dev/build/livereload.js index b4bf38e6da0..c2d8ac62007 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 62213a42cbd..9010b3c78da 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 87a1f4b8b13..59fd02b1d67 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 df2d0a227bb..a2786de0013 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 9302e7beda9..d6af996384b 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 8ea15de8044..0f374fb80bf 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 98548609ef2..e77ea633001 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 66971d4ee2b..020cfb35b9d 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 From 221c6c7e9f5f03dd1f5f1cef7ed10e3af4f43e46 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 15 Dec 2025 15:59:43 +0000 Subject: [PATCH 06/11] Comment Mentions: Added core back-end logic - Added new user notification preference, opt-in by default - Added parser to extract mentions from comment HTML, with tests to cover. - Added notification and notification handling Not yet tested, needs testing coverage. --- app/Activity/Models/MentionHistory.php | 19 +++++ .../CommentMentionNotificationHandler.php | 80 +++++++++++++++++++ .../Messages/CommentMentionNotification.php | 37 +++++++++ .../Notifications/NotificationManager.php | 3 + app/Activity/Tools/MentionParser.php | 28 +++++++ app/App/Providers/AppServiceProvider.php | 2 + app/Config/setting-defaults.php | 1 + app/Settings/UserNotificationPreferences.php | 7 +- ...15_140219_create_mention_history_table.php | 31 +++++++ lang/en/notifications.php | 2 + lang/en/preferences.php | 1 + .../users/account/notifications.blade.php | 7 ++ tests/Activity/MentionParserTest.php | 43 ++++++++++ 13 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 app/Activity/Models/MentionHistory.php create mode 100644 app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php create mode 100644 app/Activity/Notifications/Messages/CommentMentionNotification.php create mode 100644 app/Activity/Tools/MentionParser.php create mode 100644 database/migrations/2025_12_15_140219_create_mention_history_table.php create mode 100644 tests/Activity/MentionParserTest.php diff --git a/app/Activity/Models/MentionHistory.php b/app/Activity/Models/MentionHistory.php new file mode 100644 index 00000000000..bfa242df534 --- /dev/null +++ b/app/Activity/Models/MentionHistory.php @@ -0,0 +1,19 @@ +entity; + if (!($detail instanceof Comment) || !($page instanceof Page)) { + throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page"); + } + + $parser = new MentionParser(); + $mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html); + $realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get(); + + $receivingNotifications = $realMentionedUsers->filter(function (User $user) { + $prefs = new UserNotificationPreferences($user); + return $prefs->notifyOnCommentMentions(); + }); + $receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray(); + + $userMentionsToLog = $realMentionedUsers; + + // When an edit, we check our history to see if we've already notified the user about this comment before + // so that we can filter them out to avoid double notifications. + if ($activity->type === ActivityType::COMMENT_UPDATE) { + $previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail); + $receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds)); + $userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) { + return !in_array($user->id, $previouslyNotifiedUserIds); + }); + } + + $this->logMentions($userMentionsToLog, $detail, $user); + $this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page); + } + + protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void + { + $mentions = []; + $now = Carbon::now(); + + foreach ($mentionedUsers as $mentionedUser) { + $mentions[] = [ + 'mentionable_type' => $comment->getMorphClass(), + 'mentionable_id' => $comment->id, + 'from_user_id' => $fromUser->id, + 'to_user_id' => $mentionedUser->id, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + MentionHistory::query()->insert($mentions); + } + + protected function getPreviouslyNotifiedUserIds(Comment $comment): array + { + return MentionHistory::query() + ->where('mentionable_id', $comment->id) + ->where('mentionable_type', $comment->getMorphClass()) + ->pluck('to_user_id') + ->toArray(); + } +} diff --git a/app/Activity/Notifications/Messages/CommentMentionNotification.php b/app/Activity/Notifications/Messages/CommentMentionNotification.php new file mode 100644 index 00000000000..de9e719633d --- /dev/null +++ b/app/Activity/Notifications/Messages/CommentMentionNotification.php @@ -0,0 +1,37 @@ +detail; + /** @var Page $page */ + $page = $comment->entity; + + $locale = $notifiable->getLocale(); + + $listLines = array_filter([ + $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), + $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), + $locale->trans('notifications.detail_commenter') => $this->user->name, + $locale->trans('notifications.detail_comment') => strip_tags($comment->html), + ]); + + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine($listLines)) + ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id)) + ->line($this->buildReasonFooterLine($locale)); + } +} diff --git a/app/Activity/Notifications/NotificationManager.php b/app/Activity/Notifications/NotificationManager.php index 294f56ebbcf..8a6c26ffbed 100644 --- a/app/Activity/Notifications/NotificationManager.php +++ b/app/Activity/Notifications/NotificationManager.php @@ -6,6 +6,7 @@ use BookStack\Activity\Models\Activity; use BookStack\Activity\Models\Loggable; use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler; +use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler; use BookStack\Activity\Notifications\Handlers\NotificationHandler; use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler; use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler; @@ -48,5 +49,7 @@ public function loadDefaultHandlers(): void $this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class); $this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class); $this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class); } } diff --git a/app/Activity/Tools/MentionParser.php b/app/Activity/Tools/MentionParser.php new file mode 100644 index 00000000000..d7bcac5e640 --- /dev/null +++ b/app/Activity/Tools/MentionParser.php @@ -0,0 +1,28 @@ +queryXPath('//a[@data-mention-user-id]'); + + foreach ($mentionLinks as $link) { + if ($link instanceof DOMElement) { + $id = intval($link->getAttribute('data-mention-user-id')); + if ($id > 0) { + $ids[] = $id; + } + } + } + + return array_values(array_unique($ids)); + } +} diff --git a/app/App/Providers/AppServiceProvider.php b/app/App/Providers/AppServiceProvider.php index 9012a07ebf4..debba79446e 100644 --- a/app/App/Providers/AppServiceProvider.php +++ b/app/App/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace BookStack\App\Providers; use BookStack\Access\SocialDriverManager; +use BookStack\Activity\Models\Comment; use BookStack\Activity\Tools\ActivityLogger; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; @@ -73,6 +74,7 @@ public function boot(): void 'book' => Book::class, 'chapter' => Chapter::class, 'page' => Page::class, + 'comment' => Comment::class, ]); } } diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php index 88c4612ca61..2f270b283a2 100644 --- a/app/Config/setting-defaults.php +++ b/app/Config/setting-defaults.php @@ -41,6 +41,7 @@ 'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'), 'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'), 'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'), + 'notifications#comment-mentions' => true, ], ]; diff --git a/app/Settings/UserNotificationPreferences.php b/app/Settings/UserNotificationPreferences.php index 5b267b533cb..752d92de64d 100644 --- a/app/Settings/UserNotificationPreferences.php +++ b/app/Settings/UserNotificationPreferences.php @@ -26,9 +26,14 @@ public function notifyOnCommentReplies(): bool return $this->getNotificationSetting('comment-replies'); } + public function notifyOnCommentMentions(): bool + { + return $this->getNotificationSetting('comment-mentions'); + } + public function updateFromSettingsArray(array $settings) { - $allowList = ['own-page-changes', 'own-page-comments', 'comment-replies']; + $allowList = ['own-page-changes', 'own-page-comments', 'comment-replies', 'comment-mentions']; foreach ($settings as $setting => $status) { if (!in_array($setting, $allowList)) { continue; diff --git a/database/migrations/2025_12_15_140219_create_mention_history_table.php b/database/migrations/2025_12_15_140219_create_mention_history_table.php new file mode 100644 index 00000000000..2ab522dd8c7 --- /dev/null +++ b/database/migrations/2025_12_15_140219_create_mention_history_table.php @@ -0,0 +1,31 @@ +increments('id'); + $table->string('mentionable_type', 50)->index(); + $table->unsignedBigInteger('mentionable_id')->index(); + $table->unsignedInteger('from_user_id')->index(); + $table->unsignedInteger('to_user_id'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('mention_history'); + } +}; diff --git a/lang/en/notifications.php b/lang/en/notifications.php index 1afd23f1dc4..9d6d9457429 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -11,6 +11,8 @@ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You were mentioned in a comment on :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/en/preferences.php b/lang/en/preferences.php index 2872f5f3c65..f4459d738e4 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -23,6 +23,7 @@ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/resources/views/users/account/notifications.blade.php b/resources/views/users/account/notifications.blade.php index b3b082bd7e3..c61cf4af8f7 100644 --- a/resources/views/users/account/notifications.blade.php +++ b/resources/views/users/account/notifications.blade.php @@ -33,6 +33,13 @@ 'label' => trans('preferences.notifications_opt_comment_replies'), ]) +
    + @include('form.toggle-switch', [ + 'name' => 'preferences[comment-mentions]', + 'value' => $preferences->notifyOnCommentMentions(), + 'label' => trans('preferences.notifications_opt_comment_mentions'), + ]) +
    @endif diff --git a/tests/Activity/MentionParserTest.php b/tests/Activity/MentionParserTest.php new file mode 100644 index 00000000000..08bfc10d2a8 --- /dev/null +++ b/tests/Activity/MentionParserTest.php @@ -0,0 +1,43 @@ +Hello @User

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([5], $result); + + // Test multiple mentions + $html = '

    @Alice and @Bob

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([1, 2], $result); + + // Test filtering out invalid IDs (zero and negative) + $html = '

    @Invalid @Negative @Valid

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([3], $result); + + // Test non-mention links are ignored + $html = '

    Normal Link @User

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([7], $result); + + // Test empty HTML + $result = $parser->parseUserIdsFromHtml(''); + $this->assertEquals([], $result); + + // Test duplicate user IDs + $html = '

    @User mentioned @User again

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([4], $result); + } +} From 48cdaab690bbe1cd91436b5aa0fc281ce9a9d8bd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Dec 2025 10:49:12 +0000 Subject: [PATCH 07/11] Comment Mentions: Added tests to cover back-end functionality --- app/Activity/Models/MentionHistory.php | 1 + lang/en/notifications.php | 2 +- .../CommentDisplayTest.php | 4 +- tests/Activity/CommentMentionTest.php | 128 ++++++++++++++++++ .../CommentSettingTest.php | 2 +- .../{Entity => Activity}/CommentStoreTest.php | 3 +- tests/User/UserMyAccountTest.php | 8 ++ 7 files changed, 141 insertions(+), 7 deletions(-) rename tests/{Entity => Activity}/CommentDisplayTest.php (98%) create mode 100644 tests/Activity/CommentMentionTest.php rename tests/{Entity => Activity}/CommentSettingTest.php (96%) rename tests/{Entity => Activity}/CommentStoreTest.php (99%) diff --git a/app/Activity/Models/MentionHistory.php b/app/Activity/Models/MentionHistory.php index bfa242df534..7386a4d742b 100644 --- a/app/Activity/Models/MentionHistory.php +++ b/app/Activity/Models/MentionHistory.php @@ -16,4 +16,5 @@ */ class MentionHistory extends Model { + protected $table = 'mention_history'; } diff --git a/lang/en/notifications.php b/lang/en/notifications.php index 9d6d9457429..563ac24e84d 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -11,7 +11,7 @@ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', - 'comment_mention_subject' => 'You were mentioned in a comment on :pageName', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', diff --git a/tests/Entity/CommentDisplayTest.php b/tests/Activity/CommentDisplayTest.php similarity index 98% rename from tests/Entity/CommentDisplayTest.php rename to tests/Activity/CommentDisplayTest.php index 80664890a7e..01a4643be82 100644 --- a/tests/Entity/CommentDisplayTest.php +++ b/tests/Activity/CommentDisplayTest.php @@ -1,10 +1,8 @@ users->viewer(); + $this->permissions->grantUserRolePermissions($userToMention, [Permission::ReceiveNotifications]); + $editor = $this->users->editor(); + $page = $this->entities->pageWithinChapter(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

    Hello @user

    ' + ])->assertOk(); + + $notifications->assertSentTo($userToMention, function (CommentMentionNotification $notification) use ($userToMention, $editor, $page) { + $mail = $notification->toMail($userToMention); + $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES); + return $mail->subject === 'You have been mentioned in a comment on page: ' . $page->name + && str_contains($mailContent, 'View Comment') + && str_contains($mailContent, 'Page Name: ' . $page->name) + && str_contains($mailContent, 'Page Path: ' . $page->book->getShortName(24) . ' > ' . $page->chapter->getShortName(24)) + && str_contains($mailContent, 'Commenter: ' . $editor->name) + && str_contains($mailContent, 'Comment: Hello @user'); + }); + } + + public function test_mentions_are_not_notified_if_mentioned_by_same_user() + { + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, [Permission::ReceiveNotifications]); + $page = $this->entities->page(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

    Hello

    ' + ])->assertOk(); + + $notifications->assertNothingSent(); + } + + public function test_mentions_are_logged_to_the_database_even_if_not_notified() + { + $editor = $this->users->editor(); + $otherUser = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($editor, [Permission::ReceiveNotifications]); + $page = $this->entities->page(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

    Hello and

    ' + ])->assertOk(); + + $notifications->assertNothingSent(); + + $comment = $page->comments()->latest()->first(); + $this->assertDatabaseHas('mention_history', [ + 'mentionable_id' => $comment->id, + 'mentionable_type' => 'comment', + 'from_user_id' => $editor->id, + 'to_user_id' => $otherUser->id, + ]); + $this->assertDatabaseHas('mention_history', [ + 'mentionable_id' => $comment->id, + 'mentionable_type' => 'comment', + 'from_user_id' => $editor->id, + 'to_user_id' => $editor->id, + ]); + } + + public function test_comment_updates_will_send_notifications_only_if_mention_is_new() + { + $userToMention = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($userToMention, [Permission::ReceiveNotifications]); + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, [Permission::CommentUpdateOwn]); + $page = $this->entities->page(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

    Hello there

    ' + ])->assertOk(); + $comment = $page->comments()->latest()->first(); + + $notifications->assertNothingSent(); + + $this->put("/comment/{$comment->id}", [ + 'html' => '

    Hello

    ' + ])->assertOk(); + + $notifications->assertSentTo($userToMention, CommentMentionNotification::class); + $notifications->assertCount(1); + + $this->put("/comment/{$comment->id}", [ + 'html' => '

    Hello again

    ' + ])->assertOk(); + + $notifications->assertCount(1); + } + + public function test_notification_limited_to_those_with_view_permissions() + { + $userA = $this->users->newUser(); + $userB = $this->users->newUser(); + $this->permissions->grantUserRolePermissions($userA, [Permission::ReceiveNotifications]); + $this->permissions->grantUserRolePermissions($userB, [Permission::ReceiveNotifications]); + $notifications = Notification::fake(); + $page = $this->entities->page(); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->addEntityPermission($page, ['view'], $userA->roles()->first()); + + $this->asAdmin()->post("/comment/{$page->id}", [ + 'html' => '

    Hello and

    ' + ])->assertOk(); + + $notifications->assertCount(1); + $notifications->assertSentTo($userA, CommentMentionNotification::class); + } +} diff --git a/tests/Entity/CommentSettingTest.php b/tests/Activity/CommentSettingTest.php similarity index 96% rename from tests/Entity/CommentSettingTest.php rename to tests/Activity/CommentSettingTest.php index 7de4574414f..ad82d9b704e 100644 --- a/tests/Entity/CommentSettingTest.php +++ b/tests/Activity/CommentSettingTest.php @@ -1,6 +1,6 @@ asEditor()->get('/my-account/notifications'); $resp->assertSee('Notify upon comments'); $resp->assertSee('Notify upon replies'); + $resp->assertSee('Notify when I\'m mentioned in a comment'); setting()->put('app-disable-comments', true); $resp = $this->get('/my-account/notifications'); $resp->assertDontSee('Notify upon comments'); $resp->assertDontSee('Notify upon replies'); + $resp->assertDontSee('Notify when I\'m mentioned in a comment'); + } + + public function test_notification_comment_mention_option_enabled_by_default() + { + $resp = $this->asEditor()->get('/my-account/notifications'); + $this->withHtml($resp)->assertElementExists('input[name="preferences[comment-mentions]"][value="true"]'); } } From 3d9aba7b1f3185345ac58a51127ac32e8b669a50 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Dec 2025 13:48:20 +0000 Subject: [PATCH 08/11] Mentions: Added coverage for mentions search endpoint --- .../CommentMentionNotificationHandler.php | 9 ++- tests/User/UserSearchTest.php | 67 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php b/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php index f7c833aeedb..50c8cb8ab59 100644 --- a/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php @@ -19,11 +19,13 @@ class CommentMentionNotificationHandler extends BaseNotificationHandler { public function handle(Activity $activity, Loggable|string $detail, User $user): void { - $page = $detail->entity; - if (!($detail instanceof Comment) || !($page instanceof Page)) { + if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) { throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page"); } + /** @var Page $page */ + $page = $detail->entity; + $parser = new MentionParser(); $mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html); $realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get(); @@ -50,6 +52,9 @@ public function handle(Activity $activity, Loggable|string $detail, User $user): $this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page); } + /** + * @param Collection $mentionedUsers + */ protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void { $mentions = []; diff --git a/tests/User/UserSearchTest.php b/tests/User/UserSearchTest.php index 76efbf4af9b..25c7c6ee928 100644 --- a/tests/User/UserSearchTest.php +++ b/tests/User/UserSearchTest.php @@ -2,6 +2,7 @@ namespace Tests\User; +use BookStack\Permissions\Permission; use BookStack\Users\Models\User; use Tests\TestCase; @@ -62,4 +63,70 @@ public function test_select_requires_logged_in_user() $resp = $this->get('/search/users/select?search=a'); $this->assertPermissionError($resp); } + + public function test_mentions_search_matches_by_name() + { + $viewer = $this->users->viewer(); + $editor = $this->users->editor(); + + $resp = $this->actingAs($editor)->get('/search/users/mention?search=' . urlencode($viewer->name)); + + $resp->assertOk(); + $resp->assertSee($viewer->name); + $resp->assertDontSee($editor->name); + } + + public function test_mentions_search_does_not_match_by_email() + { + $viewer = $this->users->viewer(); + + $resp = $this->asEditor()->get('/search/users/mention?search=' . urlencode($viewer->email)); + + $resp->assertDontSee($viewer->name); + } + + public function test_mentions_search_requires_logged_in_user() + { + $this->setSettings(['app-public' => true]); + $guest = $this->users->guest(); + $this->permissions->grantUserRolePermissions($guest, [Permission::CommentCreateAll, Permission::CommentUpdateAll]); + + $resp = $this->get('/search/users/mention?search=a'); + $this->assertPermissionError($resp); + } + + public function test_mentions_search_requires_comment_create_or_update_permission() + { + $viewer = $this->users->viewer(); + $editor = $this->users->editor(); + + $resp = $this->actingAs($viewer)->get('/search/users/mention?search=' . urlencode($editor->name)); + $this->assertPermissionError($resp); + + $this->permissions->grantUserRolePermissions($viewer, [Permission::CommentCreateAll]); + + $resp = $this->actingAs($editor)->get('/search/users/mention?search=' . urlencode($viewer->name)); + $resp->assertOk(); + $resp->assertSee($viewer->name); + + $this->permissions->removeUserRolePermissions($viewer, [Permission::CommentCreateAll]); + $this->permissions->grantUserRolePermissions($viewer, [Permission::CommentUpdateAll]); + + $resp = $this->actingAs($editor)->get('/search/users/mention?search=' . urlencode($viewer->name)); + $resp->assertOk(); + $resp->assertSee($viewer->name); + } + + public function test_mentions_search_shows_first_by_name_without_search() + { + /** @var User $firstUser */ + $firstUser = User::query() + ->orderBy('name', 'asc') + ->first(); + + $resp = $this->asEditor()->get('/search/users/mention'); + + $resp->assertOk(); + $this->withHtml($resp)->assertElementContains('a[data-id]:first-child', $firstUser->name); + } } From 90fc02c57f891145bee681234106f8fd6d357cc1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Dec 2025 21:11:01 +0000 Subject: [PATCH 09/11] Esbuild & Mentions: Updated interaction stability and build system - Updated esbuild system to be module, and fixed build command. - Reverted module use in package.json by default as this impacted test runs/files. - Updated mention user select: - To look better in dark mode. - To not remove text after on select. - To properly revert/restore focus on enter or cancel. --- dev/build/{esbuild.js => esbuild.mjs} | 13 +++++++++---- package.json | 7 +++---- resources/js/wysiwyg/index.ts | 2 -- resources/js/wysiwyg/services/mentions.ts | 8 ++++++-- .../js/wysiwyg/ui/decorators/MentionDecorator.ts | 12 +++++++++++- resources/js/wysiwyg/ui/framework/manager.ts | 8 ++++++++ resources/sass/_components.scss | 1 + 7 files changed, 38 insertions(+), 13 deletions(-) rename dev/build/{esbuild.js => esbuild.mjs} (85%) diff --git a/dev/build/esbuild.js b/dev/build/esbuild.mjs similarity index 85% rename from dev/build/esbuild.js rename to dev/build/esbuild.mjs index cb9abc24628..fa223115750 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.mjs @@ -5,7 +5,6 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import * as process from "node:process"; - // Check if we're building for production // (Set via passing `production` as first argument) const mode = process.argv[2]; @@ -76,7 +75,13 @@ if (mode === 'watch') { }); } else { // Build with meta output for analysis - ctx.rebuild().then(result => { - fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile)); - }).catch(() => process.exit(1)); + const result = await ctx.rebuild(); + const outputs = result.metafile.outputs; + const files = Object.keys(outputs); + for (const file of files) { + const output = outputs[file]; + console.log(`Written: ${file} @ ${Math.round(output.bytes / 1000)}kB`); + } + fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile)); + process.exit(0); } diff --git a/package.json b/package.json index 1ae14e976ac..624ff876a3a 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,12 @@ { "private": true, - "type": "module", "scripts": { "build:css:dev": "sass ./resources/sass:./public/dist --embed-sources", "build:css:watch": "sass ./resources/sass:./public/dist --watch --embed-sources", "build:css:production": "sass ./resources/sass:./public/dist -s compressed", - "build:js:dev": "node dev/build/esbuild.js", - "build:js:watch": "node dev/build/esbuild.js watch", - "build:js:production": "node dev/build/esbuild.js production", + "build:js:dev": "node dev/build/esbuild.mjs", + "build:js:watch": "node dev/build/esbuild.mjs watch", + "build:js:production": "node dev/build/esbuild.mjs production", "build": "npm-run-all --parallel build:*:dev", "production": "npm-run-all --parallel build:*:production", "dev": "npm-run-all --parallel build:*:watch", diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 273657c47e6..01964b066c6 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -151,8 +151,6 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent: theme: theme, }); - // TODO - Dedupe this with the basic editor instance - // Changed elements: namespace, registerMentions, toolbar, public event usage, mentioned decorator const context: EditorUiContext = buildEditorUI(container, editor, options); editor.setRootElement(context.editorDOM); diff --git a/resources/js/wysiwyg/services/mentions.ts b/resources/js/wysiwyg/services/mentions.ts index 59fd02b1d67..ba6739f329f 100644 --- a/resources/js/wysiwyg/services/mentions.ts +++ b/resources/js/wysiwyg/services/mentions.ts @@ -6,6 +6,7 @@ import {KEY_AT_COMMAND} from "lexical/LexicalCommands"; import {$createMentionNode, $isMentionNode, MentionNode} from "@lexical/link/LexicalMentionNode"; import {EditorUiContext} from "../ui/framework/core"; import {MentionDecorator} from "../ui/decorators/MentionDecorator"; +import {$selectSingleNode} from "../utils/selection"; function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) { @@ -25,10 +26,13 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection } const split = textNode.splitText(offset); - const newNode = split[atStart ? 0 : 1]; + const priorTextNode = split[0]; + const afterTextNode = split[atStart ? 0 : 1]; const mention = $createMentionNode(0, '', ''); - newNode.replace(mention); + priorTextNode.insertAfter(mention); + afterTextNode.spliceText(0, 1, '', false); + $selectSingleNode(mention); requestAnimationFrame(() => { const mentionDecorator = context.manager.getDecoratorByNodeKey(mention.getKey()); diff --git a/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts index a2786de0013..84d66488589 100644 --- a/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts +++ b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts @@ -170,14 +170,24 @@ export class MentionDecorator extends EditorDecorator { this.dropdownContainer?.remove(); this.abortController = null; this.dropdownContainer = null; + this.context.manager.focus(); } revertMention() { this.hideSelection(); this.context.editor.update(() => { const text = $createTextNode('@'); + const before = this.getNode().getPreviousSibling(); this.getNode().replace(text); - text.selectEnd(); + requestAnimationFrame(() => { + this.context.editor.update(() => { + if (text.isAttached()) { + text.selectEnd(); + } else if (before?.isAttached()) { + before?.selectEnd(); + } + }); + }); }); } diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index cbe3cca194c..78d0cc9a27a 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -206,6 +206,14 @@ export class EditorUIManager { } } + /** + * Set the UI focus to the editor. + */ + focus(): void { + this.getContext().editorDOM.focus(); + this.getContext().editor.focus(); + } + protected updateContextToolbars(update: EditorUiStateUpdate): void { for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { const toolbar = this.activeContextToolbars[i]; diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 0f374fb80bf..8608427d8e8 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -985,6 +985,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { font-size: 0.8rem; &:hover,&:focus { background-color: #F2F2F2; + @include mixins.lightDark(background-color, #F2F2F2, #444); text-decoration: none; } } From 51f9b63db0b9c815d1ea06a2c3c62424b0976dec Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 18 Dec 2025 17:15:29 +0000 Subject: [PATCH 10/11] Comment Mentions: Fixed and tweaks during review of changes - Added advisory on role permission form to advise which allow listing of users/roles. - Updated database config to avoid PHP8.5 deprecation. - Tweaked migration to remove unused index. - Fixed test namespace. --- app/Config/database.php | 2 +- ...5_12_15_140219_create_mention_history_table.php | 2 +- lang/en/settings.php | 2 ++ .../views/settings/roles/parts/form.blade.php | 14 ++++++++++---- .../parts/related-asset-permissions-row.blade.php | 5 ++++- tests/Activity/CommentDisplayTest.php | 2 +- 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/Config/database.php b/app/Config/database.php index 5edafa777c8..86bae5f5b63 100644 --- a/app/Config/database.php +++ b/app/Config/database.php @@ -81,7 +81,7 @@ 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/database/migrations/2025_12_15_140219_create_mention_history_table.php b/database/migrations/2025_12_15_140219_create_mention_history_table.php index 2ab522dd8c7..ff7b6a98139 100644 --- a/database/migrations/2025_12_15_140219_create_mention_history_table.php +++ b/database/migrations/2025_12_15_140219_create_mention_history_table.php @@ -15,7 +15,7 @@ public function up(): void $table->increments('id'); $table->string('mentionable_type', 50)->index(); $table->unsignedBigInteger('mentionable_id')->index(); - $table->unsignedInteger('from_user_id')->index(); + $table->unsignedInteger('from_user_id'); $table->unsignedInteger('to_user_id'); $table->timestamps(); }); diff --git a/lang/en/settings.php b/lang/en/settings.php index 1b5f9ce144c..c68605fe1f8 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -197,11 +197,13 @@ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index a77b80e4c69..5a9eca7d2cd 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -30,10 +30,10 @@ {{ trans('common.toggle_all') }} -
    +
    -
    @include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])
    -
    @include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])
    +
    @include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')]) 1
    +
    @include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')]) 1
    @include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])
    @include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])
    @include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])
    @@ -48,6 +48,10 @@

    {{ trans('settings.roles_system_warning') }}

    + +

    + 1 {{ trans('settings.role_permission_note_users_and_roles') }} +

    @@ -75,7 +79,7 @@ class="item-list toggle-switch-list"> @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.books'), 'permissionPrefix' => 'book']) @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.chapters'), 'permissionPrefix' => 'chapter']) @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.pages'), 'permissionPrefix' => 'page']) - @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image', 'refMark' => '1']) + @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.attachments'), 'permissionPrefix' => 'attachment']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.comments'), 'permissionPrefix' => 'comment'])
    @@ -83,6 +87,8 @@ class="item-list toggle-switch-list">

    1 {{ trans('settings.role_asset_image_view_note') }} +
    + 2 {{ trans('settings.role_asset_users_note') }}

    diff --git a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php index 62fdd6b744a..b9bf5427f83 100644 --- a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php @@ -6,16 +6,19 @@
    {{ trans('common.create') }}
    @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => '']) + @if($permissionPrefix === 'comment')2@endif
    {{ trans('common.view') }}
    - {{ trans('settings.role_controlled_by_asset') }}@if($refMark ?? false){{ $refMark }}@endif + {{ trans('settings.role_controlled_by_asset') }}@if($permissionPrefix === 'image')1@endif
    {{ trans('common.edit') }}
    @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')]) + @if($permissionPrefix === 'comment')2@endif
    @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')]) + @if($permissionPrefix === 'comment')2@endif
    {{ trans('common.delete') }}
    diff --git a/tests/Activity/CommentDisplayTest.php b/tests/Activity/CommentDisplayTest.php index 01a4643be82..798ea16eef4 100644 --- a/tests/Activity/CommentDisplayTest.php +++ b/tests/Activity/CommentDisplayTest.php @@ -1,6 +1,6 @@ Date: Thu, 18 Dec 2025 17:40:05 +0000 Subject: [PATCH 11/11] Comment mentions: Fixed CI and test scenarios --- app/Config/database.php | 1 + tests/Activity/CommentMentionTest.php | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Config/database.php b/app/Config/database.php index 86bae5f5b63..6fc861312d4 100644 --- a/app/Config/database.php +++ b/app/Config/database.php @@ -81,6 +81,7 @@ 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ + // @phpstan-ignore class.notFound (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/tests/Activity/CommentMentionTest.php b/tests/Activity/CommentMentionTest.php index 57a36895e75..7f26f8689d2 100644 --- a/tests/Activity/CommentMentionTest.php +++ b/tests/Activity/CommentMentionTest.php @@ -24,7 +24,8 @@ public function test_mentions_are_notified() $notifications->assertSentTo($userToMention, function (CommentMentionNotification $notification) use ($userToMention, $editor, $page) { $mail = $notification->toMail($userToMention); $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES); - return $mail->subject === 'You have been mentioned in a comment on page: ' . $page->name + $subjectPrefix = 'You have been mentioned in a comment on page: ' . mb_substr($page->name, 0, 20); + return str_starts_with($mail->subject, $subjectPrefix) && str_contains($mailContent, 'View Comment') && str_contains($mailContent, 'Page Name: ' . $page->name) && str_contains($mailContent, 'Page Path: ' . $page->book->getShortName(24) . ' > ' . $page->chapter->getShortName(24))