Skip to content

Commit e2f91c2

Browse files
committed
Comment Mentions: Added keyboard nav, worked on design
1 parent 147ff00 commit e2f91c2

File tree

8 files changed

+116
-52
lines changed

8 files changed

+116
-52
lines changed

dev/build/livereload.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function listen() {
2020

2121
if (url.pathname.endsWith(name)) {
2222
const next = link.cloneNode();
23-
next.href = name + '?' + Math.random().toString(36).slice(2);
23+
next.href = name + '?version=' + Math.random().toString(36).slice(2);
2424
next.onload = function() {
2525
link.remove();
2626
};

resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
DecoratorNode,
33
DOMConversion,
4-
DOMConversionMap, DOMConversionOutput, DOMExportOutput,
4+
DOMConversionMap, DOMConversionOutput,
55
type EditorConfig,
66
LexicalEditor, LexicalNode,
77
SerializedLexicalNode,
@@ -38,6 +38,10 @@ export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
3838
self.__user_slug = userSlug;
3939
}
4040

41+
hasUserSet(): boolean {
42+
return this.__user_id > 0;
43+
}
44+
4145
isInline(): boolean {
4246
return true;
4347
}
@@ -58,21 +62,15 @@ export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
5862
element.setAttribute('target', '_blank');
5963
element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug));
6064
element.setAttribute('data-mention-user-id', String(this.__user_id));
65+
element.setAttribute('title', '@' + this.__user_name);
6166
element.textContent = '@' + this.__user_name;
62-
// element.setAttribute('contenteditable', 'false');
6367
return element;
6468
}
6569

6670
updateDOM(prevNode: MentionNode): boolean {
6771
return prevNode.__user_id !== this.__user_id;
6872
}
6973

70-
exportDOM(editor: LexicalEditor): DOMExportOutput {
71-
const element = this.createDOM(editor._config, editor);
72-
// element.removeAttribute('contenteditable');
73-
return {element};
74-
}
75-
7674
static importDOM(): DOMConversionMap|null {
7775
return {
7876
a(node: HTMLElement): DOMConversion|null {

resources/js/wysiwyg/services/mentions.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {
22
$getSelection, $isRangeSelection,
3-
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
3+
COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, RangeSelection, TextNode
44
} from "lexical";
55
import {KEY_AT_COMMAND} from "lexical/LexicalCommands";
6-
import {$createMentionNode} from "@lexical/link/LexicalMentionNode";
6+
import {$createMentionNode, $isMentionNode, MentionNode} from "@lexical/link/LexicalMentionNode";
77
import {EditorUiContext} from "../ui/framework/core";
88
import {MentionDecorator} from "../ui/decorators/MentionDecorator";
99

@@ -38,6 +38,20 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection
3838
});
3939
}
4040

41+
function selectMention(context: EditorUiContext, event: KeyboardEvent): boolean {
42+
const selected = $getSelection()?.getNodes() || [];
43+
if (selected.length === 1 && $isMentionNode(selected[0])) {
44+
const mention = selected[0] as MentionNode;
45+
const decorator = context.manager.getDecoratorByNodeKey(mention.getKey()) as MentionDecorator;
46+
decorator.showSelection();
47+
event.preventDefault();
48+
event.stopPropagation();
49+
return true;
50+
}
51+
52+
return false;
53+
}
54+
4155
export function registerMentions(context: EditorUiContext): () => void {
4256
const editor = context.editor;
4357

@@ -53,7 +67,12 @@ export function registerMentions(context: EditorUiContext): () => void {
5367
return false;
5468
}, COMMAND_PRIORITY_NORMAL);
5569

70+
const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, function (event: KeyboardEvent): boolean {
71+
return selectMention(context, event);
72+
}, COMMAND_PRIORITY_NORMAL);
73+
5674
return (): void => {
5775
unregisterCommand();
76+
unregisterEnter();
5877
};
5978
}

resources/js/wysiwyg/ui/decorators/MentionDecorator.ts

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {showLoading} from "../../../services/dom";
55
import {MentionNode} from "@lexical/link/LexicalMentionNode";
66
import {debounce} from "../../../services/util";
77
import {$createTextNode} from "lexical";
8+
import {KeyboardNavigationHandler} from "../../../services/keyboard-navigation";
9+
10+
import searchIcon from "@icons/search.svg";
811

912
function userClickHandler(onSelect: (id: number, name: string, slug: string)=>void): (event: PointerEvent) => void {
1013
return (event: PointerEvent) => {
@@ -51,7 +54,7 @@ function handleUserListLoading(selectList: HTMLElement) {
5154

5255
const updateUserList = async (searchTerm: string) => {
5356
// Empty list
54-
for (const child of [...selectList.children].slice(1)) {
57+
for (const child of [...selectList.children]) {
5558
child.remove();
5659
}
5760

@@ -60,7 +63,7 @@ function handleUserListLoading(selectList: HTMLElement) {
6063
if (cache.has(searchTerm)) {
6164
responseHtml = cache.get(searchTerm) || '';
6265
} else {
63-
const loadingWrap = el('li');
66+
const loadingWrap = el('div', {class: 'flex-container-row items-center dropdown-search-item'});
6467
showLoading(loadingWrap);
6568
selectList.appendChild(loadingWrap);
6669

@@ -71,18 +74,17 @@ function handleUserListLoading(selectList: HTMLElement) {
7174
}
7275

7376
const doc = htmlToDom(responseHtml);
74-
const toInsert = doc.querySelectorAll('li');
77+
const toInsert = doc.body.children;
7578
for (const listEl of toInsert) {
7679
const adopted = window.document.adoptNode(listEl) as HTMLElement;
7780
selectList.appendChild(adopted);
7881
}
79-
8082
};
8183

8284
// Initial load
8385
updateUserList('');
8486

85-
const input = selectList.querySelector('input') as HTMLInputElement;
87+
const input = selectList.parentElement?.querySelector('input') as HTMLInputElement;
8688
const updateUserListDebounced = debounce(updateUserList, 200, false);
8789
input.addEventListener('input', () => {
8890
const searchTerm = input.value;
@@ -92,8 +94,15 @@ function handleUserListLoading(selectList: HTMLElement) {
9294

9395
function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement {
9496
const searchInput = el('input', {type: 'text'});
95-
const searchItem = el('li', {}, [searchInput]);
96-
const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]);
97+
const list = el('div', {class: 'dropdown-search-list'});
98+
const iconWrap = el('div');
99+
iconWrap.innerHTML = searchIcon;
100+
const icon = iconWrap.children[0] as HTMLElement;
101+
icon.classList.add('svg-icon');
102+
const userSelect = el('div', {class: 'dropdown-search-dropdown compact card'}, [
103+
el('div', {class: 'dropdown-search-search'}, [icon, searchInput]),
104+
list,
105+
]);
97106

98107
context.containerDOM.appendChild(userSelect);
99108

@@ -111,28 +120,32 @@ function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM:
111120
}
112121

113122
export class MentionDecorator extends EditorDecorator {
114-
protected completedSetup: boolean = false;
115123
protected abortController: AbortController | null = null;
116-
protected selectList: HTMLElement | null = null;
124+
protected dropdownContainer: HTMLElement | null = null;
117125
protected mentionElement: HTMLElement | null = null;
118126

119127
setup(element: HTMLElement) {
120128
this.mentionElement = element;
121-
this.completedSetup = true;
129+
130+
element.addEventListener('click', (event: PointerEvent) => {
131+
this.showSelection();
132+
event.preventDefault();
133+
event.stopPropagation();
134+
});
122135
}
123136

124137
showSelection() {
125-
if (!this.mentionElement) {
138+
if (!this.mentionElement || this.dropdownContainer) {
126139
return;
127140
}
128141

129142
this.hideSelection();
130143
this.abortController = new AbortController();
131144

132-
this.selectList = buildAndShowUserSelectorAtElement(this.context, this.mentionElement);
133-
handleUserListLoading(this.selectList);
145+
this.dropdownContainer = buildAndShowUserSelectorAtElement(this.context, this.mentionElement);
146+
handleUserListLoading(this.dropdownContainer.querySelector('.dropdown-search-list') as HTMLElement);
134147

135-
this.selectList.addEventListener('click', userClickHandler((id, name, slug) => {
148+
this.dropdownContainer.addEventListener('click', userClickHandler((id, name, slug) => {
136149
this.context.editor.update(() => {
137150
const mentionNode = this.getNode() as MentionNode;
138151
this.hideSelection();
@@ -141,12 +154,22 @@ export class MentionDecorator extends EditorDecorator {
141154
});
142155
}), {signal: this.abortController.signal});
143156

144-
handleUserSelectCancel(this.context, this.selectList, this.abortController, this.revertMention.bind(this));
157+
handleUserSelectCancel(this.context, this.dropdownContainer, this.abortController, () => {
158+
if ((this.getNode() as MentionNode).hasUserSet()) {
159+
this.hideSelection()
160+
} else {
161+
this.revertMention();
162+
}
163+
});
164+
165+
new KeyboardNavigationHandler(this.dropdownContainer);
145166
}
146167

147168
hideSelection() {
148169
this.abortController?.abort();
149-
this.selectList?.remove();
170+
this.dropdownContainer?.remove();
171+
this.abortController = null;
172+
this.dropdownContainer = null;
150173
}
151174

152175
revertMention() {
@@ -158,15 +181,7 @@ export class MentionDecorator extends EditorDecorator {
158181
});
159182
}
160183

161-
update() {
162-
//
163-
}
164-
165184
render(element: HTMLElement): void {
166-
if (this.completedSetup) {
167-
this.update();
168-
} else {
169-
this.setup(element);
170-
}
185+
this.setup(element);
171186
}
172187
}

resources/js/wysiwyg/ui/defaults/toolbars.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
243243
content: () => [new EditorButton(media)],
244244
},
245245
link: {
246-
selector: 'a',
246+
selector: 'a:not([data-mention-user-id])',
247247
content() {
248248
return [
249249
new EditorButton(link),

resources/sass/_components.scss

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
746746
@include mixins.lightDark(border-color, #DDD, #444);
747747
margin-inline-start: vars.$xs;
748748
width: vars.$l;
749-
height: calc(100% - vars.$m);
749+
height: calc(100% - #{vars.$m});
750750
}
751751

752752
.comment-reference-indicator-wrap a {
@@ -982,6 +982,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
982982
}
983983
.dropdown-search-item {
984984
padding: vars.$s vars.$m;
985+
font-size: 0.8rem;
985986
&:hover,&:focus {
986987
background-color: #F2F2F2;
987988
text-decoration: none;
@@ -996,6 +997,21 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
996997
input:focus {
997998
outline: 0;
998999
}
1000+
.svg-icon {
1001+
font-size: vars.$fs-m;
1002+
}
1003+
&.compact {
1004+
.dropdown-search-list {
1005+
max-height: 320px;
1006+
}
1007+
.dropdown-search-item {
1008+
padding: vars.$xs vars.$s;
1009+
}
1010+
.avatar {
1011+
width: 22px;
1012+
height: 22px;
1013+
}
1014+
}
9991015
}
10001016

10011017
@include mixins.smaller-than(vars.$bp-l) {

resources/sass/_content.scss

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,30 @@ body .page-content img,
200200
}
201201
}
202202

203+
/**
204+
* Mention Links
205+
*/
206+
203207
a[data-mention-user-id] {
204208
display: inline-block;
205209
position: relative;
206210
color: var(--color-link);
207-
padding: 0.1em 0.2em;
211+
padding: 0.1em 0.4em;
212+
display: -webkit-inline-box;
213+
-webkit-box-orient: vertical;
214+
-webkit-line-clamp: 1;
215+
max-width: 180px;
216+
overflow: hidden;
217+
text-overflow: ellipsis;
218+
font-size: 0.92em;
219+
margin-inline: 0.2em;
220+
vertical-align: middle;
221+
border-radius: 3px;
222+
border: 1px solid transparent;
223+
&:hover {
224+
text-decoration: none;
225+
border-color: currentColor;
226+
}
208227
&:after {
209228
content: '';
210229
background-color: currentColor;
@@ -215,6 +234,5 @@ a[data-mention-user-id] {
215234
width: 100%;
216235
height: 100%;
217236
display: block;
218-
border-radius: 0.2em;
219237
}
220238
}
Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
@if($users->isEmpty())
2-
<li class="px-s py-xs">
3-
<span>{{ trans('common.no_items') }}</span>
4-
</li>
2+
<div class="flex-container-row items-center dropdown-search-item dropdown-search-item text-muted mt-m">
3+
<span>{{ trans('common.no_items') }}</span>
4+
</div>
55
@endif
66
@foreach($users as $user)
7-
<li>
8-
<a href="{{ $user->getProfileUrl() }}" class="flex-container-row items-center dropdown-search-item"
9-
data-id="{{ $user->id }}"
10-
data-name="{{ $user->name }}"
11-
data-slug="{{ $user->slug }}">
12-
<img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}">
13-
<span>{{ $user->name }}</span>
14-
</a>
15-
</li>
7+
<a href="{{ $user->getProfileUrl() }}" class="flex-container-row items-center dropdown-search-item"
8+
data-id="{{ $user->id }}"
9+
data-name="{{ $user->name }}"
10+
data-slug="{{ $user->slug }}">
11+
<img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}">
12+
<span>{{ $user->name }}</span>
13+
</a>
1614
@endforeach

0 commit comments

Comments
 (0)