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 = [ protected static array $allowedAttrsByElements = [
'p' => [], 'p' => [],
'a' => ['href', 'title', 'target'], 'a' => ['href', 'title', 'target', 'data-mention-user-id'],
'ol' => [], 'ol' => [],
'ul' => [], 'ul' => [],
'li' => [], 'li' => [],

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 {

View File

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

View File

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

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 * 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

View File

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

View File

@@ -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;
}
}