Skip to content

Commit 9bf9ae9

Browse files
committed
Mentions: Added new endpoint, Built editor list display
1 parent 50540e2 commit 9bf9ae9

File tree

8 files changed

+232
-13
lines changed

8 files changed

+232
-13
lines changed

app/Users/Controllers/UserSearchController.php

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use BookStack\Http\Controller;
66
use BookStack\Permissions\Permission;
77
use BookStack\Users\Models\User;
8+
use Illuminate\Database\Eloquent\Collection;
89
use Illuminate\Http\Request;
910

1011
class UserSearchController extends Controller
@@ -34,8 +35,43 @@ public function forSelect(Request $request)
3435
$query->where('name', 'like', '%' . $search . '%');
3536
}
3637

38+
/** @var Collection<User> $users */
39+
$users = $query->get();
40+
3741
return view('form.user-select-list', [
38-
'users' => $query->get(),
42+
'users' => $users,
43+
]);
44+
}
45+
46+
/**
47+
* Search users in the system, with the response formatted
48+
* for use in a list of mentions.
49+
*/
50+
public function forMentions(Request $request)
51+
{
52+
$hasPermission = !user()->isGuest() && (
53+
userCan(Permission::CommentCreateAll)
54+
|| userCan(Permission::CommentUpdate)
55+
);
56+
57+
if (!$hasPermission) {
58+
$this->showPermissionError();
59+
}
60+
61+
$search = $request->get('search', '');
62+
$query = User::query()
63+
->orderBy('name', 'asc')
64+
->take(20);
65+
66+
if (!empty($search)) {
67+
$query->where('name', 'like', '%' . $search . '%');
68+
}
69+
70+
/** @var Collection<User> $users */
71+
$users = $query->get();
72+
73+
return view('form.user-mention-list', [
74+
'users' => $users,
3975
]);
4076
}
4177
}

resources/js/wysiwyg/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
160160
registerHistory(editor, createEmptyHistoryState(), 300),
161161
registerShortcuts(context),
162162
registerAutoLinks(editor),
163-
registerMentions(editor),
163+
registerMentions(context),
164164
);
165165

166166
// Register toolbars, modals & decorators

resources/js/wysiwyg/lexical/core/LexicalCommands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export const KEY_ESCAPE_COMMAND: LexicalCommand<KeyboardEvent> =
7878
createCommand('KEY_ESCAPE_COMMAND');
7979
export const KEY_DELETE_COMMAND: LexicalCommand<KeyboardEvent> =
8080
createCommand('KEY_DELETE_COMMAND');
81+
export const KEY_AT_COMMAND: LexicalCommand<KeyboardEvent> =
82+
createCommand('KEY_AT_COMMAND');
8183
export const KEY_TAB_COMMAND: LexicalCommand<KeyboardEvent> =
8284
createCommand('KEY_TAB_COMMAND');
8385
export const INSERT_TAB_COMMAND: LexicalCommand<void> =

resources/js/wysiwyg/lexical/core/LexicalEvents.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import {
6767
SELECTION_CHANGE_COMMAND,
6868
UNDO_COMMAND,
6969
} from '.';
70-
import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
70+
import {KEY_AT_COMMAND, KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
7171
import {
7272
COMPOSITION_START_CHAR,
7373
DOM_ELEMENT_TYPE,
@@ -97,7 +97,7 @@ import {
9797
getEditorPropertyFromDOMNode,
9898
getEditorsToPropagate,
9999
getNearestEditorFromDOMNode,
100-
getWindow,
100+
getWindow, isAt,
101101
isBackspace,
102102
isBold,
103103
isCopy,
@@ -1062,6 +1062,8 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
10621062
} else if (isDeleteLineForward(key, metaKey)) {
10631063
event.preventDefault();
10641064
dispatchCommand(editor, DELETE_LINE_COMMAND, false);
1065+
} else if (isAt(key)) {
1066+
dispatchCommand(editor, KEY_AT_COMMAND, event);
10651067
} else if (isBold(key, altKey, metaKey, ctrlKey)) {
10661068
event.preventDefault();
10671069
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');

resources/js/wysiwyg/lexical/core/LexicalUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,10 @@ export function isDelete(key: string): boolean {
10561056
return key === 'Delete';
10571057
}
10581058

1059+
export function isAt(key: string): boolean {
1060+
return key === '@';
1061+
}
1062+
10591063
export function isSelectAll(
10601064
key: string,
10611065
metaKey: boolean,
Lines changed: 167 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,175 @@
1-
import {LexicalEditor, TextNode} from "lexical";
1+
import {
2+
$createTextNode,
3+
$getSelection, $isRangeSelection,
4+
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
5+
} from "lexical";
6+
import {KEY_AT_COMMAND} from "lexical/LexicalCommands";
7+
import {$createMentionNode} from "@lexical/link/LexicalMentionNode";
8+
import {el, htmlToDom} from "../utils/dom";
9+
import {EditorUiContext} from "../ui/framework/core";
10+
import {debounce} from "../../services/util";
11+
import {removeLoading, showLoading} from "../../services/dom";
212

313

4-
export function registerMentions(editor: LexicalEditor): () => void {
14+
function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) {
15+
const textNode = selection.getNodes()[0] as TextNode;
16+
const selectionPos = selection.getStartEndPoints();
17+
if (!selectionPos) {
18+
return;
19+
}
520

6-
const unregisterTransform = editor.registerNodeTransform(TextNode, (node: TextNode) =>{
7-
console.log(node);
8-
// TODO - If last character is @, show autocomplete selector list of users.
9-
// Filter list by any extra characters entered.
10-
// On enter, replace with name mention element.
11-
// On space/escape, hide autocomplete list.
21+
const offset = selectionPos[0].offset;
22+
23+
// Ignore if the @ sign is not after a space or the start of the line
24+
const atStart = offset === 0;
25+
const afterSpace = textNode.getTextContent().charAt(offset - 1) === ' ';
26+
if (!atStart && !afterSpace) {
27+
return;
28+
}
29+
30+
const split = textNode.splitText(offset);
31+
const newNode = split[atStart ? 0 : 1];
32+
33+
const mention = $createMentionNode(0, '', '');
34+
newNode.replace(mention);
35+
mention.select();
36+
37+
const revertEditorMention = () => {
38+
context.editor.update(() => {
39+
const text = $createTextNode('@');
40+
mention.replace(text);
41+
text.selectEnd();
42+
});
43+
};
44+
45+
requestAnimationFrame(() => {
46+
const mentionDOM = context.editor.getElementByKey(mention.getKey());
47+
if (!mentionDOM) {
48+
revertEditorMention();
49+
return;
50+
}
51+
52+
const selectList = buildAndShowUserSelectorAtElement(context, mentionDOM);
53+
handleUserListLoading(selectList);
54+
handleUserSelectCancel(context, selectList, revertEditorMention);
1255
});
1356

57+
58+
// TODO - On enter, replace with name mention element.
59+
}
60+
61+
function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, revertEditorMention: () => void) {
62+
const controller = new AbortController();
63+
64+
const onCancel = () => {
65+
revertEditorMention();
66+
selectList.remove();
67+
controller.abort();
68+
}
69+
70+
selectList.addEventListener('keydown', (event) => {
71+
if (event.key === 'Escape') {
72+
onCancel();
73+
}
74+
}, {signal: controller.signal});
75+
76+
const input = selectList.querySelector('input') as HTMLInputElement;
77+
input.addEventListener('keydown', (event) => {
78+
if (event.key === 'Backspace' && input.value === '') {
79+
onCancel();
80+
event.preventDefault();
81+
event.stopPropagation();
82+
}
83+
}, {signal: controller.signal});
84+
85+
context.editorDOM.addEventListener('click', (event) => {
86+
onCancel()
87+
}, {signal: controller.signal});
88+
context.editorDOM.addEventListener('keydown', (event) => {
89+
onCancel();
90+
}, {signal: controller.signal});
91+
}
92+
93+
function handleUserListLoading(selectList: HTMLElement) {
94+
const cache = new Map<string, string>();
95+
96+
const updateUserList = async (searchTerm: string) => {
97+
// Empty list
98+
for (const child of [...selectList.children].slice(1)) {
99+
child.remove();
100+
}
101+
102+
// Fetch new content
103+
let responseHtml = '';
104+
if (cache.has(searchTerm)) {
105+
responseHtml = cache.get(searchTerm) || '';
106+
} else {
107+
const loadingWrap = el('li');
108+
showLoading(loadingWrap);
109+
selectList.appendChild(loadingWrap);
110+
111+
const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`);
112+
responseHtml = resp.data as string;
113+
cache.set(searchTerm, responseHtml);
114+
loadingWrap.remove();
115+
}
116+
117+
const doc = htmlToDom(responseHtml);
118+
const toInsert = doc.querySelectorAll('li');
119+
for (const listEl of toInsert) {
120+
const adopted = window.document.adoptNode(listEl) as HTMLElement;
121+
selectList.appendChild(adopted);
122+
}
123+
124+
};
125+
126+
// Initial load
127+
updateUserList('');
128+
129+
const input = selectList.querySelector('input') as HTMLInputElement;
130+
const updateUserListDebounced = debounce(updateUserList, 200, false);
131+
input.addEventListener('input', () => {
132+
const searchTerm = input.value;
133+
updateUserListDebounced(searchTerm);
134+
});
135+
}
136+
137+
function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement {
138+
const searchInput = el('input', {type: 'text'});
139+
const searchItem = el('li', {}, [searchInput]);
140+
const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]);
141+
142+
context.containerDOM.appendChild(userSelect);
143+
144+
userSelect.style.display = 'block';
145+
userSelect.style.top = '0';
146+
userSelect.style.left = '0';
147+
const mentionPos = mentionDOM.getBoundingClientRect();
148+
const userSelectPos = userSelect.getBoundingClientRect();
149+
userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`;
150+
userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`;
151+
152+
searchInput.focus();
153+
154+
return userSelect;
155+
}
156+
157+
export function registerMentions(context: EditorUiContext): () => void {
158+
const editor = context.editor;
159+
160+
const unregisterCommand = editor.registerCommand(KEY_AT_COMMAND, function (event: KeyboardEvent): boolean {
161+
const selection = $getSelection();
162+
if ($isRangeSelection(selection) && selection.isCollapsed()) {
163+
window.setTimeout(() => {
164+
editor.update(() => {
165+
enterUserSelectMode(context, selection);
166+
});
167+
}, 1);
168+
}
169+
return false;
170+
}, COMMAND_PRIORITY_NORMAL);
171+
14172
return (): void => {
15-
unregisterTransform();
173+
unregisterCommand();
16174
};
17175
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
@if($users->isEmpty())
2+
<li class="px-s py-xs">
3+
<span>{{ trans('common.no_items') }}</span>
4+
</li>
5+
@endif
6+
@foreach($users as $user)
7+
<li>
8+
<a href="{{ $user->getProfileUrl() }}" class="flex-container-row items-center dropdown-search-item"
9+
data-id="{{ $user->id }}"
10+
data-name="{{ $user->name }}"
11+
data-slug="{{ $user->slug }}">
12+
<img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}">
13+
<span>{{ $user->name }}</span>
14+
</a>
15+
</li>
16+
@endforeach

routes/web.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@
198198

199199
// User Search
200200
Route::get('/search/users/select', [UserControllers\UserSearchController::class, 'forSelect']);
201+
Route::get('/search/users/mention', [UserControllers\UserSearchController::class, 'forMentions']);
201202

202203
// Template System
203204
Route::get('/templates', [EntityControllers\PageTemplateController::class, 'list']);

0 commit comments

Comments
 (0)