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:
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user