mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-12-19 10:42:29 +03:00
Mentions: Added new endpoint, Built editor list display
This commit is contained in:
@@ -5,6 +5,7 @@ namespace BookStack\Users\Controllers;
|
|||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class UserSearchController extends Controller
|
class UserSearchController extends Controller
|
||||||
@@ -34,8 +35,43 @@ class UserSearchController extends Controller
|
|||||||
$query->where('name', 'like', '%' . $search . '%');
|
$query->where('name', 'like', '%' . $search . '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var Collection<User> $users */
|
||||||
|
$users = $query->get();
|
||||||
|
|
||||||
return view('form.user-select-list', [
|
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<User> $users */
|
||||||
|
$users = $query->get();
|
||||||
|
|
||||||
|
return view('form.user-mention-list', [
|
||||||
|
'users' => $users,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
|
|||||||
registerHistory(editor, createEmptyHistoryState(), 300),
|
registerHistory(editor, createEmptyHistoryState(), 300),
|
||||||
registerShortcuts(context),
|
registerShortcuts(context),
|
||||||
registerAutoLinks(editor),
|
registerAutoLinks(editor),
|
||||||
registerMentions(editor),
|
registerMentions(context),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register toolbars, modals & decorators
|
// Register toolbars, modals & decorators
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export const KEY_ESCAPE_COMMAND: LexicalCommand<KeyboardEvent> =
|
|||||||
createCommand('KEY_ESCAPE_COMMAND');
|
createCommand('KEY_ESCAPE_COMMAND');
|
||||||
export const KEY_DELETE_COMMAND: LexicalCommand<KeyboardEvent> =
|
export const KEY_DELETE_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||||
createCommand('KEY_DELETE_COMMAND');
|
createCommand('KEY_DELETE_COMMAND');
|
||||||
|
export const KEY_AT_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||||
|
createCommand('KEY_AT_COMMAND');
|
||||||
export const KEY_TAB_COMMAND: LexicalCommand<KeyboardEvent> =
|
export const KEY_TAB_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||||
createCommand('KEY_TAB_COMMAND');
|
createCommand('KEY_TAB_COMMAND');
|
||||||
export const INSERT_TAB_COMMAND: LexicalCommand<void> =
|
export const INSERT_TAB_COMMAND: LexicalCommand<void> =
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
SELECTION_CHANGE_COMMAND,
|
SELECTION_CHANGE_COMMAND,
|
||||||
UNDO_COMMAND,
|
UNDO_COMMAND,
|
||||||
} from '.';
|
} from '.';
|
||||||
import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
|
import {KEY_AT_COMMAND, KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
|
||||||
import {
|
import {
|
||||||
COMPOSITION_START_CHAR,
|
COMPOSITION_START_CHAR,
|
||||||
DOM_ELEMENT_TYPE,
|
DOM_ELEMENT_TYPE,
|
||||||
@@ -97,7 +97,7 @@ import {
|
|||||||
getEditorPropertyFromDOMNode,
|
getEditorPropertyFromDOMNode,
|
||||||
getEditorsToPropagate,
|
getEditorsToPropagate,
|
||||||
getNearestEditorFromDOMNode,
|
getNearestEditorFromDOMNode,
|
||||||
getWindow,
|
getWindow, isAt,
|
||||||
isBackspace,
|
isBackspace,
|
||||||
isBold,
|
isBold,
|
||||||
isCopy,
|
isCopy,
|
||||||
@@ -1062,6 +1062,8 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
|
|||||||
} else if (isDeleteLineForward(key, metaKey)) {
|
} else if (isDeleteLineForward(key, metaKey)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dispatchCommand(editor, DELETE_LINE_COMMAND, false);
|
dispatchCommand(editor, DELETE_LINE_COMMAND, false);
|
||||||
|
} else if (isAt(key)) {
|
||||||
|
dispatchCommand(editor, KEY_AT_COMMAND, event);
|
||||||
} else if (isBold(key, altKey, metaKey, ctrlKey)) {
|
} else if (isBold(key, altKey, metaKey, ctrlKey)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
|
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
|
||||||
|
|||||||
@@ -1056,6 +1056,10 @@ export function isDelete(key: string): boolean {
|
|||||||
return key === 'Delete';
|
return key === 'Delete';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAt(key: string): boolean {
|
||||||
|
return key === '@';
|
||||||
|
}
|
||||||
|
|
||||||
export function isSelectAll(
|
export function isSelectAll(
|
||||||
key: string,
|
key: string,
|
||||||
metaKey: boolean,
|
metaKey: boolean,
|
||||||
|
|||||||
@@ -1,17 +1,175 @@
|
|||||||
import {LexicalEditor, TextNode} from "lexical";
|
import {
|
||||||
|
$createTextNode,
|
||||||
|
$getSelection, $isRangeSelection,
|
||||||
|
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
|
||||||
|
} from "lexical";
|
||||||
|
import {KEY_AT_COMMAND} from "lexical/LexicalCommands";
|
||||||
|
import {$createMentionNode} from "@lexical/link/LexicalMentionNode";
|
||||||
|
import {el, htmlToDom} from "../utils/dom";
|
||||||
|
import {EditorUiContext} from "../ui/framework/core";
|
||||||
|
import {debounce} from "../../services/util";
|
||||||
|
import {removeLoading, showLoading} from "../../services/dom";
|
||||||
|
|
||||||
|
|
||||||
export function registerMentions(editor: LexicalEditor): () => void {
|
function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) {
|
||||||
|
const textNode = selection.getNodes()[0] as TextNode;
|
||||||
|
const selectionPos = selection.getStartEndPoints();
|
||||||
|
if (!selectionPos) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const unregisterTransform = editor.registerNodeTransform(TextNode, (node: TextNode) =>{
|
const offset = selectionPos[0].offset;
|
||||||
console.log(node);
|
|
||||||
// TODO - If last character is @, show autocomplete selector list of users.
|
// Ignore if the @ sign is not after a space or the start of the line
|
||||||
// Filter list by any extra characters entered.
|
const atStart = offset === 0;
|
||||||
// On enter, replace with name mention element.
|
const afterSpace = textNode.getTextContent().charAt(offset - 1) === ' ';
|
||||||
// On space/escape, hide autocomplete list.
|
if (!atStart && !afterSpace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const split = textNode.splitText(offset);
|
||||||
|
const newNode = split[atStart ? 0 : 1];
|
||||||
|
|
||||||
|
const mention = $createMentionNode(0, '', '');
|
||||||
|
newNode.replace(mention);
|
||||||
|
mention.select();
|
||||||
|
|
||||||
|
const revertEditorMention = () => {
|
||||||
|
context.editor.update(() => {
|
||||||
|
const text = $createTextNode('@');
|
||||||
|
mention.replace(text);
|
||||||
|
text.selectEnd();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const mentionDOM = context.editor.getElementByKey(mention.getKey());
|
||||||
|
if (!mentionDOM) {
|
||||||
|
revertEditorMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectList = buildAndShowUserSelectorAtElement(context, mentionDOM);
|
||||||
|
handleUserListLoading(selectList);
|
||||||
|
handleUserSelectCancel(context, selectList, revertEditorMention);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// TODO - On enter, replace with name mention element.
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, revertEditorMention: () => void) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
revertEditorMention();
|
||||||
|
selectList.remove();
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectList.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, {signal: controller.signal});
|
||||||
|
|
||||||
|
const input = selectList.querySelector('input') as HTMLInputElement;
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Backspace' && input.value === '') {
|
||||||
|
onCancel();
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}, {signal: controller.signal});
|
||||||
|
|
||||||
|
context.editorDOM.addEventListener('click', (event) => {
|
||||||
|
onCancel()
|
||||||
|
}, {signal: controller.signal});
|
||||||
|
context.editorDOM.addEventListener('keydown', (event) => {
|
||||||
|
onCancel();
|
||||||
|
}, {signal: controller.signal});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserListLoading(selectList: HTMLElement) {
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
|
||||||
|
const updateUserList = async (searchTerm: string) => {
|
||||||
|
// Empty list
|
||||||
|
for (const child of [...selectList.children].slice(1)) {
|
||||||
|
child.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch new content
|
||||||
|
let responseHtml = '';
|
||||||
|
if (cache.has(searchTerm)) {
|
||||||
|
responseHtml = cache.get(searchTerm) || '';
|
||||||
|
} else {
|
||||||
|
const loadingWrap = el('li');
|
||||||
|
showLoading(loadingWrap);
|
||||||
|
selectList.appendChild(loadingWrap);
|
||||||
|
|
||||||
|
const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`);
|
||||||
|
responseHtml = resp.data as string;
|
||||||
|
cache.set(searchTerm, responseHtml);
|
||||||
|
loadingWrap.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = htmlToDom(responseHtml);
|
||||||
|
const toInsert = doc.querySelectorAll('li');
|
||||||
|
for (const listEl of toInsert) {
|
||||||
|
const adopted = window.document.adoptNode(listEl) as HTMLElement;
|
||||||
|
selectList.appendChild(adopted);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
updateUserList('');
|
||||||
|
|
||||||
|
const input = selectList.querySelector('input') as HTMLInputElement;
|
||||||
|
const updateUserListDebounced = debounce(updateUserList, 200, false);
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const searchTerm = input.value;
|
||||||
|
updateUserListDebounced(searchTerm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement {
|
||||||
|
const searchInput = el('input', {type: 'text'});
|
||||||
|
const searchItem = el('li', {}, [searchInput]);
|
||||||
|
const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]);
|
||||||
|
|
||||||
|
context.containerDOM.appendChild(userSelect);
|
||||||
|
|
||||||
|
userSelect.style.display = 'block';
|
||||||
|
userSelect.style.top = '0';
|
||||||
|
userSelect.style.left = '0';
|
||||||
|
const mentionPos = mentionDOM.getBoundingClientRect();
|
||||||
|
const userSelectPos = userSelect.getBoundingClientRect();
|
||||||
|
userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`;
|
||||||
|
userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`;
|
||||||
|
|
||||||
|
searchInput.focus();
|
||||||
|
|
||||||
|
return userSelect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMentions(context: EditorUiContext): () => void {
|
||||||
|
const editor = context.editor;
|
||||||
|
|
||||||
|
const unregisterCommand = editor.registerCommand(KEY_AT_COMMAND, function (event: KeyboardEvent): boolean {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection) && selection.isCollapsed()) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
editor.update(() => {
|
||||||
|
enterUserSelectMode(context, selection);
|
||||||
|
});
|
||||||
|
}, 1);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, COMMAND_PRIORITY_NORMAL);
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
unregisterTransform();
|
unregisterCommand();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
16
resources/views/form/user-mention-list.blade.php
Normal file
16
resources/views/form/user-mention-list.blade.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@if($users->isEmpty())
|
||||||
|
<li class="px-s py-xs">
|
||||||
|
<span>{{ trans('common.no_items') }}</span>
|
||||||
|
</li>
|
||||||
|
@endif
|
||||||
|
@foreach($users as $user)
|
||||||
|
<li>
|
||||||
|
<a href="{{ $user->getProfileUrl() }}" class="flex-container-row items-center dropdown-search-item"
|
||||||
|
data-id="{{ $user->id }}"
|
||||||
|
data-name="{{ $user->name }}"
|
||||||
|
data-slug="{{ $user->slug }}">
|
||||||
|
<img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}">
|
||||||
|
<span>{{ $user->name }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
@@ -198,6 +198,7 @@ Route::middleware('auth')->group(function () {
|
|||||||
|
|
||||||
// User Search
|
// User Search
|
||||||
Route::get('/search/users/select', [UserControllers\UserSearchController::class, 'forSelect']);
|
Route::get('/search/users/select', [UserControllers\UserSearchController::class, 'forSelect']);
|
||||||
|
Route::get('/search/users/mention', [UserControllers\UserSearchController::class, 'forMentions']);
|
||||||
|
|
||||||
// Template System
|
// Template System
|
||||||
Route::get('/templates', [EntityControllers\PageTemplateController::class, 'list']);
|
Route::get('/templates', [EntityControllers\PageTemplateController::class, 'list']);
|
||||||
|
|||||||
Reference in New Issue
Block a user