mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-30 04:23:11 +03:00
Comments: Switched to lexical editor
Required a lot of changes to provide at least a decent attempt at proper editor teardown control. Also updates HtmlDescriptionFilter and testing to address issue with bad child iteration which could lead to missed items. Renamed editor version from comments to basic as it'll also be used for item descriptions.
This commit is contained in:
@ -2,9 +2,9 @@ import {createEditor, LexicalEditor} from 'lexical';
|
||||
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
|
||||
import {registerRichText} from '@lexical/rich-text';
|
||||
import {mergeRegister} from '@lexical/utils';
|
||||
import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
|
||||
import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
|
||||
import {buildEditorUI} from "./ui";
|
||||
import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
|
||||
import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
|
||||
import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
|
||||
import {EditorUiContext} from "./ui/framework/core";
|
||||
import {listen as listenToCommonEvents} from "./services/common-events";
|
||||
@ -15,7 +15,7 @@ import {registerShortcuts} from "./services/shortcuts";
|
||||
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
|
||||
import {registerKeyboardHandling} from "./services/keyboard-handling";
|
||||
import {registerAutoLinks} from "./services/auto-links";
|
||||
import {contextToolbars, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
|
||||
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";
|
||||
@ -90,20 +90,20 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
|
||||
|
||||
registerCommonNodeMutationListeners(context);
|
||||
|
||||
return new SimpleWysiwygEditorInterface(editor);
|
||||
return new SimpleWysiwygEditorInterface(context);
|
||||
}
|
||||
|
||||
export function createCommentEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
|
||||
export function createBasicEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
|
||||
const editor = createEditor({
|
||||
namespace: 'BookStackCommentEditor',
|
||||
nodes: getNodesForPageEditor(),
|
||||
namespace: 'BookStackBasicEditor',
|
||||
nodes: getNodesForBasicEditor(),
|
||||
onError: console.error,
|
||||
theme: theme,
|
||||
});
|
||||
const context: EditorUiContext = buildEditorUI(container, editor, options);
|
||||
editor.setRootElement(context.editorDOM);
|
||||
|
||||
mergeRegister(
|
||||
const editorTeardown = mergeRegister(
|
||||
registerRichText(editor),
|
||||
registerHistory(editor, createEmptyHistoryState(), 300),
|
||||
registerShortcuts(context),
|
||||
@ -111,23 +111,33 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
|
||||
);
|
||||
|
||||
// Register toolbars, modals & decorators
|
||||
context.manager.setToolbar(getMainEditorFullToolbar(context)); // TODO - Create comment toolbar
|
||||
context.manager.setToolbar(getBasicEditorToolbar(context));
|
||||
context.manager.registerContextToolbar('link', contextToolbars.link);
|
||||
context.manager.registerModal('link', modals.link);
|
||||
context.manager.onTeardown(editorTeardown);
|
||||
|
||||
setEditorContentFromHtml(editor, htmlContent);
|
||||
|
||||
return new SimpleWysiwygEditorInterface(editor);
|
||||
return new SimpleWysiwygEditorInterface(context);
|
||||
}
|
||||
|
||||
export class SimpleWysiwygEditorInterface {
|
||||
protected editor: LexicalEditor;
|
||||
protected context: EditorUiContext;
|
||||
|
||||
constructor(editor: LexicalEditor) {
|
||||
this.editor = editor;
|
||||
constructor(context: EditorUiContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async getContentAsHtml(): Promise<string> {
|
||||
return await getEditorContentAsHtml(this.editor);
|
||||
return await getEditorContentAsHtml(this.context.editor);
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
focusEditor(this.context.editor);
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.context.editorDOM.remove();
|
||||
this.context.manager.teardown();
|
||||
}
|
||||
}
|
@ -20,9 +20,6 @@ import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
|
||||
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
|
||||
import {CaptionNode} from "@lexical/table/LexicalCaptionNode";
|
||||
|
||||
/**
|
||||
* Load the nodes for lexical.
|
||||
*/
|
||||
export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
|
||||
return [
|
||||
CalloutNode,
|
||||
@ -45,6 +42,15 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
|
||||
];
|
||||
}
|
||||
|
||||
export function getNodesForBasicEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
|
||||
return [
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
ParagraphNode,
|
||||
LinkNode,
|
||||
];
|
||||
}
|
||||
|
||||
export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
|
||||
const decorated = [ImageNode, CodeBlockNode, DiagramNode];
|
||||
|
||||
@ -53,7 +59,7 @@ export function registerCommonNodeMutationListeners(context: EditorUiContext): v
|
||||
if (mutation === "destroyed") {
|
||||
const decorator = context.manager.getDecoratorByNodeKey(nodeKey);
|
||||
if (decorator) {
|
||||
decorator.destroy(context);
|
||||
decorator.teardown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -221,6 +221,16 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
|
||||
]);
|
||||
}
|
||||
|
||||
export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement {
|
||||
return new EditorSimpleClassContainer('editor-toolbar-main', [
|
||||
new EditorButton(bold),
|
||||
new EditorButton(italic),
|
||||
new EditorButton(link),
|
||||
new EditorButton(bulletList),
|
||||
new EditorButton(numberList),
|
||||
]);
|
||||
}
|
||||
|
||||
export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
|
||||
image: {
|
||||
selector: 'img:not([drawio-diagram] img)',
|
||||
|
@ -30,6 +30,7 @@ export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDef
|
||||
export abstract class EditorUiElement {
|
||||
protected dom: HTMLElement|null = null;
|
||||
private context: EditorUiContext|null = null;
|
||||
private abortController: AbortController = new AbortController();
|
||||
|
||||
protected abstract buildDOM(): HTMLElement;
|
||||
|
||||
@ -79,9 +80,16 @@ export abstract class EditorUiElement {
|
||||
if (target) {
|
||||
target.addEventListener('editor::' + name, ((event: CustomEvent) => {
|
||||
callback(event.detail);
|
||||
}) as EventListener);
|
||||
}) as EventListener, { signal: this.abortController.signal });
|
||||
}
|
||||
}
|
||||
|
||||
teardown(): void {
|
||||
if (this.dom && this.dom.isConnected) {
|
||||
this.dom.remove();
|
||||
}
|
||||
this.abortController.abort('teardown');
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorContainerUiElement extends EditorUiElement {
|
||||
@ -129,6 +137,13 @@ export class EditorContainerUiElement extends EditorUiElement {
|
||||
child.setContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
teardown() {
|
||||
for (const child of this.children) {
|
||||
child.teardown();
|
||||
}
|
||||
super.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorSimpleClassContainer extends EditorContainerUiElement {
|
||||
|
@ -48,7 +48,7 @@ export abstract class EditorDecorator {
|
||||
* Destroy this decorator. Used for tear-down operations upon destruction
|
||||
* of the underlying node this decorator is attached to.
|
||||
*/
|
||||
destroy(context: EditorUiContext): void {
|
||||
teardown(): void {
|
||||
for (const callback of this.onDestroyCallbacks) {
|
||||
callback();
|
||||
}
|
||||
|
@ -41,11 +41,18 @@ export class DropDownManager {
|
||||
|
||||
constructor() {
|
||||
this.onMenuMouseOver = this.onMenuMouseOver.bind(this);
|
||||
this.onWindowClick = this.onWindowClick.bind(this);
|
||||
|
||||
window.addEventListener('click', (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
this.closeAllNotContainingElement(target);
|
||||
});
|
||||
window.addEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
teardown(): void {
|
||||
window.removeEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
protected onWindowClick(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
this.closeAllNotContainingElement(target);
|
||||
}
|
||||
|
||||
protected closeAllNotContainingElement(element: HTMLElement): void {
|
||||
|
@ -12,6 +12,8 @@ export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
|
||||
|
||||
export class EditorUIManager {
|
||||
|
||||
public dropdowns: DropDownManager = new DropDownManager();
|
||||
|
||||
protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
|
||||
protected activeModalsByKey: Record<string, EditorFormModal> = {};
|
||||
protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
|
||||
@ -21,12 +23,12 @@ export class EditorUIManager {
|
||||
protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
|
||||
protected activeContextToolbars: EditorContextToolbar[] = [];
|
||||
protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
|
||||
|
||||
public dropdowns: DropDownManager = new DropDownManager();
|
||||
protected domEventAbortController = new AbortController();
|
||||
protected teardownCallbacks: (()=>void)[] = [];
|
||||
|
||||
setContext(context: EditorUiContext) {
|
||||
this.context = context;
|
||||
this.setupEventListeners(context);
|
||||
this.setupEventListeners();
|
||||
this.setupEditor(context.editor);
|
||||
}
|
||||
|
||||
@ -99,7 +101,7 @@ export class EditorUIManager {
|
||||
|
||||
setToolbar(toolbar: EditorContainerUiElement) {
|
||||
if (this.toolbar) {
|
||||
this.toolbar.getDOMElement().remove();
|
||||
this.toolbar.teardown();
|
||||
}
|
||||
|
||||
this.toolbar = toolbar;
|
||||
@ -170,10 +172,40 @@ export class EditorUIManager {
|
||||
return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr';
|
||||
}
|
||||
|
||||
onTeardown(callback: () => void): void {
|
||||
this.teardownCallbacks.push(callback);
|
||||
}
|
||||
|
||||
teardown(): void {
|
||||
this.domEventAbortController.abort('teardown');
|
||||
|
||||
for (const [_, modal] of Object.entries(this.activeModalsByKey)) {
|
||||
modal.teardown();
|
||||
}
|
||||
|
||||
for (const [_, decorator] of Object.entries(this.decoratorInstancesByNodeKey)) {
|
||||
decorator.teardown();
|
||||
}
|
||||
|
||||
if (this.toolbar) {
|
||||
this.toolbar.teardown();
|
||||
}
|
||||
|
||||
for (const toolbar of this.activeContextToolbars) {
|
||||
toolbar.teardown();
|
||||
}
|
||||
|
||||
this.dropdowns.teardown();
|
||||
|
||||
for (const callback of this.teardownCallbacks) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
protected updateContextToolbars(update: EditorUiStateUpdate): void {
|
||||
for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
|
||||
const toolbar = this.activeContextToolbars[i];
|
||||
toolbar.destroy();
|
||||
toolbar.teardown();
|
||||
this.activeContextToolbars.splice(i, 1);
|
||||
}
|
||||
|
||||
@ -253,9 +285,9 @@ export class EditorUIManager {
|
||||
});
|
||||
}
|
||||
|
||||
protected setupEventListeners(context: EditorUiContext) {
|
||||
protected setupEventListeners() {
|
||||
const layoutUpdate = this.triggerLayoutUpdate.bind(this);
|
||||
window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true});
|
||||
window.addEventListener('resize', layoutUpdate, {passive: true});
|
||||
window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true, signal: this.domEventAbortController.signal});
|
||||
window.addEventListener('resize', layoutUpdate, {passive: true, signal: this.domEventAbortController.signal});
|
||||
}
|
||||
}
|
@ -34,8 +34,8 @@ export class EditorFormModal extends EditorContainerUiElement {
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.getDOMElement().remove();
|
||||
this.getContext().manager.setModalInactive(this.key);
|
||||
this.teardown();
|
||||
}
|
||||
|
||||
getForm(): EditorForm {
|
||||
|
@ -60,17 +60,4 @@ export class EditorContextToolbar extends EditorContainerUiElement {
|
||||
const dom = this.getDOMElement();
|
||||
dom.append(...children.map(child => child.getDOMElement()));
|
||||
}
|
||||
|
||||
protected empty() {
|
||||
const children = this.getChildren();
|
||||
for (const child of children) {
|
||||
child.getDOMElement().remove();
|
||||
}
|
||||
this.removeChildren(...children);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.empty();
|
||||
this.getDOMElement().remove();
|
||||
}
|
||||
}
|
@ -64,6 +64,6 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
export function focusEditor(editor: LexicalEditor) {
|
||||
export function focusEditor(editor: LexicalEditor): void {
|
||||
editor.focus(() => {}, {defaultSelection: "rootStart"});
|
||||
}
|
Reference in New Issue
Block a user