Skip to content

Commit 1e768ce

Browse files
committed
Lexical: Changed mention to be a decorator node
Allows better selection. Also updated existing decorator file names to align with classes so they're easier to find. Also aligned/fixed decorator constuctor/setup methods.
1 parent 9bf9ae9 commit 1e768ce

File tree

10 files changed

+252
-155
lines changed

10 files changed

+252
-155
lines changed

app/Util/HtmlDescriptionFilter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class HtmlDescriptionFilter
1919
*/
2020
protected static array $allowedAttrsByElements = [
2121
'p' => [],
22-
'a' => ['href', 'title', 'target'],
22+
'a' => ['href', 'title', 'target', 'data-mention-user-id'],
2323
'ol' => [],
2424
'ul' => [],
2525
'li' => [],

resources/js/wysiwyg/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ import {registerKeyboardHandling} from "./services/keyboard-handling";
2222
import {registerAutoLinks} from "./services/auto-links";
2323
import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
2424
import {modals} from "./ui/defaults/modals";
25-
import {CodeBlockDecorator} from "./ui/decorators/code-block";
26-
import {DiagramDecorator} from "./ui/decorators/diagram";
25+
import {CodeBlockDecorator} from "./ui/decorators/CodeBlockDecorator";
26+
import {DiagramDecorator} from "./ui/decorators/DiagramDecorator";
2727
import {registerMouseHandling} from "./services/mouse-handling";
2828
import {registerSelectionHandling} from "./services/selection-handling";
2929
import {EditorApi} from "./api/api";
3030
import {registerMentions} from "./services/mentions";
31+
import {MentionDecorator} from "./ui/decorators/MentionDecorator";
3132

3233
const theme = {
3334
text: {
@@ -151,7 +152,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
151152
});
152153

153154
// TODO - Dedupe this with the basic editor instance
154-
// Changed elements: namespace, registerMentions, toolbar, public event usage
155+
// Changed elements: namespace, registerMentions, toolbar, public event usage, mentioned decorator
155156
const context: EditorUiContext = buildEditorUI(container, editor, options);
156157
editor.setRootElement(context.editorDOM);
157158

@@ -168,6 +169,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
168169
context.manager.registerContextToolbar('link', contextToolbars.link);
169170
context.manager.registerModal('link', modals.link);
170171
context.manager.onTeardown(editorTeardown);
172+
context.manager.registerDecoratorType('mention', MentionDecorator);
171173

172174
setEditorContentFromHtml(editor, htmlContent);
173175

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

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
import {
2+
DecoratorNode,
23
DOMConversion,
3-
DOMConversionMap, DOMConversionOutput,
4+
DOMConversionMap, DOMConversionOutput, DOMExportOutput,
45
type EditorConfig,
5-
ElementNode,
66
LexicalEditor, LexicalNode,
7-
SerializedElementNode,
7+
SerializedLexicalNode,
88
Spread
99
} from "lexical";
10+
import {EditorDecoratorAdapter} from "../../ui/framework/decorator";
1011

1112
export type SerializedMentionNode = Spread<{
1213
user_id: number;
1314
user_name: string;
1415
user_slug: string;
15-
}, SerializedElementNode>
16+
}, SerializedLexicalNode>
1617

17-
export class MentionNode extends ElementNode {
18+
export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
1819
__user_id: number = 0;
1920
__user_name: string = '';
2021
__user_slug: string = '';
2122

2223
static getType(): string {
2324
return 'mention';
2425
}
25-
2626
static clone(node: MentionNode): MentionNode {
2727
const newNode = new MentionNode(node.__key);
2828
newNode.__user_id = node.__user_id;
@@ -42,34 +42,55 @@ export class MentionNode extends ElementNode {
4242
return true;
4343
}
4444

45+
isParentRequired(): boolean {
46+
return true;
47+
}
48+
49+
decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
50+
return {
51+
type: 'mention',
52+
getNode: () => this,
53+
};
54+
}
55+
4556
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
4657
const element = document.createElement('a');
4758
element.setAttribute('target', '_blank');
48-
element.setAttribute('href', window.baseUrl('/users/' + this.__user_slug));
49-
element.setAttribute('data-user-mention-id', String(this.__user_id));
59+
element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug));
60+
element.setAttribute('data-mention-user-id', String(this.__user_id));
5061
element.textContent = '@' + this.__user_name;
62+
// element.setAttribute('contenteditable', 'false');
5163
return element;
5264
}
5365

5466
updateDOM(prevNode: MentionNode): boolean {
5567
return prevNode.__user_id !== this.__user_id;
5668
}
5769

70+
exportDOM(editor: LexicalEditor): DOMExportOutput {
71+
const element = this.createDOM(editor._config, editor);
72+
// element.removeAttribute('contenteditable');
73+
return {element};
74+
}
75+
5876
static importDOM(): DOMConversionMap|null {
5977
return {
6078
a(node: HTMLElement): DOMConversion|null {
61-
if (node.hasAttribute('data-user-mention-id')) {
79+
if (node.hasAttribute('data-mention-user-id')) {
6280
return {
6381
conversion: (element: HTMLElement): DOMConversionOutput|null => {
6482
const node = new MentionNode();
6583
node.setUserDetails(
66-
Number(element.getAttribute('data-user-mention-id') || '0'),
84+
Number(element.getAttribute('data-mention-user-id') || '0'),
6785
element.innerText.replace(/^@/, ''),
6886
element.getAttribute('href')?.split('/user/')[1] || ''
6987
);
7088

7189
return {
7290
node,
91+
after(childNodes): LexicalNode[] {
92+
return [];
93+
}
7394
};
7495
},
7596
priority: 4,
@@ -82,7 +103,6 @@ export class MentionNode extends ElementNode {
82103

83104
exportJSON(): SerializedMentionNode {
84105
return {
85-
...super.exportJSON(),
86106
type: 'mention',
87107
version: 1,
88108
user_id: this.__user_id,

resources/js/wysiwyg/services/mentions.ts

Lines changed: 4 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import {
2-
$createTextNode,
32
$getSelection, $isRangeSelection,
43
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
54
} from "lexical";
65
import {KEY_AT_COMMAND} from "lexical/LexicalCommands";
76
import {$createMentionNode} from "@lexical/link/LexicalMentionNode";
8-
import {el, htmlToDom} from "../utils/dom";
97
import {EditorUiContext} from "../ui/framework/core";
10-
import {debounce} from "../../services/util";
11-
import {removeLoading, showLoading} from "../../services/dom";
8+
import {MentionDecorator} from "../ui/decorators/MentionDecorator";
129

1310

1411
function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) {
@@ -32,128 +29,15 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection
3229

3330
const mention = $createMentionNode(0, '', '');
3431
newNode.replace(mention);
35-
mention.select();
36-
37-
const revertEditorMention = () => {
38-
context.editor.update(() => {
39-
const text = $createTextNode('@');
40-
mention.replace(text);
41-
text.selectEnd();
42-
});
43-
};
4432

4533
requestAnimationFrame(() => {
46-
const mentionDOM = context.editor.getElementByKey(mention.getKey());
47-
if (!mentionDOM) {
48-
revertEditorMention();
49-
return;
50-
}
51-
52-
const selectList = buildAndShowUserSelectorAtElement(context, mentionDOM);
53-
handleUserListLoading(selectList);
54-
handleUserSelectCancel(context, selectList, revertEditorMention);
55-
});
56-
57-
58-
// TODO - On enter, replace with name mention element.
59-
}
60-
61-
function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, revertEditorMention: () => void) {
62-
const controller = new AbortController();
63-
64-
const onCancel = () => {
65-
revertEditorMention();
66-
selectList.remove();
67-
controller.abort();
68-
}
69-
70-
selectList.addEventListener('keydown', (event) => {
71-
if (event.key === 'Escape') {
72-
onCancel();
73-
}
74-
}, {signal: controller.signal});
75-
76-
const input = selectList.querySelector('input') as HTMLInputElement;
77-
input.addEventListener('keydown', (event) => {
78-
if (event.key === 'Backspace' && input.value === '') {
79-
onCancel();
80-
event.preventDefault();
81-
event.stopPropagation();
82-
}
83-
}, {signal: controller.signal});
84-
85-
context.editorDOM.addEventListener('click', (event) => {
86-
onCancel()
87-
}, {signal: controller.signal});
88-
context.editorDOM.addEventListener('keydown', (event) => {
89-
onCancel();
90-
}, {signal: controller.signal});
91-
}
92-
93-
function handleUserListLoading(selectList: HTMLElement) {
94-
const cache = new Map<string, string>();
95-
96-
const updateUserList = async (searchTerm: string) => {
97-
// Empty list
98-
for (const child of [...selectList.children].slice(1)) {
99-
child.remove();
100-
}
101-
102-
// Fetch new content
103-
let responseHtml = '';
104-
if (cache.has(searchTerm)) {
105-
responseHtml = cache.get(searchTerm) || '';
106-
} else {
107-
const loadingWrap = el('li');
108-
showLoading(loadingWrap);
109-
selectList.appendChild(loadingWrap);
110-
111-
const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`);
112-
responseHtml = resp.data as string;
113-
cache.set(searchTerm, responseHtml);
114-
loadingWrap.remove();
34+
const mentionDecorator = context.manager.getDecoratorByNodeKey(mention.getKey());
35+
if (mentionDecorator instanceof MentionDecorator) {
36+
mentionDecorator.showSelection()
11537
}
116-
117-
const doc = htmlToDom(responseHtml);
118-
const toInsert = doc.querySelectorAll('li');
119-
for (const listEl of toInsert) {
120-
const adopted = window.document.adoptNode(listEl) as HTMLElement;
121-
selectList.appendChild(adopted);
122-
}
123-
124-
};
125-
126-
// Initial load
127-
updateUserList('');
128-
129-
const input = selectList.querySelector('input') as HTMLInputElement;
130-
const updateUserListDebounced = debounce(updateUserList, 200, false);
131-
input.addEventListener('input', () => {
132-
const searchTerm = input.value;
133-
updateUserListDebounced(searchTerm);
13438
});
13539
}
13640

137-
function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement {
138-
const searchInput = el('input', {type: 'text'});
139-
const searchItem = el('li', {}, [searchInput]);
140-
const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]);
141-
142-
context.containerDOM.appendChild(userSelect);
143-
144-
userSelect.style.display = 'block';
145-
userSelect.style.top = '0';
146-
userSelect.style.left = '0';
147-
const mentionPos = mentionDOM.getBoundingClientRect();
148-
const userSelectPos = userSelect.getBoundingClientRect();
149-
userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`;
150-
userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`;
151-
152-
searchInput.focus();
153-
154-
return userSelect;
155-
}
156-
15741
export function registerMentions(context: EditorUiContext): () => void {
15842
const editor = context.editor;
15943

resources/js/wysiwyg/ui/decorators/code-block.ts renamed to resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class CodeBlockDecorator extends EditorDecorator {
1414
// @ts-ignore
1515
protected editor: any = null;
1616

17-
setup(context: EditorUiContext, element: HTMLElement) {
17+
setup(element: HTMLElement) {
1818
const codeNode = this.getNode() as CodeBlockNode;
1919
const preEl = element.querySelector('pre');
2020
if (!preEl) {
@@ -35,24 +35,24 @@ export class CodeBlockDecorator extends EditorDecorator {
3535

3636
element.addEventListener('click', event => {
3737
requestAnimationFrame(() => {
38-
context.editor.update(() => {
38+
this.context.editor.update(() => {
3939
$selectSingleNode(this.getNode());
4040
});
4141
});
4242
});
4343

4444
element.addEventListener('dblclick', event => {
45-
context.editor.getEditorState().read(() => {
46-
$openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode));
45+
this.context.editor.getEditorState().read(() => {
46+
$openCodeEditorForNode(this.context.editor, (this.getNode() as CodeBlockNode));
4747
});
4848
});
4949

5050
const selectionChange = (selection: BaseSelection|null): void => {
5151
element.classList.toggle('selected', $selectionContainsNode(selection, codeNode));
5252
};
53-
context.manager.onSelectionChange(selectionChange);
53+
this.context.manager.onSelectionChange(selectionChange);
5454
this.onDestroy(() => {
55-
context.manager.offSelectionChange(selectionChange);
55+
this.context.manager.offSelectionChange(selectionChange);
5656
});
5757

5858
// @ts-ignore
@@ -89,11 +89,11 @@ export class CodeBlockDecorator extends EditorDecorator {
8989
}
9090
}
9191

92-
render(context: EditorUiContext, element: HTMLElement): void {
92+
render(element: HTMLElement): void {
9393
if (this.completedSetup) {
9494
this.update();
9595
} else {
96-
this.setup(context, element);
96+
this.setup(element);
9797
}
9898
}
9999
}

0 commit comments

Comments
 (0)