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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)[] = [];
|
||||||
|
|||||||
107
resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts
Normal file
107
resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
17
resources/js/wysiwyg/services/mentions.ts
Normal file
17
resources/js/wysiwyg/services/mentions.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user