added watchdog for CKEditor to recover from crashes, fixes #3227

This commit is contained in:
zadam 2022-12-15 21:45:47 +01:00
parent 648dd73fa1
commit b202b43bf5
3 changed files with 62 additions and 26 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -9,6 +9,7 @@ import noteCreateService from "../../services/note_create.js";
import AbstractTextTypeWidget from "./abstract_text_type_widget.js"; import AbstractTextTypeWidget from "./abstract_text_type_widget.js";
import link from "../../services/link.js"; import link from "../../services/link.js";
import appContext from "../../components/app_context.js"; import appContext from "../../components/app_context.js";
import dialogService from "../../services/dialog.js";
const ENABLE_INSPECTOR = false; const ENABLE_INSPECTOR = false;
@ -118,7 +119,42 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
// display of $widget in both branches. // display of $widget in both branches.
this.$widget.show(); this.$widget.show();
this.textEditor = await BalloonEditor.create(this.$editor[0], { this.watchdog = new EditorWatchdog(BalloonEditor, {
// An average number of milliseconds between the last editor errors (defaults to 5000).
// When the period of time between errors is lower than that and the crashNumberLimit
// is also reached, the watchdog changes its state to crashedPermanently and it stops
// restarting the editor. This prevents an infinite restart loop.
minimumNonErrorTimePeriod: 5000,
// A threshold specifying the number of errors (defaults to 3).
// After this limit is reached and the time between last errors
// is shorter than minimumNonErrorTimePeriod, the watchdog changes
// its state to crashedPermanently and it stops restarting the editor.
// This prevents an infinite restart loop.
crashNumberLimit: 3,
// A minimum number of milliseconds between saving the editor data internally (defaults to 5000).
// Note that for large documents this might impact the editor performance.
saveInterval: 5000
});
this.watchdog.on('stateChange', () => {
const currentState = this.watchdog.state;
if (!['crashed', 'crashedPermanently'].includes(currentState)) {
return;
}
console.log(`CKEditor changed to ${currentState}`);
this.watchdog.crashes.forEach(crashInfo => console.log(crashInfo));
if (currentState === 'crashedPermanently') {
dialogService.info(`Editing component keeps crashing. Please try restarting Trilium. If problem persists, consider creating a bug report.`);
this.watchdog.editor.enableReadOnlyMode('crashed-editor');
}
});
await this.watchdog.create(this.$editor[0], {
placeholder: "Type the content of your note here ...", placeholder: "Type the content of your note here ...",
mention: mentionSetup, mention: mentionSetup,
codeBlock: { codeBlock: {
@ -133,11 +169,11 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
} }
}); });
this.textEditor.model.document.on('change:data', () => this.spacedUpdate.scheduleUpdate()); this.watchdog.editor.model.document.on('change:data', () => this.spacedUpdate.scheduleUpdate());
if (glob.isDev && ENABLE_INSPECTOR) { if (glob.isDev && ENABLE_INSPECTOR) {
await import(/* webpackIgnore: true */'../../../libraries/ckeditor/inspector'); await import(/* webpackIgnore: true */'../../../libraries/ckeditor/inspector');
CKEditorInspector.attach(this.textEditor); CKEditorInspector.attach(this.watchdog.editor);
} }
} }
@ -145,12 +181,12 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
const noteComplement = await froca.getNoteComplement(note.noteId); const noteComplement = await froca.getNoteComplement(note.noteId);
await this.spacedUpdate.allowUpdateWithoutChange(() => { await this.spacedUpdate.allowUpdateWithoutChange(() => {
this.textEditor.setData(noteComplement.content || ""); this.watchdog.editor.setData(noteComplement.content || "");
}); });
} }
getContent() { getContent() {
const content = this.textEditor.getData(); const content = this.watchdog.editor.getData();
// if content is only tags/whitespace (typically <p>&nbsp;</p>), then just make it empty // if content is only tags/whitespace (typically <p>&nbsp;</p>), then just make it empty
// this is important when setting new note to code // this is important when setting new note to code
@ -164,13 +200,13 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
show() {} show() {}
getEditor() { getEditor() {
return this.textEditor; return this.watchdog?.editor;
} }
cleanup() { cleanup() {
if (this.textEditor) { if (this.watchdog?.editor) {
this.spacedUpdate.allowUpdateWithoutChange(() => { this.spacedUpdate.allowUpdateWithoutChange(() => {
this.textEditor.setData(''); this.watchdog.editor.setData('');
}); });
} }
} }
@ -185,8 +221,8 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
async addLinkToEditor(linkHref, linkTitle) { async addLinkToEditor(linkHref, linkTitle) {
await this.initialized; await this.initialized;
this.textEditor.model.change(writer => { this.watchdog.editor.model.change(writer => {
const insertPosition = this.textEditor.model.document.selection.getFirstPosition(); const insertPosition = this.watchdog.editor.model.document.selection.getFirstPosition();
writer.insertText(linkTitle, {linkHref: linkHref}, insertPosition); writer.insertText(linkTitle, {linkHref: linkHref}, insertPosition);
}); });
} }
@ -194,8 +230,8 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
async addTextToEditor(text) { async addTextToEditor(text) {
await this.initialized; await this.initialized;
this.textEditor.model.change(writer => { this.watchdog.editor.model.change(writer => {
const insertPosition = this.textEditor.model.document.selection.getLastPosition(); const insertPosition = this.watchdog.editor.model.document.selection.getLastPosition();
writer.insertText(text, insertPosition); writer.insertText(text, insertPosition);
}); });
} }
@ -213,21 +249,21 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
if (linkTitle) { if (linkTitle) {
if (this.hasSelection()) { if (this.hasSelection()) {
this.textEditor.execute('link', '#' + notePath); this.watchdog.editor.execute('link', '#' + notePath);
} else { } else {
await this.addLinkToEditor('#' + notePath, linkTitle); await this.addLinkToEditor('#' + notePath, linkTitle);
} }
} }
else { else {
this.textEditor.execute('referenceLink', { notePath: notePath }); this.watchdog.editor.execute('referenceLink', { notePath: notePath });
} }
this.textEditor.editing.view.focus(); this.watchdog.editor.editing.view.focus();
} }
// returns true if user selected some text, false if there's no selection // returns true if user selected some text, false if there's no selection
hasSelection() { hasSelection() {
const model = this.textEditor.model; const model = this.watchdog.editor.model;
const selection = model.document.selection; const selection = model.document.selection;
return !selection.isCollapsed; return !selection.isCollapsed;
@ -241,10 +277,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
await this.initialized; await this.initialized;
if (callback) { if (callback) {
callback(this.textEditor); callback(this.watchdog.editor);
} }
resolve(this.textEditor); resolve(this.watchdog.editor);
} }
addLinkToTextCommand() { addLinkToTextCommand() {
@ -254,7 +290,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
} }
getSelectedText() { getSelectedText() {
const range = this.textEditor.model.document.selection.getFirstRange(); const range = this.watchdog.editor.model.document.selection.getFirstRange();
let text = ''; let text = '';
for (const item of range.getItems()) { for (const item of range.getItems()) {
@ -269,7 +305,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
async followLinkUnderCursorCommand() { async followLinkUnderCursorCommand() {
await this.initialized; await this.initialized;
const selection = this.textEditor.model.document.selection; const selection = this.watchdog.editor.model.document.selection;
const selectedElement = selection.getSelectedElement(); const selectedElement = selection.getSelectedElement();
if (selectedElement?.name === 'reference') { if (selectedElement?.name === 'reference') {
@ -301,10 +337,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
} }
addIncludeNote(noteId, boxSize) { addIncludeNote(noteId, boxSize) {
this.textEditor.model.change( writer => { this.watchdog.editor.model.change( writer => {
// Insert <includeNote>*</includeNote> at the current selection position // Insert <includeNote>*</includeNote> at the current selection position
// in a way that will result in creating a valid model structure // in a way that will result in creating a valid model structure
this.textEditor.model.insertContent(writer.createElement('includeNote', { this.watchdog.editor.model.insertContent(writer.createElement('includeNote', {
noteId: noteId, noteId: noteId,
boxSize: boxSize boxSize: boxSize
})); }));
@ -314,13 +350,13 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
async addImage(noteId) { async addImage(noteId) {
const note = await froca.getNote(noteId); const note = await froca.getNote(noteId);
this.textEditor.model.change( writer => { this.watchdog.editor.model.change( writer => {
const sanitizedTitle = note.title.replace(/[^a-z0-9-.]/gi, ""); const sanitizedTitle = note.title.replace(/[^a-z0-9-.]/gi, "");
const src = `api/images/${note.noteId}/${sanitizedTitle}`; const src = `api/images/${note.noteId}/${sanitizedTitle}`;
const imageElement = writer.createElement( 'image', { 'src': src } ); const imageElement = writer.createElement( 'image', { 'src': src } );
this.textEditor.model.insertContent(imageElement, this.textEditor.model.document.selection); this.watchdog.editor.model.insertContent(imageElement, this.watchdog.editor.model.document.selection);
} ); } );
} }