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)) {
|
if (url.pathname.endsWith(name)) {
|
||||||
const next = link.cloneNode();
|
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() {
|
next.onload = function() {
|
||||||
link.remove();
|
link.remove();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
DecoratorNode,
|
DecoratorNode,
|
||||||
DOMConversion,
|
DOMConversion,
|
||||||
DOMConversionMap, DOMConversionOutput, DOMExportOutput,
|
DOMConversionMap, DOMConversionOutput,
|
||||||
type EditorConfig,
|
type EditorConfig,
|
||||||
LexicalEditor, LexicalNode,
|
LexicalEditor, LexicalNode,
|
||||||
SerializedLexicalNode,
|
SerializedLexicalNode,
|
||||||
@@ -38,6 +38,10 @@ export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||||||
self.__user_slug = userSlug;
|
self.__user_slug = userSlug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasUserSet(): boolean {
|
||||||
|
return this.__user_id > 0;
|
||||||
|
}
|
||||||
|
|
||||||
isInline(): boolean {
|
isInline(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -58,8 +62,8 @@ export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||||||
element.setAttribute('target', '_blank');
|
element.setAttribute('target', '_blank');
|
||||||
element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug));
|
element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug));
|
||||||
element.setAttribute('data-mention-user-id', String(this.__user_id));
|
element.setAttribute('data-mention-user-id', String(this.__user_id));
|
||||||
|
element.setAttribute('title', '@' + this.__user_name);
|
||||||
element.textContent = '@' + this.__user_name;
|
element.textContent = '@' + this.__user_name;
|
||||||
// element.setAttribute('contenteditable', 'false');
|
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,12 +71,6 @@ export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||||||
return prevNode.__user_id !== this.__user_id;
|
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 {
|
static importDOM(): DOMConversionMap|null {
|
||||||
return {
|
return {
|
||||||
a(node: HTMLElement): DOMConversion|null {
|
a(node: HTMLElement): DOMConversion|null {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
$getSelection, $isRangeSelection,
|
$getSelection, $isRangeSelection,
|
||||||
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
|
COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, RangeSelection, TextNode
|
||||||
} from "lexical";
|
} from "lexical";
|
||||||
import {KEY_AT_COMMAND} from "lexical/LexicalCommands";
|
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 {EditorUiContext} from "../ui/framework/core";
|
||||||
import {MentionDecorator} from "../ui/decorators/MentionDecorator";
|
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 {
|
export function registerMentions(context: EditorUiContext): () => void {
|
||||||
const editor = context.editor;
|
const editor = context.editor;
|
||||||
|
|
||||||
@@ -53,7 +67,12 @@ export function registerMentions(context: EditorUiContext): () => void {
|
|||||||
return false;
|
return false;
|
||||||
}, COMMAND_PRIORITY_NORMAL);
|
}, COMMAND_PRIORITY_NORMAL);
|
||||||
|
|
||||||
|
const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, function (event: KeyboardEvent): boolean {
|
||||||
|
return selectMention(context, event);
|
||||||
|
}, COMMAND_PRIORITY_NORMAL);
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
unregisterCommand();
|
unregisterCommand();
|
||||||
|
unregisterEnter();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,9 @@ import {showLoading} from "../../../services/dom";
|
|||||||
import {MentionNode} from "@lexical/link/LexicalMentionNode";
|
import {MentionNode} from "@lexical/link/LexicalMentionNode";
|
||||||
import {debounce} from "../../../services/util";
|
import {debounce} from "../../../services/util";
|
||||||
import {$createTextNode} from "lexical";
|
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 {
|
function userClickHandler(onSelect: (id: number, name: string, slug: string)=>void): (event: PointerEvent) => void {
|
||||||
return (event: PointerEvent) => {
|
return (event: PointerEvent) => {
|
||||||
@@ -51,7 +54,7 @@ function handleUserListLoading(selectList: HTMLElement) {
|
|||||||
|
|
||||||
const updateUserList = async (searchTerm: string) => {
|
const updateUserList = async (searchTerm: string) => {
|
||||||
// Empty list
|
// Empty list
|
||||||
for (const child of [...selectList.children].slice(1)) {
|
for (const child of [...selectList.children]) {
|
||||||
child.remove();
|
child.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +63,7 @@ function handleUserListLoading(selectList: HTMLElement) {
|
|||||||
if (cache.has(searchTerm)) {
|
if (cache.has(searchTerm)) {
|
||||||
responseHtml = cache.get(searchTerm) || '';
|
responseHtml = cache.get(searchTerm) || '';
|
||||||
} else {
|
} else {
|
||||||
const loadingWrap = el('li');
|
const loadingWrap = el('div', {class: 'flex-container-row items-center dropdown-search-item'});
|
||||||
showLoading(loadingWrap);
|
showLoading(loadingWrap);
|
||||||
selectList.appendChild(loadingWrap);
|
selectList.appendChild(loadingWrap);
|
||||||
|
|
||||||
@@ -71,18 +74,17 @@ function handleUserListLoading(selectList: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const doc = htmlToDom(responseHtml);
|
const doc = htmlToDom(responseHtml);
|
||||||
const toInsert = doc.querySelectorAll('li');
|
const toInsert = doc.body.children;
|
||||||
for (const listEl of toInsert) {
|
for (const listEl of toInsert) {
|
||||||
const adopted = window.document.adoptNode(listEl) as HTMLElement;
|
const adopted = window.document.adoptNode(listEl) as HTMLElement;
|
||||||
selectList.appendChild(adopted);
|
selectList.appendChild(adopted);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
updateUserList('');
|
updateUserList('');
|
||||||
|
|
||||||
const input = selectList.querySelector('input') as HTMLInputElement;
|
const input = selectList.parentElement?.querySelector('input') as HTMLInputElement;
|
||||||
const updateUserListDebounced = debounce(updateUserList, 200, false);
|
const updateUserListDebounced = debounce(updateUserList, 200, false);
|
||||||
input.addEventListener('input', () => {
|
input.addEventListener('input', () => {
|
||||||
const searchTerm = input.value;
|
const searchTerm = input.value;
|
||||||
@@ -92,8 +94,15 @@ function handleUserListLoading(selectList: HTMLElement) {
|
|||||||
|
|
||||||
function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement {
|
function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement {
|
||||||
const searchInput = el('input', {type: 'text'});
|
const searchInput = el('input', {type: 'text'});
|
||||||
const searchItem = el('li', {}, [searchInput]);
|
const list = el('div', {class: 'dropdown-search-list'});
|
||||||
const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]);
|
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);
|
context.containerDOM.appendChild(userSelect);
|
||||||
|
|
||||||
@@ -111,28 +120,32 @@ function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MentionDecorator extends EditorDecorator {
|
export class MentionDecorator extends EditorDecorator {
|
||||||
protected completedSetup: boolean = false;
|
|
||||||
protected abortController: AbortController | null = null;
|
protected abortController: AbortController | null = null;
|
||||||
protected selectList: HTMLElement | null = null;
|
protected dropdownContainer: HTMLElement | null = null;
|
||||||
protected mentionElement: HTMLElement | null = null;
|
protected mentionElement: HTMLElement | null = null;
|
||||||
|
|
||||||
setup(element: HTMLElement) {
|
setup(element: HTMLElement) {
|
||||||
this.mentionElement = element;
|
this.mentionElement = element;
|
||||||
this.completedSetup = true;
|
|
||||||
|
element.addEventListener('click', (event: PointerEvent) => {
|
||||||
|
this.showSelection();
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
showSelection() {
|
showSelection() {
|
||||||
if (!this.mentionElement) {
|
if (!this.mentionElement || this.dropdownContainer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hideSelection();
|
this.hideSelection();
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
this.selectList = buildAndShowUserSelectorAtElement(this.context, this.mentionElement);
|
this.dropdownContainer = buildAndShowUserSelectorAtElement(this.context, this.mentionElement);
|
||||||
handleUserListLoading(this.selectList);
|
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(() => {
|
this.context.editor.update(() => {
|
||||||
const mentionNode = this.getNode() as MentionNode;
|
const mentionNode = this.getNode() as MentionNode;
|
||||||
this.hideSelection();
|
this.hideSelection();
|
||||||
@@ -141,12 +154,22 @@ export class MentionDecorator extends EditorDecorator {
|
|||||||
});
|
});
|
||||||
}), {signal: this.abortController.signal});
|
}), {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() {
|
hideSelection() {
|
||||||
this.abortController?.abort();
|
this.abortController?.abort();
|
||||||
this.selectList?.remove();
|
this.dropdownContainer?.remove();
|
||||||
|
this.abortController = null;
|
||||||
|
this.dropdownContainer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
revertMention() {
|
revertMention() {
|
||||||
@@ -158,15 +181,7 @@ export class MentionDecorator extends EditorDecorator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
render(element: HTMLElement): void {
|
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)],
|
content: () => [new EditorButton(media)],
|
||||||
},
|
},
|
||||||
link: {
|
link: {
|
||||||
selector: 'a',
|
selector: 'a:not([data-mention-user-id])',
|
||||||
content() {
|
content() {
|
||||||
return [
|
return [
|
||||||
new EditorButton(link),
|
new EditorButton(link),
|
||||||
|
|||||||
@@ -746,7 +746,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
|
|||||||
@include mixins.lightDark(border-color, #DDD, #444);
|
@include mixins.lightDark(border-color, #DDD, #444);
|
||||||
margin-inline-start: vars.$xs;
|
margin-inline-start: vars.$xs;
|
||||||
width: vars.$l;
|
width: vars.$l;
|
||||||
height: calc(100% - vars.$m);
|
height: calc(100% - #{vars.$m});
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-reference-indicator-wrap a {
|
.comment-reference-indicator-wrap a {
|
||||||
@@ -982,6 +982,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
|
|||||||
}
|
}
|
||||||
.dropdown-search-item {
|
.dropdown-search-item {
|
||||||
padding: vars.$s vars.$m;
|
padding: vars.$s vars.$m;
|
||||||
|
font-size: 0.8rem;
|
||||||
&:hover,&:focus {
|
&:hover,&:focus {
|
||||||
background-color: #F2F2F2;
|
background-color: #F2F2F2;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -996,6 +997,21 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
|
|||||||
input:focus {
|
input:focus {
|
||||||
outline: 0;
|
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) {
|
@include mixins.smaller-than(vars.$bp-l) {
|
||||||
|
|||||||
@@ -200,11 +200,30 @@ body .page-content img,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mention Links
|
||||||
|
*/
|
||||||
|
|
||||||
a[data-mention-user-id] {
|
a[data-mention-user-id] {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
color: var(--color-link);
|
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 {
|
&:after {
|
||||||
content: '';
|
content: '';
|
||||||
background-color: currentColor;
|
background-color: currentColor;
|
||||||
@@ -215,6 +234,5 @@ a[data-mention-user-id] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 0.2em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
@if($users->isEmpty())
|
@if($users->isEmpty())
|
||||||
<li class="px-s py-xs">
|
<div class="flex-container-row items-center dropdown-search-item dropdown-search-item text-muted mt-m">
|
||||||
<span>{{ trans('common.no_items') }}</span>
|
<span>{{ trans('common.no_items') }}</span>
|
||||||
</li>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@foreach($users as $user)
|
@foreach($users as $user)
|
||||||
<li>
|
|
||||||
<a href="{{ $user->getProfileUrl() }}" class="flex-container-row items-center dropdown-search-item"
|
<a href="{{ $user->getProfileUrl() }}" class="flex-container-row items-center dropdown-search-item"
|
||||||
data-id="{{ $user->id }}"
|
data-id="{{ $user->id }}"
|
||||||
data-name="{{ $user->name }}"
|
data-name="{{ $user->name }}"
|
||||||
@@ -12,5 +11,4 @@
|
|||||||
<img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}">
|
<img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}">
|
||||||
<span>{{ $user->name }}</span>
|
<span>{{ $user->name }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
Reference in New Issue
Block a user