CKEditor stability improvements (#7979)

This commit is contained in:
Elian Doran 2025-12-07 22:40:52 +02:00 committed by GitHub
commit dcaf91a878
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 280 additions and 75 deletions

View File

@ -34,6 +34,7 @@ import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
import type { InfoProps } from "../widgets/dialogs/info.jsx";
interface Layout {
getRootWidget: (appContext: AppContext) => RootContainer;
@ -124,7 +125,7 @@ export type CommandMappings = {
isNewNote?: boolean;
};
showPromptDialog: PromptDialogOptions;
showInfoDialog: ConfirmWithMessageOptions;
showInfoDialog: InfoProps;
showConfirmDialog: ConfirmWithMessageOptions;
showRecentChanges: CommandData & { ancestorNoteId: string };
showImportDialog: CommandData & { noteId: string };

View File

@ -1,8 +1,9 @@
import { Modal } from "bootstrap";
import appContext from "../components/app_context.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
if (closeActDialog) {
@ -37,8 +38,8 @@ export function closeActiveDialog() {
}
}
async function info(message: string) {
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res }));
async function info(message: MessageType, extraProps?: InfoExtraProps) {
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { ...extraProps, message, callback: res }));
}
/**

View File

@ -9,6 +9,10 @@ export interface ToastOptions {
message: string;
timeout?: number;
progress?: number;
buttons?: {
text: string;
onClick: (api: { dismissToast: () => void }) => void;
}[];
}
export type ToastOptionsWithRequiredId = Omit<ToastOptions, "id"> & Required<Pick<ToastOptions, "id">>;

View File

@ -205,7 +205,8 @@
"info": {
"modalTitle": "Info message",
"closeButton": "Close",
"okButton": "OK"
"okButton": "OK",
"copy_to_clipboard": "Copy to clipboard"
},
"jump_to_note": {
"search_placeholder": "Search for note by its name or type > for commands...",
@ -987,7 +988,14 @@
"placeholder": "Type the content of your code note here..."
},
"editable_text": {
"placeholder": "Type the content of your note here..."
"placeholder": "Type the content of your note here...",
"editor_crashed_title": "The text editor crashed",
"editor_crashed_content": "Your content was recovered successfully, but a few of your most recent changes may not have been saved.",
"editor_crashed_details_button": "View more details...",
"editor_crashed_details_intro": "If you experience this error several times, consider reporting it on GitHub by pasting the information below.",
"editor_crashed_details_title": "Technical information",
"auto-detect-language": "Auto-detected",
"keeps-crashing": "Editing component keeps crashing. Please try restarting Trilium. If problem persists, consider creating a bug report."
},
"empty": {
"open_note_instruction": "Open a note by typing the note's title into the input below or choose a note in the tree.",
@ -1826,10 +1834,6 @@
"move-to-available-launchers": "Move to available launchers",
"duplicate-launcher": "Duplicate launcher <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Auto-detected",
"keeps-crashing": "Editing component keeps crashing. Please try restarting Trilium. If problem persists, consider creating a bug report."
},
"highlighting": {
"title": "Code Blocks",
"description": "Controls the syntax highlighting for code blocks inside text notes, code notes will not be affected.",

View File

@ -48,12 +48,22 @@
background-color: unset !important;
}
.toast .toast-progress {
position: absolute;
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
background-color: var(--toast-text-color) !important;
height: 4px;
transition: width 0.1s linear;
.toast {
.toast-buttons {
padding: 0 1em 1em 1em;
display: flex;
gap: 1em;
justify-content: space-between;
}
.toast-progress {
position: absolute;
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
background-color: var(--toast-text-color) !important;
height: 4px;
transition: width 0.1s linear;
}
}

View File

@ -6,6 +6,7 @@ import { useEffect } from "preact/hooks";
import { removeToastFromStore, ToastOptionsWithRequiredId, toasts } from "../services/toast";
import Icon from "./react/Icon";
import { RawHtmlBlock } from "./react/RawHtml";
import Button from "./react/Button";
export default function ToastContainer() {
return (
@ -15,7 +16,7 @@ export default function ToastContainer() {
)
}
function Toast({ id, title, timeout, progress, message, icon }: ToastOptionsWithRequiredId) {
function Toast({ id, title, timeout, progress, message, icon, buttons }: ToastOptionsWithRequiredId) {
// Autohide.
useEffect(() => {
if (!timeout || timeout <= 0) return;
@ -23,10 +24,14 @@ function Toast({ id, title, timeout, progress, message, icon }: ToastOptionsWith
return () => clearTimeout(timerId);
}, [ id, timeout ]);
function dismissToast() {
removeToastFromStore(id);
}
const closeButton = (
<button
type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"
onClick={() => removeToastFromStore(id)}
onClick={dismissToast}
/>
);
const toastIcon = <Icon icon={icon.startsWith("bx ") ? icon : `bx bx-${icon}`} />;
@ -52,6 +57,15 @@ function Toast({ id, title, timeout, progress, message, icon }: ToastOptionsWith
<RawHtmlBlock className="toast-body" html={message} />
{!title && <div class="toast-header">{closeButton}</div>}
{buttons && (
<div class="toast-buttons">
{buttons.map(({ text, onClick }) => (
<Button text={text} onClick={() => onClick({ dismissToast })} />
))}
</div>
)}
<div
class="toast-progress"
style={{ width: `${(progress ?? 0) * 100}%` }}

View File

@ -4,12 +4,14 @@ import { t } from "../../services/i18n";
import { useState } from "preact/hooks";
import FormCheckbox from "../react/FormCheckbox";
import { useTriliumEvent } from "../react/hooks";
import { isValidElement, type VNode } from "preact";
import { RawHtmlBlock } from "../react/RawHtml";
interface ConfirmDialogProps {
title?: string;
message?: string | HTMLElement;
message?: MessageType;
callback?: ConfirmDialogCallback;
isConfirmDeleteNoteBox?: boolean;
isConfirmDeleteNoteBox?: boolean;
}
export default function ConfirmDialog() {
@ -20,7 +22,7 @@ export default function ConfirmDialog() {
function showDialog(title: string | null, message: MessageType, callback: ConfirmDialogCallback, isConfirmDeleteNoteBox: boolean) {
setOpts({
title: title ?? undefined,
message: (typeof message === "object" && "length" in message ? message[0] : message),
message,
callback,
isConfirmDeleteNoteBox
});
@ -30,7 +32,7 @@ export default function ConfirmDialog() {
useTriliumEvent("showConfirmDialog", ({ message, callback }) => showDialog(null, message, callback, false));
useTriliumEvent("showConfirmDeleteNoteBoxWithNoteDialog", ({ title, callback }) => showDialog(title, t("confirm.are_you_sure_remove_note", { title: title }), callback, true));
return (
return (
<Modal
className="confirm-dialog"
title={opts?.title ?? t("confirm.confirmation")}
@ -57,9 +59,10 @@ export default function ConfirmDialog() {
show={shown}
stackable
>
{!opts?.message || typeof opts?.message === "string"
? <div>{(opts?.message as string) ?? ""}</div>
: <div dangerouslySetInnerHTML={{ __html: opts?.message.outerHTML ?? "" }} />}
{isValidElement(opts?.message)
? opts?.message
: <RawHtmlBlock html={opts?.message} />
}
{opts?.isConfirmDeleteNoteBox && (
<FormCheckbox
@ -74,7 +77,7 @@ export default function ConfirmDialog() {
export type ConfirmDialogResult = false | ConfirmDialogOptions;
export type ConfirmDialogCallback = (val?: ConfirmDialogResult) => void;
type MessageType = string | HTMLElement | JQuery<HTMLElement>;
export type MessageType = string | HTMLElement | JQuery<HTMLElement> | VNode;
export interface ConfirmDialogOptions {
confirmed: boolean;

View File

@ -0,0 +1,11 @@
.modal.info-dialog {
user-select: text;
h3 {
font-size: 1.25em;
}
pre {
font-size: 0.75em;
}
}

View File

@ -1,12 +1,26 @@
import { EventData } from "../../components/app_context";
import Modal from "../react/Modal";
import Modal, { type ModalProps } from "../react/Modal";
import { t } from "../../services/i18n";
import Button from "../react/Button";
import { useRef, useState } from "preact/hooks";
import { RawHtmlBlock } from "../react/RawHtml";
import { useTriliumEvent } from "../react/hooks";
import { isValidElement } from "preact";
import { ConfirmWithMessageOptions } from "./confirm";
import "./info.css";
import server from "../../services/server";
import { ToMarkdownResponse } from "@triliumnext/commons";
import { copyTextWithToast } from "../../services/clipboard_ext";
export interface InfoExtraProps extends Partial<Pick<ModalProps, "size" | "title">> {
/** Adds a button in the footer that allows easily copying the content of the infobox to clipboard. */
copyToClipboardButton?: boolean;
}
export type InfoProps = ConfirmWithMessageOptions & InfoExtraProps;
export default function InfoDialog() {
const modalRef = useRef<HTMLDivElement>(null);
const [ opts, setOpts ] = useState<EventData<"showInfoDialog">>();
const [ shown, setShown ] = useState(false);
const okButtonRef = useRef<HTMLButtonElement>(null);
@ -18,21 +32,42 @@ export default function InfoDialog() {
return (<Modal
className="info-dialog"
size="sm"
title={t("info.modalTitle")}
size={opts?.size ?? "sm"}
title={opts?.title ?? t("info.modalTitle")}
onHidden={() => {
opts?.callback?.();
setShown(false);
}}
onShown={() => okButtonRef.current?.focus?.()}
footer={<Button
buttonRef={okButtonRef}
text={t("info.okButton")}
onClick={() => setShown(false)}
/>}
modalRef={modalRef}
footer={<>
{opts?.copyToClipboardButton && (
<Button
text={t("info.copy_to_clipboard")}
icon="bx bx-copy"
onClick={async () => {
const htmlContent = modalRef.current?.querySelector<HTMLDivElement>(".modal-body")?.innerHTML;
if (!htmlContent) return;
const { markdownContent } = await server.post<ToMarkdownResponse>("other/to-markdown", { htmlContent });
copyTextWithToast(markdownContent);
}}
/>
)}
<Button
buttonRef={okButtonRef}
text={t("info.okButton")}
onClick={() => setShown(false)}
/>
</>}
show={shown}
stackable
scrollable
>
<RawHtmlBlock className="info-dialog-content" html={opts?.message ?? ""} />
{isValidElement(opts?.message)
? opts?.message
: <RawHtmlBlock className="info-dialog-content" html={opts?.message} />
}
</Modal>);
}

View File

@ -7,15 +7,12 @@ import Modal from "../react/Modal";
import Button from "../react/Button";
import { useTriliumEvent } from "../react/hooks";
import { CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
import { RenderMarkdownResponse } from "@triliumnext/commons";
export interface MarkdownImportOpts {
editorApi: CKEditorApi;
}
interface RenderMarkdownResponse {
htmlContent: string;
}
export default function MarkdownImportDialog() {
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
const editorApiRef = useRef<CKEditorApi>(null);

View File

@ -14,7 +14,7 @@ interface CustomTitleBarButton {
onClick: () => void;
}
interface ModalProps {
export interface ModalProps {
className: string;
title: string | ComponentChildren;
customTitleBarButtons?: (CustomTitleBarButton | null)[];
@ -164,7 +164,7 @@ export default function Modal({ children, className, size, title, customTitleBar
onClick={titleBarButton.onClick}>
</button>
))}
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")}></button>
</div>

View File

@ -78,12 +78,23 @@ export function useSpacedUpdate(callback: () => void | Promise<void>, interval =
return spacedUpdateRef.current;
}
export interface SavedData {
content: string;
attachments?: {
role: string;
title: string;
mime: string;
content: string;
position: number;
}[];
}
export function useEditorSpacedUpdate({ note, noteContext, getData, onContentChange, dataSaved, updateInterval }: {
note: FNote,
noteContext: NoteContext | null | undefined,
getData: () => Promise<object | undefined> | object | undefined,
getData: () => Promise<SavedData | undefined> | SavedData | undefined,
onContentChange: (newContent: string) => void,
dataSaved?: () => void,
dataSaved?: (savedData: SavedData) => void,
updateInterval?: number;
}) {
const parentComponent = useContext(ParentComponent);
@ -99,7 +110,7 @@ export function useEditorSpacedUpdate({ note, noteContext, getData, onContentCha
protected_session_holder.touchProtectedSessionIfNecessary(note);
await server.put(`notes/${note.noteId}/data`, data, parentComponent?.componentId);
dataSaved?.();
dataSaved?.(data);
}
}, [ note, getData, dataSaved ])
const spacedUpdate = useSpacedUpdate(callback);

View File

@ -3,7 +3,7 @@ import NoteContext from "../../../components/note_context";
import FNote from "../../../entities/fnote";
import { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem } from "@excalidraw/excalidraw/types";
import { useRef } from "preact/hooks";
import { useEditorSpacedUpdate } from "../../react/hooks";
import { SavedData, useEditorSpacedUpdate } from "../../react/hooks";
import { ExcalidrawElement, NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import { exportToSvg, getSceneVersion } from "@excalidraw/excalidraw";
import server from "../../../services/server";
@ -77,7 +77,7 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
const api = apiRef.current;
if (!api) return;
const { content, svg } = await getData(api);
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
const attachments: SavedData["attachments"] = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
// libraryChanged is unset in dataSaved()
if (libraryChanged.current) {
@ -124,7 +124,7 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
title: libraryItem.id + libraryItem.name,
mime: "application/json",
content: JSON.stringify(libraryItem),
position: position
position
});
position += 10;

View File

@ -77,7 +77,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC
const spacedUpdate = useEditorSpacedUpdate({
note,
noteContext,
getData: () => ({ content: editorRef.current?.getText() }),
getData: () => ({ content: editorRef.current?.getText() ?? "" }),
onContentChange: (content) => {
const codeEditor = editorRef.current;
if (!codeEditor) return;

View File

@ -25,7 +25,7 @@ interface CKEditorWithWatchdogProps extends Pick<HTMLProps<HTMLDivElement>, "cla
watchdogRef: RefObject<EditorWatchdog>;
watchdogConfig?: WatchdogConfig;
onNotificationWarning?: (evt: any, data: any) => void;
onWatchdogStateChange?: (watchdog: EditorWatchdog<any>) => void;
onWatchdogStateChange?: (watchdog: EditorWatchdog) => void;
onChange: () => void;
/** Called upon whenever a new CKEditor instance is initialized, whether it's the first initialization, after a crash or after a config change that requires it (e.g. content language). */
onEditorInitialized?: (editor: CKTextEditor) => void;
@ -182,7 +182,7 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe
watchdog.create(container);
return () => watchdog.destroy();
}, [ contentLanguage, templates, uiLanguage ]);
}, [ contentLanguage, templates, uiLanguage ]); // TODO: adding all dependencies here will cause errors during CK init.
// React to notification warning callback.
useEffect(() => {
@ -204,7 +204,7 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe
);
}
function buildWatchdog(isClassicEditor: boolean, watchdogConfig?: WatchdogConfig): EditorWatchdog<CKTextEditor> {
function buildWatchdog(isClassicEditor: boolean, watchdogConfig?: WatchdogConfig): EditorWatchdog {
if (isClassicEditor) {
return new EditorWatchdog(ClassicEditor, watchdogConfig);
} else {

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import dialog from "../../../services/dialog";
import toast from "../../../services/toast";
import utils, { hasTouchBar, isMobile } from "../../../services/utils";
@ -57,6 +57,10 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
onContentChange(newContent) {
contentRef.current = newContent;
watchdogRef.current?.editor?.setData(newContent);
},
dataSaved(savedData) {
// Store back the saved data in order to retrieve it in case the CKEditor crashes.
contentRef.current = savedData.content;
}
});
const templates = useTemplates();
@ -121,7 +125,7 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
const resp = await note_create.createNoteWithTypePrompt(notePath, {
activate: false,
title: title
title
});
if (!resp || !resp.note) return;
@ -210,6 +214,8 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
addTextToEditor(text);
});
const onWatchdogStateChange = useWatchdogCrashHandling();
return (
<>
{note && !!templates && <CKEditorWithWatchdog
@ -226,7 +232,7 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
// 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: 10,
// 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
saveInterval: Number.MAX_SAFE_INTEGER
}}
templates={templates}
onNotificationWarning={onNotificationWarning}
@ -245,7 +251,9 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
}
initialized.current.resolve();
editor.setData(contentRef.current ?? "");
// Restore the data, either on the first render or if the editor crashes.
// We are not using CKEditor's built-in watch dog content, instead we are using the data we store regularly in the spaced update (see `dataSaved`).
editor.setData(contentRef.current);
parentComponent?.triggerEvent("textEditorRefreshed", { ntxId, editor });
}}
/>}
@ -269,20 +277,57 @@ function useTemplates() {
return templates;
}
function onWatchdogStateChange(watchdog: EditorWatchdog) {
const currentState = watchdog.state;
logInfo(`CKEditor state changed to ${currentState}`);
function useWatchdogCrashHandling() {
const hasCrashed = useRef(false);
const onWatchdogStateChange = useCallback((watchdog: EditorWatchdog) => {
const currentState = watchdog.state;
logInfo(`CKEditor state changed to ${currentState}`);
if (!["crashed", "crashedPermanently"].includes(currentState)) {
return;
}
if (currentState === "ready") {
hasCrashed.current = false;
watchdog.editor?.focus();
}
logError(`CKEditor crash logs: ${JSON.stringify(watchdog.crashes, null, 4)}`);
if (!["crashed", "crashedPermanently"].includes(currentState)) {
return;
}
if (currentState === "crashedPermanently") {
dialog.info(t("editable-text.keeps-crashing"));
watchdog.editor?.enableReadOnlyMode("crashed-editor");
}
hasCrashed.current = true;
const formattedCrash = JSON.stringify(watchdog.crashes, null, 4);
logError(`CKEditor crash logs: ${formattedCrash}`);
if (currentState === "crashed") {
toast.showPersistent({
id: "editor-crashed",
icon: "bx bx-bug",
title: t("editable_text.editor_crashed_title"),
message: t("editable_text.editor_crashed_content"),
buttons: [
{
text: t("editable_text.editor_crashed_details_button"),
onClick: ({ dismissToast }) => {
dismissToast();
dialog.info(<>
<p>{t("editable_text.editor_crashed_details_intro")}</p>
<h3>{t("editable_text.editor_crashed_details_title")}</h3>
<pre><code class="language-application-json">{formattedCrash}</code></pre>
</>, {
title: t("editable_text.editor_crashed_title"),
size: "lg",
copyToClipboardButton: true
});
}
}
],
timeout: 20_000
});
} else if (currentState === "crashedPermanently") {
dialog.info(t("editable_text.keeps-crashing"));
watchdog.editor?.enableReadOnlyMode("crashed-editor");
}
}, []);
return onWatchdogStateChange;
}
function onNotificationWarning(data, evt) {
@ -302,7 +347,7 @@ function EditableTextTouchBar({ watchdogRef, refreshTouchBarRef }: { watchdogRef
const [ headingSelectedIndex, setHeadingSelectedIndex ] = useState<number>();
function refresh() {
let headingSelectedIndex: number | undefined = undefined;
let headingSelectedIndex: number | undefined;
const editor = watchdogRef.current?.editor;
const headingCommand = editor?.commands.get("heading");
const paragraphCommand = editor?.commands.get("paragraph");
@ -316,7 +361,7 @@ function EditableTextTouchBar({ watchdogRef, refreshTouchBarRef }: { watchdogRef
setHeadingSelectedIndex(headingSelectedIndex);
}
useEffect(refresh, []);
useEffect(refresh, [ watchdogRef ]);
refreshTouchBarRef.current = refresh;
return (

View File

@ -220,7 +220,7 @@ function buildListOfLanguages() {
return [
{
language: mimeTypesService.MIME_TYPE_AUTO,
label: t("editable-text.auto-detect-language")
label: t("editable_text.auto-detect-language")
},
...userLanguages
];

View File

@ -4328,6 +4328,28 @@ paths:
text/html:
schema:
type: string
/api/other/to-markdown:
post:
tags: [Utilities]
summary: Renders given HTML to Markdown
operationId: toMarkdown
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [htmlContent]
properties:
htmlContent:
type: string
responses:
'200':
description: The input text rendered as Markdown
content:
text/plain:
schema:
type: string
components:
securitySchemes:

View File

@ -2,6 +2,8 @@ import type { Request } from "express";
import becca from "../../becca/becca.js";
import markdownService from "../../services/import/markdown.js";
import markdown from "../../services/export/markdown.js";
import { RenderMarkdownResponse, ToMarkdownResponse } from "@triliumnext/commons";
function getIconUsage() {
const iconClassToCountMap: Record<string, number> = {};
@ -29,13 +31,26 @@ function getIconUsage() {
function renderMarkdown(req: Request) {
const { markdownContent } = req.body;
if (!markdownContent || typeof markdownContent !== 'string') {
throw new Error('markdownContent parameter is required and must be a string');
}
return {
htmlContent: markdownService.renderToHtml(markdownContent, "")
};
} satisfies RenderMarkdownResponse;
}
function toMarkdown(req: Request) {
const { htmlContent } = req.body;
if (!htmlContent || typeof htmlContent !== 'string') {
throw new Error('htmlContent parameter is required and must be a string');
}
return {
markdownContent: markdown.toMarkdown(htmlContent)
} satisfies ToMarkdownResponse;
}
export default {
getIconUsage,
renderMarkdown
renderMarkdown,
toMarkdown
};

View File

@ -348,6 +348,7 @@ function register(app: express.Application) {
route(GET, "/api/fonts", [auth.checkApiAuthOrElectron], fontsRoute.getFontCss);
apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage);
apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown);
apiRoute(PST, "/api/other/to-markdown", otherRoute.toMarkdown);
apiRoute(GET, "/api/recent-changes/:ancestorNoteId", recentChangesApiRoute.getRecentChanges);
apiRoute(GET, "/api/edited-notes/:date", revisionsApiRoute.getEditedNotesOnDate);

View File

@ -0,0 +1,23 @@
import { CKEditorError, EditorWatchdog } from "ckeditor5";
const IGNORED_ERRORS = [
// See: https://github.com/TriliumNext/Trilium/issues/5776
"TypeError: Cannot read properties of null (reading 'parent')",
// See: https://github.com/TriliumNext/Trilium/issues/7739
"model-nodelist-offset-out-of-bounds"
]
export default class CustomWatchdog extends EditorWatchdog {
_isErrorComingFromThisItem(error: CKEditorError): boolean {
for (const ignoredError of IGNORED_ERRORS) {
if (error.message.includes(ignoredError)) {
return false;
}
}
return super._isErrorComingFromThisItem(error);
}
}

View File

@ -4,7 +4,7 @@ import "./theme/code_block_toolbar.css";
import { COMMON_PLUGINS, CORE_PLUGINS, POPUP_EDITOR_PLUGINS } from "./plugins.js";
import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } from "ckeditor5";
import "./translation_overrides.js";
export { EditorWatchdog } from "ckeditor5";
export { default as EditorWatchdog } from "./custom_watchdog";
export { PREMIUM_PLUGINS } from "./plugins.js";
export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, ModelPosition, ModelElement, WatchdogConfig, WatchdogState } from "ckeditor5";
export type { TemplateDefinition } from "ckeditor5-premium-features";

View File

@ -277,3 +277,11 @@ export interface NoteMapPostResponse {
export interface UpdateAttributeResponse {
attributeId: string;
}
export interface RenderMarkdownResponse {
htmlContent: string;
}
export interface ToMarkdownResponse {
markdownContent: string;
}