mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-12-19 10:42:29 +03:00
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.
This commit is contained in:
@@ -19,7 +19,7 @@ class HtmlDescriptionFilter
|
|||||||
*/
|
*/
|
||||||
protected static array $allowedAttrsByElements = [
|
protected static array $allowedAttrsByElements = [
|
||||||
'p' => [],
|
'p' => [],
|
||||||
'a' => ['href', 'title', 'target'],
|
'a' => ['href', 'title', 'target', 'data-mention-user-id'],
|
||||||
'ol' => [],
|
'ol' => [],
|
||||||
'ul' => [],
|
'ul' => [],
|
||||||
'li' => [],
|
'li' => [],
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import {registerKeyboardHandling} from "./services/keyboard-handling";
|
|||||||
import {registerAutoLinks} from "./services/auto-links";
|
import {registerAutoLinks} from "./services/auto-links";
|
||||||
import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
|
import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
|
||||||
import {modals} from "./ui/defaults/modals";
|
import {modals} from "./ui/defaults/modals";
|
||||||
import {CodeBlockDecorator} from "./ui/decorators/code-block";
|
import {CodeBlockDecorator} from "./ui/decorators/CodeBlockDecorator";
|
||||||
import {DiagramDecorator} from "./ui/decorators/diagram";
|
import {DiagramDecorator} from "./ui/decorators/DiagramDecorator";
|
||||||
import {registerMouseHandling} from "./services/mouse-handling";
|
import {registerMouseHandling} from "./services/mouse-handling";
|
||||||
import {registerSelectionHandling} from "./services/selection-handling";
|
import {registerSelectionHandling} from "./services/selection-handling";
|
||||||
import {EditorApi} from "./api/api";
|
import {EditorApi} from "./api/api";
|
||||||
import {registerMentions} from "./services/mentions";
|
import {registerMentions} from "./services/mentions";
|
||||||
|
import {MentionDecorator} from "./ui/decorators/MentionDecorator";
|
||||||
|
|
||||||
const theme = {
|
const theme = {
|
||||||
text: {
|
text: {
|
||||||
@@ -151,7 +152,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
|
|||||||
});
|
});
|
||||||
|
|
||||||
// TODO - Dedupe this with the basic editor instance
|
// TODO - Dedupe this with the basic editor instance
|
||||||
// Changed elements: namespace, registerMentions, toolbar, public event usage
|
// Changed elements: namespace, registerMentions, toolbar, public event usage, mentioned decorator
|
||||||
const context: EditorUiContext = buildEditorUI(container, editor, options);
|
const context: EditorUiContext = buildEditorUI(container, editor, options);
|
||||||
editor.setRootElement(context.editorDOM);
|
editor.setRootElement(context.editorDOM);
|
||||||
|
|
||||||
@@ -168,6 +169,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
|
|||||||
context.manager.registerContextToolbar('link', contextToolbars.link);
|
context.manager.registerContextToolbar('link', contextToolbars.link);
|
||||||
context.manager.registerModal('link', modals.link);
|
context.manager.registerModal('link', modals.link);
|
||||||
context.manager.onTeardown(editorTeardown);
|
context.manager.onTeardown(editorTeardown);
|
||||||
|
context.manager.registerDecoratorType('mention', MentionDecorator);
|
||||||
|
|
||||||
setEditorContentFromHtml(editor, htmlContent);
|
setEditorContentFromHtml(editor, htmlContent);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
|
DecoratorNode,
|
||||||
DOMConversion,
|
DOMConversion,
|
||||||
DOMConversionMap, DOMConversionOutput,
|
DOMConversionMap, DOMConversionOutput, DOMExportOutput,
|
||||||
type EditorConfig,
|
type EditorConfig,
|
||||||
ElementNode,
|
|
||||||
LexicalEditor, LexicalNode,
|
LexicalEditor, LexicalNode,
|
||||||
SerializedElementNode,
|
SerializedLexicalNode,
|
||||||
Spread
|
Spread
|
||||||
} from "lexical";
|
} from "lexical";
|
||||||
|
import {EditorDecoratorAdapter} from "../../ui/framework/decorator";
|
||||||
|
|
||||||
export type SerializedMentionNode = Spread<{
|
export type SerializedMentionNode = Spread<{
|
||||||
user_id: number;
|
user_id: number;
|
||||||
user_name: string;
|
user_name: string;
|
||||||
user_slug: string;
|
user_slug: string;
|
||||||
}, SerializedElementNode>
|
}, SerializedLexicalNode>
|
||||||
|
|
||||||
export class MentionNode extends ElementNode {
|
export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
|
||||||
__user_id: number = 0;
|
__user_id: number = 0;
|
||||||
__user_name: string = '';
|
__user_name: string = '';
|
||||||
__user_slug: string = '';
|
__user_slug: string = '';
|
||||||
@@ -22,7 +23,6 @@ export class MentionNode extends ElementNode {
|
|||||||
static getType(): string {
|
static getType(): string {
|
||||||
return 'mention';
|
return 'mention';
|
||||||
}
|
}
|
||||||
|
|
||||||
static clone(node: MentionNode): MentionNode {
|
static clone(node: MentionNode): MentionNode {
|
||||||
const newNode = new MentionNode(node.__key);
|
const newNode = new MentionNode(node.__key);
|
||||||
newNode.__user_id = node.__user_id;
|
newNode.__user_id = node.__user_id;
|
||||||
@@ -42,12 +42,24 @@ export class MentionNode extends ElementNode {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isParentRequired(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
|
||||||
|
return {
|
||||||
|
type: 'mention',
|
||||||
|
getNode: () => this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
|
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
|
||||||
const element = document.createElement('a');
|
const element = document.createElement('a');
|
||||||
element.setAttribute('target', '_blank');
|
element.setAttribute('target', '_blank');
|
||||||
element.setAttribute('href', window.baseUrl('/users/' + this.__user_slug));
|
element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug));
|
||||||
element.setAttribute('data-user-mention-id', String(this.__user_id));
|
element.setAttribute('data-mention-user-id', String(this.__user_id));
|
||||||
element.textContent = '@' + this.__user_name;
|
element.textContent = '@' + this.__user_name;
|
||||||
|
// element.setAttribute('contenteditable', 'false');
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,21 +67,30 @@ export class MentionNode extends ElementNode {
|
|||||||
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 {
|
||||||
if (node.hasAttribute('data-user-mention-id')) {
|
if (node.hasAttribute('data-mention-user-id')) {
|
||||||
return {
|
return {
|
||||||
conversion: (element: HTMLElement): DOMConversionOutput|null => {
|
conversion: (element: HTMLElement): DOMConversionOutput|null => {
|
||||||
const node = new MentionNode();
|
const node = new MentionNode();
|
||||||
node.setUserDetails(
|
node.setUserDetails(
|
||||||
Number(element.getAttribute('data-user-mention-id') || '0'),
|
Number(element.getAttribute('data-mention-user-id') || '0'),
|
||||||
element.innerText.replace(/^@/, ''),
|
element.innerText.replace(/^@/, ''),
|
||||||
element.getAttribute('href')?.split('/user/')[1] || ''
|
element.getAttribute('href')?.split('/user/')[1] || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
node,
|
node,
|
||||||
|
after(childNodes): LexicalNode[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
priority: 4,
|
priority: 4,
|
||||||
@@ -82,7 +103,6 @@ export class MentionNode extends ElementNode {
|
|||||||
|
|
||||||
exportJSON(): SerializedMentionNode {
|
exportJSON(): SerializedMentionNode {
|
||||||
return {
|
return {
|
||||||
...super.exportJSON(),
|
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
version: 1,
|
version: 1,
|
||||||
user_id: this.__user_id,
|
user_id: this.__user_id,
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
$createTextNode,
|
|
||||||
$getSelection, $isRangeSelection,
|
$getSelection, $isRangeSelection,
|
||||||
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
|
COMMAND_PRIORITY_NORMAL, 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} from "@lexical/link/LexicalMentionNode";
|
||||||
import {el, htmlToDom} from "../utils/dom";
|
|
||||||
import {EditorUiContext} from "../ui/framework/core";
|
import {EditorUiContext} from "../ui/framework/core";
|
||||||
import {debounce} from "../../services/util";
|
import {MentionDecorator} from "../ui/decorators/MentionDecorator";
|
||||||
import {removeLoading, showLoading} from "../../services/dom";
|
|
||||||
|
|
||||||
|
|
||||||
function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) {
|
function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) {
|
||||||
@@ -32,126 +29,13 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection
|
|||||||
|
|
||||||
const mention = $createMentionNode(0, '', '');
|
const mention = $createMentionNode(0, '', '');
|
||||||
newNode.replace(mention);
|
newNode.replace(mention);
|
||||||
mention.select();
|
|
||||||
|
|
||||||
const revertEditorMention = () => {
|
|
||||||
context.editor.update(() => {
|
|
||||||
const text = $createTextNode('@');
|
|
||||||
mention.replace(text);
|
|
||||||
text.selectEnd();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const mentionDOM = context.editor.getElementByKey(mention.getKey());
|
const mentionDecorator = context.manager.getDecoratorByNodeKey(mention.getKey());
|
||||||
if (!mentionDOM) {
|
if (mentionDecorator instanceof MentionDecorator) {
|
||||||
revertEditorMention();
|
mentionDecorator.showSelection()
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectList = buildAndShowUserSelectorAtElement(context, mentionDOM);
|
|
||||||
handleUserListLoading(selectList);
|
|
||||||
handleUserSelectCancel(context, selectList, revertEditorMention);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// TODO - On enter, replace with name mention element.
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, revertEditorMention: () => void) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
revertEditorMention();
|
|
||||||
selectList.remove();
|
|
||||||
controller.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
selectList.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}, {signal: controller.signal});
|
|
||||||
|
|
||||||
const input = selectList.querySelector('input') as HTMLInputElement;
|
|
||||||
input.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Backspace' && input.value === '') {
|
|
||||||
onCancel();
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}, {signal: controller.signal});
|
|
||||||
|
|
||||||
context.editorDOM.addEventListener('click', (event) => {
|
|
||||||
onCancel()
|
|
||||||
}, {signal: controller.signal});
|
|
||||||
context.editorDOM.addEventListener('keydown', (event) => {
|
|
||||||
onCancel();
|
|
||||||
}, {signal: controller.signal});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUserListLoading(selectList: HTMLElement) {
|
|
||||||
const cache = new Map<string, string>();
|
|
||||||
|
|
||||||
const updateUserList = async (searchTerm: string) => {
|
|
||||||
// Empty list
|
|
||||||
for (const child of [...selectList.children].slice(1)) {
|
|
||||||
child.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch new content
|
|
||||||
let responseHtml = '';
|
|
||||||
if (cache.has(searchTerm)) {
|
|
||||||
responseHtml = cache.get(searchTerm) || '';
|
|
||||||
} else {
|
|
||||||
const loadingWrap = el('li');
|
|
||||||
showLoading(loadingWrap);
|
|
||||||
selectList.appendChild(loadingWrap);
|
|
||||||
|
|
||||||
const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`);
|
|
||||||
responseHtml = resp.data as string;
|
|
||||||
cache.set(searchTerm, responseHtml);
|
|
||||||
loadingWrap.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = htmlToDom(responseHtml);
|
|
||||||
const toInsert = doc.querySelectorAll('li');
|
|
||||||
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 updateUserListDebounced = debounce(updateUserList, 200, false);
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
const searchTerm = input.value;
|
|
||||||
updateUserListDebounced(searchTerm);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
context.containerDOM.appendChild(userSelect);
|
|
||||||
|
|
||||||
userSelect.style.display = 'block';
|
|
||||||
userSelect.style.top = '0';
|
|
||||||
userSelect.style.left = '0';
|
|
||||||
const mentionPos = mentionDOM.getBoundingClientRect();
|
|
||||||
const userSelectPos = userSelect.getBoundingClientRect();
|
|
||||||
userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`;
|
|
||||||
userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`;
|
|
||||||
|
|
||||||
searchInput.focus();
|
|
||||||
|
|
||||||
return userSelect;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerMentions(context: EditorUiContext): () => void {
|
export function registerMentions(context: EditorUiContext): () => void {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class CodeBlockDecorator extends EditorDecorator {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
protected editor: any = null;
|
protected editor: any = null;
|
||||||
|
|
||||||
setup(context: EditorUiContext, element: HTMLElement) {
|
setup(element: HTMLElement) {
|
||||||
const codeNode = this.getNode() as CodeBlockNode;
|
const codeNode = this.getNode() as CodeBlockNode;
|
||||||
const preEl = element.querySelector('pre');
|
const preEl = element.querySelector('pre');
|
||||||
if (!preEl) {
|
if (!preEl) {
|
||||||
@@ -35,24 +35,24 @@ export class CodeBlockDecorator extends EditorDecorator {
|
|||||||
|
|
||||||
element.addEventListener('click', event => {
|
element.addEventListener('click', event => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
context.editor.update(() => {
|
this.context.editor.update(() => {
|
||||||
$selectSingleNode(this.getNode());
|
$selectSingleNode(this.getNode());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
element.addEventListener('dblclick', event => {
|
element.addEventListener('dblclick', event => {
|
||||||
context.editor.getEditorState().read(() => {
|
this.context.editor.getEditorState().read(() => {
|
||||||
$openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode));
|
$openCodeEditorForNode(this.context.editor, (this.getNode() as CodeBlockNode));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectionChange = (selection: BaseSelection|null): void => {
|
const selectionChange = (selection: BaseSelection|null): void => {
|
||||||
element.classList.toggle('selected', $selectionContainsNode(selection, codeNode));
|
element.classList.toggle('selected', $selectionContainsNode(selection, codeNode));
|
||||||
};
|
};
|
||||||
context.manager.onSelectionChange(selectionChange);
|
this.context.manager.onSelectionChange(selectionChange);
|
||||||
this.onDestroy(() => {
|
this.onDestroy(() => {
|
||||||
context.manager.offSelectionChange(selectionChange);
|
this.context.manager.offSelectionChange(selectionChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -89,11 +89,11 @@ export class CodeBlockDecorator extends EditorDecorator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render(context: EditorUiContext, element: HTMLElement): void {
|
render(element: HTMLElement): void {
|
||||||
if (this.completedSetup) {
|
if (this.completedSetup) {
|
||||||
this.update();
|
this.update();
|
||||||
} else {
|
} else {
|
||||||
this.setup(context, element);
|
this.setup(element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,33 +9,33 @@ import {$openDrawingEditorForNode} from "../../utils/diagrams";
|
|||||||
export class DiagramDecorator extends EditorDecorator {
|
export class DiagramDecorator extends EditorDecorator {
|
||||||
protected completedSetup: boolean = false;
|
protected completedSetup: boolean = false;
|
||||||
|
|
||||||
setup(context: EditorUiContext, element: HTMLElement) {
|
setup(element: HTMLElement) {
|
||||||
const diagramNode = this.getNode();
|
const diagramNode = this.getNode();
|
||||||
element.classList.add('editor-diagram');
|
element.classList.add('editor-diagram');
|
||||||
|
|
||||||
context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => {
|
this.context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => {
|
||||||
if (!element.contains(event.target as HTMLElement)) {
|
if (!element.contains(event.target as HTMLElement)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.editor.update(() => {
|
this.context.editor.update(() => {
|
||||||
$selectSingleNode(this.getNode());
|
$selectSingleNode(this.getNode());
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}, COMMAND_PRIORITY_NORMAL);
|
}, COMMAND_PRIORITY_NORMAL);
|
||||||
|
|
||||||
element.addEventListener('dblclick', event => {
|
element.addEventListener('dblclick', event => {
|
||||||
context.editor.getEditorState().read(() => {
|
this.context.editor.getEditorState().read(() => {
|
||||||
$openDrawingEditorForNode(context, (this.getNode() as DiagramNode));
|
$openDrawingEditorForNode(this.context, (this.getNode() as DiagramNode));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectionChange = (selection: BaseSelection|null): void => {
|
const selectionChange = (selection: BaseSelection|null): void => {
|
||||||
element.classList.toggle('selected', $selectionContainsNode(selection, diagramNode));
|
element.classList.toggle('selected', $selectionContainsNode(selection, diagramNode));
|
||||||
};
|
};
|
||||||
context.manager.onSelectionChange(selectionChange);
|
this.context.manager.onSelectionChange(selectionChange);
|
||||||
this.onDestroy(() => {
|
this.onDestroy(() => {
|
||||||
context.manager.offSelectionChange(selectionChange);
|
this.context.manager.offSelectionChange(selectionChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.completedSetup = true;
|
this.completedSetup = true;
|
||||||
@@ -45,11 +45,11 @@ export class DiagramDecorator extends EditorDecorator {
|
|||||||
//
|
//
|
||||||
}
|
}
|
||||||
|
|
||||||
render(context: EditorUiContext, element: HTMLElement): void {
|
render(element: HTMLElement): void {
|
||||||
if (this.completedSetup) {
|
if (this.completedSetup) {
|
||||||
this.update();
|
this.update();
|
||||||
} else {
|
} else {
|
||||||
this.setup(context, element);
|
this.setup(element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
172
resources/js/wysiwyg/ui/decorators/MentionDecorator.ts
Normal file
172
resources/js/wysiwyg/ui/decorators/MentionDecorator.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import {EditorDecorator} from "../framework/decorator";
|
||||||
|
import {EditorUiContext} from "../framework/core";
|
||||||
|
import {el, htmlToDom} from "../../utils/dom";
|
||||||
|
import {showLoading} from "../../../services/dom";
|
||||||
|
import {MentionNode} from "@lexical/link/LexicalMentionNode";
|
||||||
|
import {debounce} from "../../../services/util";
|
||||||
|
import {$createTextNode} from "lexical";
|
||||||
|
|
||||||
|
function userClickHandler(onSelect: (id: number, name: string, slug: string)=>void): (event: PointerEvent) => void {
|
||||||
|
return (event: PointerEvent) => {
|
||||||
|
const userItem = (event.target as HTMLElement).closest('a[data-id]') as HTMLAnchorElement | null;
|
||||||
|
if (!userItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(userItem.dataset.id || '0');
|
||||||
|
const name = userItem.dataset.name || '';
|
||||||
|
const slug = userItem.dataset.slug || '';
|
||||||
|
|
||||||
|
onSelect(id, name, slug);
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, controller: AbortController, onCancel: () => void): void {
|
||||||
|
selectList.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, {signal: controller.signal});
|
||||||
|
|
||||||
|
const input = selectList.querySelector('input') as HTMLInputElement;
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Backspace' && input.value === '') {
|
||||||
|
onCancel();
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}, {signal: controller.signal});
|
||||||
|
|
||||||
|
context.editorDOM.addEventListener('click', (event) => {
|
||||||
|
onCancel()
|
||||||
|
}, {signal: controller.signal});
|
||||||
|
context.editorDOM.addEventListener('keydown', (event) => {
|
||||||
|
onCancel();
|
||||||
|
}, {signal: controller.signal});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserListLoading(selectList: HTMLElement) {
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
|
||||||
|
const updateUserList = async (searchTerm: string) => {
|
||||||
|
// Empty list
|
||||||
|
for (const child of [...selectList.children].slice(1)) {
|
||||||
|
child.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch new content
|
||||||
|
let responseHtml = '';
|
||||||
|
if (cache.has(searchTerm)) {
|
||||||
|
responseHtml = cache.get(searchTerm) || '';
|
||||||
|
} else {
|
||||||
|
const loadingWrap = el('li');
|
||||||
|
showLoading(loadingWrap);
|
||||||
|
selectList.appendChild(loadingWrap);
|
||||||
|
|
||||||
|
const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`);
|
||||||
|
responseHtml = resp.data as string;
|
||||||
|
cache.set(searchTerm, responseHtml);
|
||||||
|
loadingWrap.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = htmlToDom(responseHtml);
|
||||||
|
const toInsert = doc.querySelectorAll('li');
|
||||||
|
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 updateUserListDebounced = debounce(updateUserList, 200, false);
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const searchTerm = input.value;
|
||||||
|
updateUserListDebounced(searchTerm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
context.containerDOM.appendChild(userSelect);
|
||||||
|
|
||||||
|
userSelect.style.display = 'block';
|
||||||
|
userSelect.style.top = '0';
|
||||||
|
userSelect.style.left = '0';
|
||||||
|
const mentionPos = mentionDOM.getBoundingClientRect();
|
||||||
|
const userSelectPos = userSelect.getBoundingClientRect();
|
||||||
|
userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`;
|
||||||
|
userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`;
|
||||||
|
|
||||||
|
searchInput.focus();
|
||||||
|
|
||||||
|
return userSelect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MentionDecorator extends EditorDecorator {
|
||||||
|
protected completedSetup: boolean = false;
|
||||||
|
protected abortController: AbortController | null = null;
|
||||||
|
protected selectList: HTMLElement | null = null;
|
||||||
|
protected mentionElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
setup(element: HTMLElement) {
|
||||||
|
this.mentionElement = element;
|
||||||
|
this.completedSetup = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSelection() {
|
||||||
|
if (!this.mentionElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideSelection();
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
|
this.selectList = buildAndShowUserSelectorAtElement(this.context, this.mentionElement);
|
||||||
|
handleUserListLoading(this.selectList);
|
||||||
|
|
||||||
|
this.selectList.addEventListener('click', userClickHandler((id, name, slug) => {
|
||||||
|
this.context.editor.update(() => {
|
||||||
|
const mentionNode = this.getNode() as MentionNode;
|
||||||
|
this.hideSelection();
|
||||||
|
mentionNode.setUserDetails(id, name, slug);
|
||||||
|
mentionNode.selectNext();
|
||||||
|
});
|
||||||
|
}), {signal: this.abortController.signal});
|
||||||
|
|
||||||
|
handleUserSelectCancel(this.context, this.selectList, this.abortController, this.revertMention.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSelection() {
|
||||||
|
this.abortController?.abort();
|
||||||
|
this.selectList?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
revertMention() {
|
||||||
|
this.hideSelection();
|
||||||
|
this.context.editor.update(() => {
|
||||||
|
const text = $createTextNode('@');
|
||||||
|
this.getNode().replace(text);
|
||||||
|
text.selectEnd();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
render(element: HTMLElement): void {
|
||||||
|
if (this.completedSetup) {
|
||||||
|
this.update();
|
||||||
|
} else {
|
||||||
|
this.setup(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export abstract class EditorDecorator {
|
|||||||
* If an element is returned, this will be appended to the element
|
* If an element is returned, this will be appended to the element
|
||||||
* that is being decorated.
|
* that is being decorated.
|
||||||
*/
|
*/
|
||||||
abstract render(context: EditorUiContext, decorated: HTMLElement): HTMLElement|void;
|
abstract render(decorated: HTMLElement): HTMLElement|void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroy this decorator. Used for tear-down operations upon destruction
|
* Destroy this decorator. Used for tear-down operations upon destruction
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export class EditorUIManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const decorator = new decoratorClass(nodeKey);
|
const decorator = new decoratorClass(this.getContext());
|
||||||
this.decoratorInstancesByNodeKey[nodeKey] = decorator;
|
this.decoratorInstancesByNodeKey[nodeKey] = decorator;
|
||||||
return decorator;
|
return decorator;
|
||||||
}
|
}
|
||||||
@@ -262,7 +262,7 @@ export class EditorUIManager {
|
|||||||
const adapter = decorators[key];
|
const adapter = decorators[key];
|
||||||
const decorator = this.getDecorator(adapter.type, key);
|
const decorator = this.getDecorator(adapter.type, key);
|
||||||
decorator.setNode(adapter.getNode());
|
decorator.setNode(adapter.getNode());
|
||||||
const decoratorEl = decorator.render(this.getContext(), decoratedEl);
|
const decoratorEl = decorator.render(decoratedEl);
|
||||||
if (decoratorEl) {
|
if (decoratorEl) {
|
||||||
decoratedEl.append(decoratorEl);
|
decoratedEl.append(decoratorEl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,3 +199,22 @@ body .page-content img,
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a[data-mention-user-id] {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
color: var(--color-link);
|
||||||
|
padding: 0.1em 0.2em;
|
||||||
|
&:after {
|
||||||
|
content: '';
|
||||||
|
background-color: currentColor;
|
||||||
|
opacity: 0.2;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user