1
0
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:
Dan Brown
2025-12-09 16:56:34 +00:00
parent 50540e23a1
commit 9bf9ae9c37
8 changed files with 232 additions and 13 deletions

View File

@@ -160,7 +160,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context),
registerAutoLinks(editor),
registerMentions(editor),
registerMentions(context),
);
// Register toolbars, modals & decorators

View File

@@ -78,6 +78,8 @@ export const KEY_ESCAPE_COMMAND: LexicalCommand<KeyboardEvent> =
createCommand('KEY_ESCAPE_COMMAND');
export const KEY_DELETE_COMMAND: LexicalCommand<KeyboardEvent> =
createCommand('KEY_DELETE_COMMAND');
export const KEY_AT_COMMAND: LexicalCommand<KeyboardEvent> =
createCommand('KEY_AT_COMMAND');
export const KEY_TAB_COMMAND: LexicalCommand<KeyboardEvent> =
createCommand('KEY_TAB_COMMAND');
export const INSERT_TAB_COMMAND: LexicalCommand<void> =

View File

@@ -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');

View File

@@ -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,

View File

@@ -1,17 +1,175 @@
import {LexicalEditor, TextNode} from "lexical";
import {
$createTextNode,
$getSelection, $isRangeSelection,
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
} from "lexical";
import {KEY_AT_COMMAND} from "lexical/LexicalCommands";
import {$createMentionNode} from "@lexical/link/LexicalMentionNode";
import {el, htmlToDom} from "../utils/dom";
import {EditorUiContext} from "../ui/framework/core";
import {debounce} from "../../services/util";
import {removeLoading, showLoading} from "../../services/dom";
export function registerMentions(editor: LexicalEditor): () => void {
function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) {
const textNode = selection.getNodes()[0] as TextNode;
const selectionPos = selection.getStartEndPoints();
if (!selectionPos) {
return;
}
const unregisterTransform = editor.registerNodeTransform(TextNode, (node: TextNode) =>{
console.log(node);
// TODO - If last character is @, show autocomplete selector list of users.
// Filter list by any extra characters entered.
// On enter, replace with name mention element.
// On space/escape, hide autocomplete list.
const offset = selectionPos[0].offset;
// Ignore if the @ sign is not after a space or the start of the line
const atStart = offset === 0;
const afterSpace = textNode.getTextContent().charAt(offset - 1) === ' ';
if (!atStart && !afterSpace) {
return;
}
const split = textNode.splitText(offset);
const newNode = split[atStart ? 0 : 1];
const mention = $createMentionNode(0, '', '');
newNode.replace(mention);
mention.select();
const revertEditorMention = () => {
context.editor.update(() => {
const text = $createTextNode('@');
mention.replace(text);
text.selectEnd();
});
};
requestAnimationFrame(() => {
const mentionDOM = context.editor.getElementByKey(mention.getKey());
if (!mentionDOM) {
revertEditorMention();
return;
}
const selectList = buildAndShowUserSelectorAtElement(context, mentionDOM);
handleUserListLoading(selectList);
handleUserSelectCancel(context, selectList, revertEditorMention);
});
// TODO - On enter, replace with name mention element.
}
function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, revertEditorMention: () => void) {
const controller = new AbortController();
const onCancel = () => {
revertEditorMention();
selectList.remove();
controller.abort();
}
selectList.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
onCancel();
}
}, {signal: controller.signal});
const input = selectList.querySelector('input') as HTMLInputElement;
input.addEventListener('keydown', (event) => {
if (event.key === 'Backspace' && input.value === '') {
onCancel();
event.preventDefault();
event.stopPropagation();
}
}, {signal: controller.signal});
context.editorDOM.addEventListener('click', (event) => {
onCancel()
}, {signal: controller.signal});
context.editorDOM.addEventListener('keydown', (event) => {
onCancel();
}, {signal: controller.signal});
}
function handleUserListLoading(selectList: HTMLElement) {
const cache = new Map<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 => {
unregisterTransform();
unregisterCommand();
};
}