diff --git a/app/Activity/Models/MentionHistory.php b/app/Activity/Models/MentionHistory.php new file mode 100644 index 00000000000..7386a4d742b --- /dev/null +++ b/app/Activity/Models/MentionHistory.php @@ -0,0 +1,20 @@ +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(); + + $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); + } + + /** + * @param Collection $mentionedUsers + */ + 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/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/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/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/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..c2d8ac62007 --- /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 + '?version=' + 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/lang/en/notifications.php b/lang/en/notifications.php index 1afd23f1dc4..563ac24e84d 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 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:', '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/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", 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..273657c47e6 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"; @@ -17,11 +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: { @@ -136,6 +143,44 @@ 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, mentioned decorator + const context: EditorUiContext = buildEditorUI(container, editor, options); + editor.setRootElement(context.editorDOM); + + const editorTeardown = mergeRegister( + registerRichText(editor), + registerHistory(editor, createEmptyHistoryState(), 300), + registerShortcuts(context), + registerAutoLinks(editor), + registerMentions(context), + ); + + // Register toolbars, modals & decorators + context.manager.setToolbar(getBasicEditorToolbar(context)); + context.manager.registerContextToolbar('link', contextToolbars.link); + context.manager.registerModal('link', modals.link); + context.manager.onTeardown(editorTeardown); + context.manager.registerDecoratorType('mention', MentionDecorator); + + setEditorContentFromHtml(editor, htmlContent); + + window.$events.emitPublic(container, 'editor-wysiwyg::post-init', { + usage: 'comment-editor', + api: new EditorApi(context), + }); + + return new SimpleWysiwygEditorInterface(context); +} + export class SimpleWysiwygEditorInterface { protected context: EditorUiContext; protected onChangeListeners: (() => void)[] = []; diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts index f995237a0cf..1b378b4a010 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts @@ -78,6 +78,8 @@ export const KEY_ESCAPE_COMMAND: LexicalCommand = createCommand('KEY_ESCAPE_COMMAND'); export const KEY_DELETE_COMMAND: LexicalCommand = createCommand('KEY_DELETE_COMMAND'); +export const KEY_AT_COMMAND: LexicalCommand = + createCommand('KEY_AT_COMMAND'); export const KEY_TAB_COMMAND: LexicalCommand = createCommand('KEY_TAB_COMMAND'); export const INSERT_TAB_COMMAND: LexicalCommand = diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts index 26cf25a800d..2d197ccc27a 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts @@ -67,7 +67,7 @@ import { SELECTION_CHANGE_COMMAND, UNDO_COMMAND, } from '.'; -import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands'; +import {KEY_AT_COMMAND, KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands'; import { COMPOSITION_START_CHAR, DOM_ELEMENT_TYPE, @@ -97,7 +97,7 @@ import { getEditorPropertyFromDOMNode, getEditorsToPropagate, getNearestEditorFromDOMNode, - getWindow, + getWindow, isAt, isBackspace, isBold, isCopy, @@ -1062,6 +1062,8 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void { } else if (isDeleteLineForward(key, metaKey)) { event.preventDefault(); dispatchCommand(editor, DELETE_LINE_COMMAND, false); + } else if (isAt(key)) { + dispatchCommand(editor, KEY_AT_COMMAND, event); } else if (isBold(key, altKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold'); diff --git a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts index 71096b19dce..b0bf2f180bc 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts @@ -1056,6 +1056,10 @@ export function isDelete(key: string): boolean { return key === 'Delete'; } +export function isAt(key: string): boolean { + return key === '@'; +} + export function isSelectAll( key: string, metaKey: boolean, diff --git a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts new file mode 100644 index 00000000000..9010b3c78da --- /dev/null +++ b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts @@ -0,0 +1,125 @@ +import { + DecoratorNode, + DOMConversion, + DOMConversionMap, DOMConversionOutput, + type EditorConfig, + LexicalEditor, LexicalNode, + SerializedLexicalNode, + Spread +} from "lexical"; +import {EditorDecoratorAdapter} from "../../ui/framework/decorator"; + +export type SerializedMentionNode = Spread<{ + user_id: number; + user_name: string; + user_slug: string; +}, SerializedLexicalNode> + +export class MentionNode extends DecoratorNode { + __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; + } + + hasUserSet(): boolean { + return this.__user_id > 0; + } + + isInline(): boolean { + 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('/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; + 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-mention-user-id')) { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + const node = new MentionNode(); + node.setUserDetails( + 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, + }; + } + return null; + }, + }; + } + + exportJSON(): SerializedMentionNode { + return { + 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..59fd02b1d67 --- /dev/null +++ b/resources/js/wysiwyg/services/mentions.ts @@ -0,0 +1,78 @@ +import { + $getSelection, $isRangeSelection, + COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, RangeSelection, TextNode +} from "lexical"; +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"; + + +function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) { + const textNode = selection.getNodes()[0] as TextNode; + const selectionPos = selection.getStartEndPoints(); + if (!selectionPos) { + return; + } + + const offset = selectionPos[0].offset; + + // Ignore if the @ sign is not after a space or the start of the line + const atStart = offset === 0; + const afterSpace = textNode.getTextContent().charAt(offset - 1) === ' '; + if (!atStart && !afterSpace) { + return; + } + + const split = textNode.splitText(offset); + const newNode = split[atStart ? 0 : 1]; + + const mention = $createMentionNode(0, '', ''); + newNode.replace(mention); + + requestAnimationFrame(() => { + const mentionDecorator = context.manager.getDecoratorByNodeKey(mention.getKey()); + if (mentionDecorator instanceof MentionDecorator) { + mentionDecorator.showSelection() + } + }); +} + +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; + + 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); + + 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/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..a2786de0013 --- /dev/null +++ b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts @@ -0,0 +1,187 @@ +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"; +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) => { + 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]) { + child.remove(); + } + + // Fetch new content + let responseHtml = ''; + if (cache.has(searchTerm)) { + responseHtml = cache.get(searchTerm) || ''; + } else { + const loadingWrap = el('div', {class: 'flex-container-row items-center dropdown-search-item'}); + 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.body.children; + for (const listEl of toInsert) { + const adopted = window.document.adoptNode(listEl) as HTMLElement; + selectList.appendChild(adopted); + } + }; + + // Initial load + updateUserList(''); + + const input = selectList.parentElement?.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 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); + + 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 abortController: AbortController | null = null; + protected dropdownContainer: HTMLElement | null = null; + protected mentionElement: HTMLElement | null = null; + + setup(element: HTMLElement) { + this.mentionElement = element; + + element.addEventListener('click', (event: PointerEvent) => { + this.showSelection(); + event.preventDefault(); + event.stopPropagation(); + }); + } + + showSelection() { + if (!this.mentionElement || this.dropdownContainer) { + return; + } + + this.hideSelection(); + this.abortController = new AbortController(); + + this.dropdownContainer = buildAndShowUserSelectorAtElement(this.context, this.mentionElement); + handleUserListLoading(this.dropdownContainer.querySelector('.dropdown-search-list') as HTMLElement); + + this.dropdownContainer.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.dropdownContainer, this.abortController, () => { + if ((this.getNode() as MentionNode).hasUserSet()) { + this.hideSelection() + } else { + this.revertMention(); + } + }); + + new KeyboardNavigationHandler(this.dropdownContainer); + } + + hideSelection() { + this.abortController?.abort(); + this.dropdownContainer?.remove(); + this.abortController = null; + this.dropdownContainer = null; + } + + revertMention() { + this.hideSelection(); + this.context.editor.update(() => { + const text = $createTextNode('@'); + this.getNode().replace(text); + text.selectEnd(); + }); + } + + render(element: HTMLElement): void { + 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/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/_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 aba1556a983..e77ea633001 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -198,4 +198,41 @@ body .page-content img, color: inherit; text-decoration: underline; } +} + +/** + * Mention Links + */ + +a[data-mention-user-id] { + display: inline-block; + position: relative; + color: var(--color-link); + 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; + opacity: 0.2; + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + display: block; + } } \ 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..020cfb35b9d --- /dev/null +++ b/resources/views/form/user-mention-list.blade.php @@ -0,0 +1,14 @@ +@if($users->isEmpty()) + +@endif +@foreach($users as $user) + + {{ $user->name }} + {{ $user->name }} + +@endforeach \ No newline at end of file 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/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']); 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 @@ 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); + } +} diff --git a/tests/User/UserMyAccountTest.php b/tests/User/UserMyAccountTest.php index e1b40daddcf..dbb53b061a9 100644 --- a/tests/User/UserMyAccountTest.php +++ b/tests/User/UserMyAccountTest.php @@ -329,11 +329,19 @@ public function test_notification_comment_options_only_exist_if_comments_active( $resp = $this->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"]'); } } 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); + } }