1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-12-19 10:42:29 +03:00

Lexical: Created mention node, started mention service, split comment editor out

This commit is contained in:
Dan Brown
2025-12-08 15:52:21 +00:00
parent 1ee5711435
commit 50540e23a1
6 changed files with 180 additions and 5 deletions

View File

@@ -2,7 +2,7 @@ import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom'; import {getLoading, htmlToDom} from '../services/dom';
import {PageCommentReference} from "./page-comment-reference"; import {PageCommentReference} from "./page-comment-reference";
import {HttpError} from "../services/http"; import {HttpError} from "../services/http";
import {SimpleWysiwygEditorInterface} from "../wysiwyg"; import {createCommentEditorInstance, SimpleWysiwygEditorInterface} from "../wysiwyg";
import {el} from "../wysiwyg/utils/dom"; import {el} from "../wysiwyg/utils/dom";
export interface PageCommentReplyEventData { export interface PageCommentReplyEventData {
@@ -104,7 +104,7 @@ export class PageComment extends Component {
this.input.parentElement?.appendChild(container); this.input.parentElement?.appendChild(container);
this.input.hidden = true; this.input.hidden = true;
this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, editorContent, { this.wysiwygEditor = wysiwygModule.createCommentEditorInstance(container as HTMLElement, editorContent, {
darkMode: document.documentElement.classList.contains('dark-mode'), darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.$opts.textDirection, textDirection: this.$opts.textDirection,
translations: (window as unknown as Record<string, Object>).editor_translations, translations: (window as unknown as Record<string, Object>).editor_translations,

View File

@@ -5,7 +5,7 @@ import {PageCommentReference} from "./page-comment-reference";
import {scrollAndHighlightElement} from "../services/util"; import {scrollAndHighlightElement} from "../services/util";
import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment"; import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment";
import {el} from "../wysiwyg/utils/dom"; import {el} from "../wysiwyg/utils/dom";
import {SimpleWysiwygEditorInterface} from "../wysiwyg"; import {createCommentEditorInstance, SimpleWysiwygEditorInterface} from "../wysiwyg";
export class PageComments extends Component { export class PageComments extends Component {
@@ -200,7 +200,7 @@ export class PageComments extends Component {
this.formInput.parentElement?.appendChild(container); this.formInput.parentElement?.appendChild(container);
this.formInput.hidden = true; this.formInput.hidden = true;
this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '<p></p>', { this.wysiwygEditor = wysiwygModule.createCommentEditorInstance(container as HTMLElement, '<p></p>', {
darkMode: document.documentElement.classList.contains('dark-mode'), darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.wysiwygTextDirection, textDirection: this.wysiwygTextDirection,
translations: (window as unknown as Record<string, Object>).editor_translations, translations: (window as unknown as Record<string, Object>).editor_translations,

View File

@@ -2,7 +2,12 @@ import {createEditor} from 'lexical';
import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {registerRichText} from '@lexical/rich-text'; import {registerRichText} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils'; import {mergeRegister} from '@lexical/utils';
import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; import {
getNodesForBasicEditor,
getNodesForCommentEditor,
getNodesForPageEditor,
registerCommonNodeMutationListeners
} from './nodes';
import {buildEditorUI} from "./ui"; import {buildEditorUI} from "./ui";
import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions"; import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
@@ -22,6 +27,7 @@ import {DiagramDecorator} from "./ui/decorators/diagram";
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";
const theme = { const theme = {
text: { text: {
@@ -136,6 +142,43 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s
return new SimpleWysiwygEditorInterface(context); return new SimpleWysiwygEditorInterface(context);
} }
export function createCommentEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
const editor = createEditor({
namespace: 'BookStackCommentEditor',
nodes: getNodesForCommentEditor(),
onError: console.error,
theme: theme,
});
// TODO - Dedupe this with the basic editor instance
// Changed elements: namespace, registerMentions, toolbar, public event usage
const context: EditorUiContext = buildEditorUI(container, editor, options);
editor.setRootElement(context.editorDOM);
const editorTeardown = mergeRegister(
registerRichText(editor),
registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context),
registerAutoLinks(editor),
registerMentions(editor),
);
// Register toolbars, modals & decorators
context.manager.setToolbar(getBasicEditorToolbar(context));
context.manager.registerContextToolbar('link', contextToolbars.link);
context.manager.registerModal('link', modals.link);
context.manager.onTeardown(editorTeardown);
setEditorContentFromHtml(editor, htmlContent);
window.$events.emitPublic(container, 'editor-wysiwyg::post-init', {
usage: 'comment-editor',
api: new EditorApi(context),
});
return new SimpleWysiwygEditorInterface(context);
}
export class SimpleWysiwygEditorInterface { export class SimpleWysiwygEditorInterface {
protected context: EditorUiContext; protected context: EditorUiContext;
protected onChangeListeners: (() => void)[] = []; protected onChangeListeners: (() => void)[] = [];

View File

@@ -0,0 +1,107 @@
import {
DOMConversion,
DOMConversionMap, DOMConversionOutput,
type EditorConfig,
ElementNode,
LexicalEditor, LexicalNode,
SerializedElementNode,
Spread
} from "lexical";
export type SerializedMentionNode = Spread<{
user_id: number;
user_name: string;
user_slug: string;
}, SerializedElementNode>
export class MentionNode extends ElementNode {
__user_id: number = 0;
__user_name: string = '';
__user_slug: string = '';
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
const newNode = new MentionNode(node.__key);
newNode.__user_id = node.__user_id;
newNode.__user_name = node.__user_name;
newNode.__user_slug = node.__user_slug;
return newNode;
}
setUserDetails(userId: number, userName: string, userSlug: string): void {
const self = this.getWritable();
self.__user_id = userId;
self.__user_name = userName;
self.__user_slug = userSlug;
}
isInline(): boolean {
return true;
}
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.textContent = '@' + this.__user_name;
return element;
}
updateDOM(prevNode: MentionNode): boolean {
return prevNode.__user_id !== this.__user_id;
}
static importDOM(): DOMConversionMap|null {
return {
a(node: HTMLElement): DOMConversion|null {
if (node.hasAttribute('data-user-mention-id')) {
return {
conversion: (element: HTMLElement): DOMConversionOutput|null => {
const node = new MentionNode();
node.setUserDetails(
Number(element.getAttribute('data-user-mention-id') || '0'),
element.innerText.replace(/^@/, ''),
element.getAttribute('href')?.split('/user/')[1] || ''
);
return {
node,
};
},
priority: 4,
};
}
return null;
},
};
}
exportJSON(): SerializedMentionNode {
return {
...super.exportJSON(),
type: 'mention',
version: 1,
user_id: this.__user_id,
user_name: this.__user_name,
user_slug: this.__user_slug,
};
}
static importJSON(serializedNode: SerializedMentionNode): MentionNode {
return $createMentionNode(serializedNode.user_id, serializedNode.user_name, serializedNode.user_slug);
}
}
export function $createMentionNode(userId: number, userName: string, userSlug: string) {
const node = new MentionNode();
node.setUserDetails(userId, userName, userSlug);
return node;
}
export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode {
return node instanceof MentionNode;
}

View File

@@ -19,6 +19,7 @@ import {MediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
import {CaptionNode} from "@lexical/table/LexicalCaptionNode"; import {CaptionNode} from "@lexical/table/LexicalCaptionNode";
import {MentionNode} from "@lexical/link/LexicalMentionNode";
export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] { export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
return [ return [
@@ -51,6 +52,13 @@ export function getNodesForBasicEditor(): (KlassConstructor<typeof LexicalNode>
]; ];
} }
export function getNodesForCommentEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
return [
...getNodesForBasicEditor(),
MentionNode,
];
}
export function registerCommonNodeMutationListeners(context: EditorUiContext): void { export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
const decorated = [ImageNode, CodeBlockNode, DiagramNode]; const decorated = [ImageNode, CodeBlockNode, DiagramNode];

View File

@@ -0,0 +1,17 @@
import {LexicalEditor, TextNode} from "lexical";
export function registerMentions(editor: LexicalEditor): () => void {
const unregisterTransform = editor.registerNodeTransform(TextNode, (node: TextNode) =>{
console.log(node);
// TODO - If last character is @, show autocomplete selector list of users.
// Filter list by any extra characters entered.
// On enter, replace with name mention element.
// On space/escape, hide autocomplete list.
});
return (): void => {
unregisterTransform();
};
}