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())
+
+ {{ trans('common.no_items') }}
+
+@endif
+@foreach($users as $user)
+
+
+ {{ $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);
+ }
}