mirror of
https://github.com/zadam/trilium.git
synced 2025-12-11 18:04:24 +01:00
CKEditor stability improvements (#7979)
This commit is contained in:
commit
dcaf91a878
@ -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 };
|
||||
|
||||
@ -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 }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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">>;
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}%` }}
|
||||
|
||||
@ -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;
|
||||
|
||||
11
apps/client/src/widgets/dialogs/info.css
Normal file
11
apps/client/src/widgets/dialogs/info.css
Normal file
@ -0,0 +1,11 @@
|
||||
.modal.info-dialog {
|
||||
user-select: text;
|
||||
|
||||
h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
}
|
||||
@ -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>);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
];
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
23
packages/ckeditor5/src/custom_watchdog.ts
Normal file
23
packages/ckeditor5/src/custom_watchdog.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -277,3 +277,11 @@ export interface NoteMapPostResponse {
|
||||
export interface UpdateAttributeResponse {
|
||||
attributeId: string;
|
||||
}
|
||||
|
||||
export interface RenderMarkdownResponse {
|
||||
htmlContent: string;
|
||||
}
|
||||
|
||||
export interface ToMarkdownResponse {
|
||||
markdownContent: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user