1
0
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:
Dan Brown
2025-12-13 17:03:48 +00:00
parent 9bf9ae9c37
commit 1e768ce33f
10 changed files with 252 additions and 155 deletions

View File

@@ -19,7 +19,7 @@ class HtmlDescriptionFilter
*/
protected static array $allowedAttrsByElements = [
'p' => [],
'a' => ['href', 'title', 'target'],
'a' => ['href', 'title', 'target', 'data-mention-user-id'],
'ol' => [],
'ul' => [],
'li' => [],

View File

@@ -22,12 +22,13 @@ import {registerKeyboardHandling} from "./services/keyboard-handling";
import {registerAutoLinks} from "./services/auto-links";
import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
import {modals} from "./ui/defaults/modals";
import {CodeBlockDecorator} from "./ui/decorators/code-block";
import {DiagramDecorator} from "./ui/decorators/diagram";
import {CodeBlockDecorator} from "./ui/decorators/CodeBlockDecorator";
import {DiagramDecorator} from "./ui/decorators/DiagramDecorator";
import {registerMouseHandling} from "./services/mouse-handling";
import {registerSelectionHandling} from "./services/selection-handling";
import {EditorApi} from "./api/api";
import {registerMentions} from "./services/mentions";
import {MentionDecorator} from "./ui/decorators/MentionDecorator";
const theme = {
text: {
@@ -151,7 +152,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
});
// 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);
editor.setRootElement(context.editorDOM);
@@ -168,6 +169,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
context.manager.registerContextToolbar('link', contextToolbars.link);
context.manager.registerModal('link', modals.link);
context.manager.onTeardown(editorTeardown);
context.manager.registerDecoratorType('mention', MentionDecorator);
setEditorContentFromHtml(editor, htmlContent);

View File

@@ -1,20 +1,21 @@
import {
DecoratorNode,
DOMConversion,
DOMConversionMap, DOMConversionOutput,
DOMConversionMap, DOMConversionOutput, DOMExportOutput,
type EditorConfig,
ElementNode,
LexicalEditor, LexicalNode,
SerializedElementNode,
SerializedLexicalNode,
Spread
} from "lexical";
import {EditorDecoratorAdapter} from "../../ui/framework/decorator";
export type SerializedMentionNode = Spread<{
user_id: number;
user_name: string;
user_slug: string;
}, SerializedElementNode>
}, SerializedLexicalNode>
export class MentionNode extends ElementNode {
export class MentionNode extends DecoratorNode<EditorDecoratorAdapter> {
__user_id: number = 0;
__user_name: string = '';
__user_slug: string = '';
@@ -22,7 +23,6 @@ export class MentionNode extends ElementNode {
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
const newNode = new MentionNode(node.__key);
newNode.__user_id = node.__user_id;
@@ -42,12 +42,24 @@ export class MentionNode extends ElementNode {
return true;
}
isParentRequired(): boolean {
return true;
}
decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
return {
type: 'mention',
getNode: () => this,
};
}
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
const element = document.createElement('a');
element.setAttribute('target', '_blank');
element.setAttribute('href', window.baseUrl('/users/' + this.__user_slug));
element.setAttribute('data-user-mention-id', String(this.__user_id));
element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug));
element.setAttribute('data-mention-user-id', String(this.__user_id));
element.textContent = '@' + this.__user_name;
// element.setAttribute('contenteditable', 'false');
return element;
}
@@ -55,21 +67,30 @@ export class MentionNode extends ElementNode {
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 {
if (node.hasAttribute('data-user-mention-id')) {
if (node.hasAttribute('data-mention-user-id')) {
return {
conversion: (element: HTMLElement): DOMConversionOutput|null => {
const node = new MentionNode();
node.setUserDetails(
Number(element.getAttribute('data-user-mention-id') || '0'),
Number(element.getAttribute('data-mention-user-id') || '0'),
element.innerText.replace(/^@/, ''),
element.getAttribute('href')?.split('/user/')[1] || ''
);
return {
node,
after(childNodes): LexicalNode[] {
return [];
}
};
},
priority: 4,
@@ -82,7 +103,6 @@ export class MentionNode extends ElementNode {
exportJSON(): SerializedMentionNode {
return {
...super.exportJSON(),
type: 'mention',
version: 1,
user_id: this.__user_id,

View File

@@ -1,14 +1,11 @@
import {
$createTextNode,
$getSelection, $isRangeSelection,
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
} from "lexical";
import {KEY_AT_COMMAND} from "lexical/LexicalCommands";
import {$createMentionNode} from "@lexical/link/LexicalMentionNode";
import {el, htmlToDom} from "../utils/dom";
import {EditorUiContext} from "../ui/framework/core";
import {debounce} from "../../services/util";
import {removeLoading, showLoading} from "../../services/dom";
import {MentionDecorator} from "../ui/decorators/MentionDecorator";
function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) {
@@ -32,126 +29,13 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection
const mention = $createMentionNode(0, '', '');
newNode.replace(mention);
mention.select();
const revertEditorMention = () => {
context.editor.update(() => {
const text = $createTextNode('@');
mention.replace(text);
text.selectEnd();
});
};
requestAnimationFrame(() => {
const mentionDOM = context.editor.getElementByKey(mention.getKey());
if (!mentionDOM) {
revertEditorMention();
return;
const mentionDecorator = context.manager.getDecoratorByNodeKey(mention.getKey());
if (mentionDecorator instanceof MentionDecorator) {
mentionDecorator.showSelection()
}
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 {

View File

@@ -14,7 +14,7 @@ export class CodeBlockDecorator extends EditorDecorator {
// @ts-ignore
protected editor: any = null;
setup(context: EditorUiContext, element: HTMLElement) {
setup(element: HTMLElement) {
const codeNode = this.getNode() as CodeBlockNode;
const preEl = element.querySelector('pre');
if (!preEl) {
@@ -35,24 +35,24 @@ export class CodeBlockDecorator extends EditorDecorator {
element.addEventListener('click', event => {
requestAnimationFrame(() => {
context.editor.update(() => {
this.context.editor.update(() => {
$selectSingleNode(this.getNode());
});
});
});
element.addEventListener('dblclick', event => {
context.editor.getEditorState().read(() => {
$openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode));
this.context.editor.getEditorState().read(() => {
$openCodeEditorForNode(this.context.editor, (this.getNode() as CodeBlockNode));
});
});
const selectionChange = (selection: BaseSelection|null): void => {
element.classList.toggle('selected', $selectionContainsNode(selection, codeNode));
};
context.manager.onSelectionChange(selectionChange);
this.context.manager.onSelectionChange(selectionChange);
this.onDestroy(() => {
context.manager.offSelectionChange(selectionChange);
this.context.manager.offSelectionChange(selectionChange);
});
// @ts-ignore
@@ -89,11 +89,11 @@ export class CodeBlockDecorator extends EditorDecorator {
}
}
render(context: EditorUiContext, element: HTMLElement): void {
render(element: HTMLElement): void {
if (this.completedSetup) {
this.update();
} else {
this.setup(context, element);
this.setup(element);
}
}
}

View File

@@ -9,33 +9,33 @@ import {$openDrawingEditorForNode} from "../../utils/diagrams";
export class DiagramDecorator extends EditorDecorator {
protected completedSetup: boolean = false;
setup(context: EditorUiContext, element: HTMLElement) {
setup(element: HTMLElement) {
const diagramNode = this.getNode();
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)) {
return false;
}
context.editor.update(() => {
this.context.editor.update(() => {
$selectSingleNode(this.getNode());
});
return true;
}, COMMAND_PRIORITY_NORMAL);
element.addEventListener('dblclick', event => {
context.editor.getEditorState().read(() => {
$openDrawingEditorForNode(context, (this.getNode() as DiagramNode));
this.context.editor.getEditorState().read(() => {
$openDrawingEditorForNode(this.context, (this.getNode() as DiagramNode));
});
});
const selectionChange = (selection: BaseSelection|null): void => {
element.classList.toggle('selected', $selectionContainsNode(selection, diagramNode));
};
context.manager.onSelectionChange(selectionChange);
this.context.manager.onSelectionChange(selectionChange);
this.onDestroy(() => {
context.manager.offSelectionChange(selectionChange);
this.context.manager.offSelectionChange(selectionChange);
});
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) {
this.update();
} else {
this.setup(context, element);
this.setup(element);
}
}
}

View 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);
}
}
}

View File

@@ -42,7 +42,7 @@ export abstract class EditorDecorator {
* If an element is returned, this will be appended to the element
* 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

View File

@@ -90,7 +90,7 @@ export class EditorUIManager {
}
// @ts-ignore
const decorator = new decoratorClass(nodeKey);
const decorator = new decoratorClass(this.getContext());
this.decoratorInstancesByNodeKey[nodeKey] = decorator;
return decorator;
}
@@ -262,7 +262,7 @@ export class EditorUIManager {
const adapter = decorators[key];
const decorator = this.getDecorator(adapter.type, key);
decorator.setNode(adapter.getNode());
const decoratorEl = decorator.render(this.getContext(), decoratedEl);
const decoratorEl = decorator.render(decoratedEl);
if (decoratorEl) {
decoratedEl.append(decoratorEl);
}

View File

@@ -198,4 +198,23 @@ body .page-content img,
color: inherit;
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;
}
}