1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-12-19 10:42:29 +03:00

Comment Mentions: Added keyboard nav, worked on design

This commit is contained in:
Dan Brown
2025-12-14 17:19:08 +00:00
parent 147ff00c7a
commit e2f91c2bbb
8 changed files with 116 additions and 52 deletions

View File

@@ -20,7 +20,7 @@ function listen() {
if (url.pathname.endsWith(name)) {
const next = link.cloneNode();
next.href = name + '?' + Math.random().toString(36).slice(2);
next.href = name + '?version=' + Math.random().toString(36).slice(2);
next.onload = function() {
link.remove();
};

View File

@@ -1,7 +1,7 @@
import {
DecoratorNode,
DOMConversion,
DOMConversionMap, DOMConversionOutput, DOMExportOutput,
DOMConversionMap, DOMConversionOutput,
type EditorConfig,
LexicalEditor, LexicalNode,
SerializedLexicalNode,
@@ -38,6 +38,10 @@ export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
self.__user_slug = userSlug;
}
hasUserSet(): boolean {
return this.__user_id > 0;
}
isInline(): boolean {
return true;
}
@@ -58,8 +62,8 @@ export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
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;
// element.setAttribute('contenteditable', 'false');
return element;
}
@@ -67,12 +71,6 @@ export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
return prevNode.__user_id !== this.__user_id;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = this.createDOM(editor._config, editor);
// element.removeAttribute('contenteditable');
return {element};
}
static importDOM(): DOMConversionMap|null {
return {
a(node: HTMLElement): DOMConversion|null {

View File

@@ -1,9 +1,9 @@
import {
$getSelection, $isRangeSelection,
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, RangeSelection, TextNode
} from "lexical";
import {KEY_AT_COMMAND} from "lexical/LexicalCommands";
import {$createMentionNode} from "@lexical/link/LexicalMentionNode";
import {$createMentionNode, $isMentionNode, MentionNode} from "@lexical/link/LexicalMentionNode";
import {EditorUiContext} from "../ui/framework/core";
import {MentionDecorator} from "../ui/decorators/MentionDecorator";
@@ -38,6 +38,20 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection
});
}
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;
@@ -53,7 +67,12 @@ export function registerMentions(context: EditorUiContext): () => void {
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();
};
}

View File

@@ -5,6 +5,9 @@ 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) => {
@@ -51,7 +54,7 @@ function handleUserListLoading(selectList: HTMLElement) {
const updateUserList = async (searchTerm: string) => {
// Empty list
for (const child of [...selectList.children].slice(1)) {
for (const child of [...selectList.children]) {
child.remove();
}
@@ -60,7 +63,7 @@ function handleUserListLoading(selectList: HTMLElement) {
if (cache.has(searchTerm)) {
responseHtml = cache.get(searchTerm) || '';
} else {
const loadingWrap = el('li');
const loadingWrap = el('div', {class: 'flex-container-row items-center dropdown-search-item'});
showLoading(loadingWrap);
selectList.appendChild(loadingWrap);
@@ -71,18 +74,17 @@ function handleUserListLoading(selectList: HTMLElement) {
}
const doc = htmlToDom(responseHtml);
const toInsert = doc.querySelectorAll('li');
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.querySelector('input') as HTMLInputElement;
const input = selectList.parentElement?.querySelector('input') as HTMLInputElement;
const updateUserListDebounced = debounce(updateUserList, 200, false);
input.addEventListener('input', () => {
const searchTerm = input.value;
@@ -92,8 +94,15 @@ function handleUserListLoading(selectList: HTMLElement) {
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]);
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);
@@ -111,28 +120,32 @@ function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM:
}
export class MentionDecorator extends EditorDecorator {
protected completedSetup: boolean = false;
protected abortController: AbortController | null = null;
protected selectList: HTMLElement | null = null;
protected dropdownContainer: HTMLElement | null = null;
protected mentionElement: HTMLElement | null = null;
setup(element: HTMLElement) {
this.mentionElement = element;
this.completedSetup = true;
element.addEventListener('click', (event: PointerEvent) => {
this.showSelection();
event.preventDefault();
event.stopPropagation();
});
}
showSelection() {
if (!this.mentionElement) {
if (!this.mentionElement || this.dropdownContainer) {
return;
}
this.hideSelection();
this.abortController = new AbortController();
this.selectList = buildAndShowUserSelectorAtElement(this.context, this.mentionElement);
handleUserListLoading(this.selectList);
this.dropdownContainer = buildAndShowUserSelectorAtElement(this.context, this.mentionElement);
handleUserListLoading(this.dropdownContainer.querySelector('.dropdown-search-list') as HTMLElement);
this.selectList.addEventListener('click', userClickHandler((id, name, slug) => {
this.dropdownContainer.addEventListener('click', userClickHandler((id, name, slug) => {
this.context.editor.update(() => {
const mentionNode = this.getNode() as MentionNode;
this.hideSelection();
@@ -141,12 +154,22 @@ export class MentionDecorator extends EditorDecorator {
});
}), {signal: this.abortController.signal});
handleUserSelectCancel(this.context, this.selectList, this.abortController, this.revertMention.bind(this));
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.selectList?.remove();
this.dropdownContainer?.remove();
this.abortController = null;
this.dropdownContainer = null;
}
revertMention() {
@@ -158,15 +181,7 @@ export class MentionDecorator extends EditorDecorator {
});
}
update() {
//
}
render(element: HTMLElement): void {
if (this.completedSetup) {
this.update();
} else {
this.setup(element);
}
this.setup(element);
}
}

View File

@@ -243,7 +243,7 @@ export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
content: () => [new EditorButton(media)],
},
link: {
selector: 'a',
selector: 'a:not([data-mention-user-id])',
content() {
return [
new EditorButton(link),

View File

@@ -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) {

View File

@@ -200,11 +200,30 @@ body .page-content img,
}
}
/**
* Mention Links
*/
a[data-mention-user-id] {
display: inline-block;
position: relative;
color: var(--color-link);
padding: 0.1em 0.2em;
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;
@@ -215,6 +234,5 @@ a[data-mention-user-id] {
width: 100%;
height: 100%;
display: block;
border-radius: 0.2em;
}
}

View File

@@ -1,16 +1,14 @@
@if($users->isEmpty())
<li class="px-s py-xs">
<span>{{ trans('common.no_items') }}</span>
</li>
<div class="flex-container-row items-center dropdown-search-item dropdown-search-item text-muted mt-m">
<span>{{ trans('common.no_items') }}</span>
</div>
@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>
<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>
@endforeach