Skip to content

Commit b80992c

Browse files
committed
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.
1 parent c606970 commit b80992c

File tree

16 files changed

+176
-92
lines changed

16 files changed

+176
-92
lines changed

app/Util/HtmlDescriptionFilter.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
use DOMAttr;
66
use DOMElement;
7-
use DOMNamedNodeMap;
87
use DOMNode;
98

109
/**
@@ -25,6 +24,7 @@ class HtmlDescriptionFilter
2524
'ul' => [],
2625
'li' => [],
2726
'strong' => [],
27+
'span' => [],
2828
'em' => [],
2929
'br' => [],
3030
];
@@ -59,7 +59,6 @@ protected static function filterElement(DOMElement $element): void
5959
return;
6060
}
6161

62-
/** @var DOMNamedNodeMap $attrs */
6362
$attrs = $element->attributes;
6463
for ($i = $attrs->length - 1; $i >= 0; $i--) {
6564
/** @var DOMAttr $attr */
@@ -70,7 +69,8 @@ protected static function filterElement(DOMElement $element): void
7069
}
7170
}
7271

73-
foreach ($element->childNodes as $child) {
72+
$childNodes = [...$element->childNodes];
73+
foreach ($childNodes as $child) {
7474
if ($child instanceof DOMElement) {
7575
static::filterElement($child);
7676
}

resources/js/components/page-comment.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {Component} from './component';
22
import {getLoading, htmlToDom} from '../services/dom';
3-
import {buildForInput} from '../wysiwyg-tinymce/config';
43
import {PageCommentReference} from "./page-comment-reference";
54
import {HttpError} from "../services/http";
5+
import {SimpleWysiwygEditorInterface} from "../wysiwyg";
6+
import {el} from "../wysiwyg/utils/dom";
67

78
export interface PageCommentReplyEventData {
89
id: string; // ID of comment being replied to
@@ -21,8 +22,7 @@ export class PageComment extends Component {
2122
protected updatedText!: string;
2223
protected archiveText!: string;
2324

24-
protected wysiwygEditor: any = null;
25-
protected wysiwygLanguage!: string;
25+
protected wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
2626
protected wysiwygTextDirection!: string;
2727

2828
protected container!: HTMLElement;
@@ -44,7 +44,6 @@ export class PageComment extends Component {
4444
this.archiveText = this.$opts.archiveText;
4545

4646
// Editor reference and text options
47-
this.wysiwygLanguage = this.$opts.wysiwygLanguage;
4847
this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
4948

5049
// Element references
@@ -90,29 +89,28 @@ export class PageComment extends Component {
9089
this.form.toggleAttribute('hidden', !show);
9190
}
9291

93-
protected startEdit() : void {
92+
protected async startEdit(): Promise<void> {
9493
this.toggleEditMode(true);
9594

9695
if (this.wysiwygEditor) {
9796
this.wysiwygEditor.focus();
9897
return;
9998
}
10099

101-
const config = buildForInput({
102-
language: this.wysiwygLanguage,
103-
containerElement: this.input,
100+
type WysiwygModule = typeof import('../wysiwyg');
101+
const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
102+
const editorContent = this.input.value;
103+
const container = el('div', {class: 'comment-editor-container'});
104+
this.input.parentElement?.appendChild(container);
105+
this.input.hidden = true;
106+
107+
this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, editorContent, {
104108
darkMode: document.documentElement.classList.contains('dark-mode'),
105-
textDirection: this.wysiwygTextDirection,
106-
drawioUrl: '',
107-
pageId: 0,
108-
translations: {},
109-
translationMap: (window as unknown as Record<string, Object>).editor_translations,
109+
textDirection: this.$opts.textDirection,
110+
translations: (window as unknown as Record<string, Object>).editor_translations,
110111
});
111112

112-
(window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => {
113-
this.wysiwygEditor = editors[0];
114-
setTimeout(() => this.wysiwygEditor.focus(), 50);
115-
});
113+
this.wysiwygEditor.focus();
116114
}
117115

118116
protected async update(event: Event): Promise<void> {
@@ -121,7 +119,7 @@ export class PageComment extends Component {
121119
this.form.toggleAttribute('hidden', true);
122120

123121
const reqData = {
124-
html: this.wysiwygEditor.getContent(),
122+
html: await this.wysiwygEditor?.getContentAsHtml() || '',
125123
};
126124

127125
try {

resources/js/components/page-comments.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import {Component} from './component';
22
import {getLoading, htmlToDom} from '../services/dom';
3-
import {buildForInput} from '../wysiwyg-tinymce/config';
43
import {Tabs} from "./tabs";
54
import {PageCommentReference} from "./page-comment-reference";
65
import {scrollAndHighlightElement} from "../services/util";
76
import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment";
7+
import {el} from "../wysiwyg/utils/dom";
8+
import {SimpleWysiwygEditorInterface} from "../wysiwyg";
89

910
export class PageComments extends Component {
1011

@@ -28,9 +29,8 @@ export class PageComments extends Component {
2829
private hideFormButton!: HTMLElement;
2930
private removeReplyToButton!: HTMLElement;
3031
private removeReferenceButton!: HTMLElement;
31-
private wysiwygLanguage!: string;
3232
private wysiwygTextDirection!: string;
33-
private wysiwygEditor: any = null;
33+
private wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
3434
private createdText!: string;
3535
private countText!: string;
3636
private archivedCountText!: string;
@@ -63,7 +63,6 @@ export class PageComments extends Component {
6363
this.removeReferenceButton = this.$refs.removeReferenceButton;
6464

6565
// WYSIWYG options
66-
this.wysiwygLanguage = this.$opts.wysiwygLanguage;
6766
this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
6867

6968
// Translations
@@ -107,7 +106,7 @@ export class PageComments extends Component {
107106
}
108107
}
109108

110-
protected saveComment(event: SubmitEvent): void {
109+
protected async saveComment(event: SubmitEvent): Promise<void> {
111110
event.preventDefault();
112111
event.stopPropagation();
113112

@@ -117,7 +116,7 @@ export class PageComments extends Component {
117116
this.form.toggleAttribute('hidden', true);
118117

119118
const reqData = {
120-
html: this.wysiwygEditor.getContent(),
119+
html: (await this.wysiwygEditor?.getContentAsHtml()) || '',
121120
parent_id: this.parentId || null,
122121
content_ref: this.contentReference,
123122
};
@@ -189,27 +188,25 @@ export class PageComments extends Component {
189188
this.addButtonContainer.toggleAttribute('hidden', false);
190189
}
191190

192-
protected loadEditor(): void {
191+
protected async loadEditor(): Promise<void> {
193192
if (this.wysiwygEditor) {
194193
this.wysiwygEditor.focus();
195194
return;
196195
}
197196

198-
const config = buildForInput({
199-
language: this.wysiwygLanguage,
200-
containerElement: this.formInput,
197+
type WysiwygModule = typeof import('../wysiwyg');
198+
const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
199+
const container = el('div', {class: 'comment-editor-container'});
200+
this.formInput.parentElement?.appendChild(container);
201+
this.formInput.hidden = true;
202+
203+
this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '', {
201204
darkMode: document.documentElement.classList.contains('dark-mode'),
202205
textDirection: this.wysiwygTextDirection,
203-
drawioUrl: '',
204-
pageId: 0,
205-
translations: {},
206-
translationMap: (window as unknown as Record<string, Object>).editor_translations,
206+
translations: (window as unknown as Record<string, Object>).editor_translations,
207207
});
208208

209-
(window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => {
210-
this.wysiwygEditor = editors[0];
211-
setTimeout(() => this.wysiwygEditor.focus(), 50);
212-
});
209+
this.wysiwygEditor.focus();
213210
}
214211

215212
protected removeEditor(): void {

resources/js/wysiwyg/index.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import {createEditor, LexicalEditor} from 'lexical';
22
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
33
import {registerRichText} from '@lexical/rich-text';
44
import {mergeRegister} from '@lexical/utils';
5-
import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
5+
import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
66
import {buildEditorUI} from "./ui";
7-
import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
7+
import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
88
import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
99
import {EditorUiContext} from "./ui/framework/core";
1010
import {listen as listenToCommonEvents} from "./services/common-events";
@@ -15,7 +15,7 @@ import {registerShortcuts} from "./services/shortcuts";
1515
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
1616
import {registerKeyboardHandling} from "./services/keyboard-handling";
1717
import {registerAutoLinks} from "./services/auto-links";
18-
import {contextToolbars, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
18+
import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
1919
import {modals} from "./ui/defaults/modals";
2020
import {CodeBlockDecorator} from "./ui/decorators/code-block";
2121
import {DiagramDecorator} from "./ui/decorators/diagram";
@@ -90,44 +90,54 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
9090

9191
registerCommonNodeMutationListeners(context);
9292

93-
return new SimpleWysiwygEditorInterface(editor);
93+
return new SimpleWysiwygEditorInterface(context);
9494
}
9595

96-
export function createCommentEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
96+
export function createBasicEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
9797
const editor = createEditor({
98-
namespace: 'BookStackCommentEditor',
99-
nodes: getNodesForPageEditor(),
98+
namespace: 'BookStackBasicEditor',
99+
nodes: getNodesForBasicEditor(),
100100
onError: console.error,
101101
theme: theme,
102102
});
103103
const context: EditorUiContext = buildEditorUI(container, editor, options);
104104
editor.setRootElement(context.editorDOM);
105105

106-
mergeRegister(
106+
const editorTeardown = mergeRegister(
107107
registerRichText(editor),
108108
registerHistory(editor, createEmptyHistoryState(), 300),
109109
registerShortcuts(context),
110110
registerAutoLinks(editor),
111111
);
112112

113113
// Register toolbars, modals & decorators
114-
context.manager.setToolbar(getMainEditorFullToolbar(context)); // TODO - Create comment toolbar
114+
context.manager.setToolbar(getBasicEditorToolbar(context));
115115
context.manager.registerContextToolbar('link', contextToolbars.link);
116116
context.manager.registerModal('link', modals.link);
117+
context.manager.onTeardown(editorTeardown);
117118

118119
setEditorContentFromHtml(editor, htmlContent);
119120

120-
return new SimpleWysiwygEditorInterface(editor);
121+
return new SimpleWysiwygEditorInterface(context);
121122
}
122123

123124
export class SimpleWysiwygEditorInterface {
124-
protected editor: LexicalEditor;
125+
protected context: EditorUiContext;
125126

126-
constructor(editor: LexicalEditor) {
127-
this.editor = editor;
127+
constructor(context: EditorUiContext) {
128+
this.context = context;
128129
}
129130

130131
async getContentAsHtml(): Promise<string> {
131-
return await getEditorContentAsHtml(this.editor);
132+
return await getEditorContentAsHtml(this.context.editor);
133+
}
134+
135+
focus(): void {
136+
focusEditor(this.context.editor);
137+
}
138+
139+
remove() {
140+
this.context.editorDOM.remove();
141+
this.context.manager.teardown();
132142
}
133143
}

resources/js/wysiwyg/nodes.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
2020
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
2121
import {CaptionNode} from "@lexical/table/LexicalCaptionNode";
2222

23-
/**
24-
* Load the nodes for lexical.
25-
*/
2623
export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
2724
return [
2825
CalloutNode,
@@ -45,6 +42,15 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
4542
];
4643
}
4744

45+
export function getNodesForBasicEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
46+
return [
47+
ListNode,
48+
ListItemNode,
49+
ParagraphNode,
50+
LinkNode,
51+
];
52+
}
53+
4854
export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
4955
const decorated = [ImageNode, CodeBlockNode, DiagramNode];
5056

@@ -53,7 +59,7 @@ export function registerCommonNodeMutationListeners(context: EditorUiContext): v
5359
if (mutation === "destroyed") {
5460
const decorator = context.manager.getDecoratorByNodeKey(nodeKey);
5561
if (decorator) {
56-
decorator.destroy(context);
62+
decorator.teardown();
5763
}
5864
}
5965
}

resources/js/wysiwyg/ui/defaults/toolbars.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,16 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
221221
]);
222222
}
223223

224+
export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement {
225+
return new EditorSimpleClassContainer('editor-toolbar-main', [
226+
new EditorButton(bold),
227+
new EditorButton(italic),
228+
new EditorButton(link),
229+
new EditorButton(bulletList),
230+
new EditorButton(numberList),
231+
]);
232+
}
233+
224234
export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
225235
image: {
226236
selector: 'img:not([drawio-diagram] img)',

resources/js/wysiwyg/ui/framework/core.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDef
3030
export abstract class EditorUiElement {
3131
protected dom: HTMLElement|null = null;
3232
private context: EditorUiContext|null = null;
33+
private abortController: AbortController = new AbortController();
3334

3435
protected abstract buildDOM(): HTMLElement;
3536

@@ -79,9 +80,16 @@ export abstract class EditorUiElement {
7980
if (target) {
8081
target.addEventListener('editor::' + name, ((event: CustomEvent) => {
8182
callback(event.detail);
82-
}) as EventListener);
83+
}) as EventListener, { signal: this.abortController.signal });
8384
}
8485
}
86+
87+
teardown(): void {
88+
if (this.dom && this.dom.isConnected) {
89+
this.dom.remove();
90+
}
91+
this.abortController.abort('teardown');
92+
}
8593
}
8694

8795
export class EditorContainerUiElement extends EditorUiElement {
@@ -129,6 +137,13 @@ export class EditorContainerUiElement extends EditorUiElement {
129137
child.setContext(context);
130138
}
131139
}
140+
141+
teardown() {
142+
for (const child of this.children) {
143+
child.teardown();
144+
}
145+
super.teardown();
146+
}
132147
}
133148

134149
export class EditorSimpleClassContainer extends EditorContainerUiElement {

resources/js/wysiwyg/ui/framework/decorator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export abstract class EditorDecorator {
4848
* Destroy this decorator. Used for tear-down operations upon destruction
4949
* of the underlying node this decorator is attached to.
5050
*/
51-
destroy(context: EditorUiContext): void {
51+
teardown(): void {
5252
for (const callback of this.onDestroyCallbacks) {
5353
callback();
5454
}

0 commit comments

Comments
 (0)