Compare commits

...

33 Commits

Author SHA1 Message Date
Elian Doran
cb3aced2ed
docs(script): clarify originEntity and activeContext 2025-12-07 23:58:02 +02:00
Elian Doran
2f13a1ad21
fix(mind-map): show text in links between nodes on export (#7938) 2025-12-07 23:20:18 +02:00
Elian Doran
045127adee
chore(vscode): add errorlens to recommendations 2025-12-07 23:11:07 +02:00
Elian Doran
db1a0c0362
i18n(client): update renamed key 2025-12-07 23:07:30 +02:00
Elian Doran
dcaf91a878
CKEditor stability improvements (#7979) 2025-12-07 22:40:52 +02:00
Elian Doran
a9209f5103
chore(ckeditor/watchdog): accidental comment 2025-12-07 22:32:06 +02:00
Elian Doran
ea613986c2
chore(server): add OpenAPI spec for to-markdown 2025-12-07 22:26:21 +02:00
Elian Doran
1ed46bd47c
refactor(server): add validation for HTML/Markdown rendering 2025-12-07 22:24:14 +02:00
Elian Doran
f1ee79e75a
refactor(client): inconsistent prefix for messages 2025-12-07 22:23:19 +02:00
Elian Doran
cd27160905
chore(ckeditor/watchdog): fix typecheck issues 2025-12-07 22:09:30 +02:00
Elian Doran
9ddf4a1308
feat(ckeditor/watchdog): ignore model out of bounds (closes #7739) 2025-12-07 22:00:10 +02:00
Elian Doran
a1c5ed9eb5
feat(ckeditor/watchdog): ignore parent check (closes #5776) 2025-12-07 21:59:52 +02:00
Elian Doran
7a4f19eada
chore(ckeditor): revert breaking change 2025-12-07 21:34:10 +02:00
Elian Doran
397fb785d6
feat(ckeditor/watchdog): functional copy to clipboard button 2025-12-07 21:21:55 +02:00
Elian Doran
75a1fcc933
chore(dialog): fix mandatory parameter 2025-12-07 21:06:56 +02:00
Elian Doran
292cbf1383
feat(ckeditor/watchdog): add a title to the details screen 2025-12-07 21:03:39 +02:00
Elian Doran
37a14fefb3
feat(ckeditor/watchdog): improve layout of info dialog 2025-12-07 21:01:56 +02:00
Elian Doran
f424633d8c
feat(ckeditor/watchdog): improve dialog size 2025-12-07 20:59:53 +02:00
Elian Doran
048258d2d1
feat(toast): improve button layout 2025-12-07 20:59:41 +02:00
Elian Doran
f779108b6c
feat(ckeditor/watchdog): add a more details button 2025-12-07 20:40:29 +02:00
Elian Doran
522f3ae0a1
feat(toast): support buttons 2025-12-07 20:39:27 +02:00
Elian Doran
3fc7067c59
feat(dialog): support React nodes in info dialog 2025-12-07 20:39:11 +02:00
Elian Doran
c600e8ef89
feat(ckeditor/watchdog): add a toast to indicate the error 2025-12-07 20:16:34 +02:00
Elian Doran
5ad267fe1b
chore(ckeditor): try to disable watchdog timer 2025-12-07 20:09:56 +02:00
Elian Doran
d8b3e438f8
chore(ckeditor): solve a few eslint warnings 2025-12-07 19:46:06 +02:00
Elian Doran
2834af66e9
feat(ckeditor/watchdog): restore focus after crash 2025-12-07 19:36:46 +02:00
Elian Doran
1bbf86fbeb
feat(ckeditor/watchdog): use data stored in the spaced update 2025-12-07 19:30:35 +02:00
Lucas
f976dd8d30
Apply suggestions from code review
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-04 13:08:28 -08:00
Lucas
2d3aa3a96e
Merge branch 'main' into main 2025-12-04 13:04:24 -08:00
lzinga
2666c1e196 feat(snapdom): update screenshot generation options for SVG and PNG exports 2025-12-04 12:52:01 -08:00
Lucas
4b8c8888ee
Merge branch 'TriliumNext:main' into main 2025-12-04 12:49:37 -08:00
lzinga
ce1fd64aa9 feat(export): enhance SVG and PNG export functionality with snapdom integration 2025-12-04 12:49:10 -08:00
lzinga
5c9503732d fix(mind-map): show text in links between nodes on export 2025-12-04 11:08:44 -08:00
48 changed files with 646 additions and 336 deletions

View File

@ -8,6 +8,7 @@
"redhat.vscode-yaml",
"tobermory.es6-string-html",
"vitest.explorer",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"usernamehw.errorlens"
]
}

View File

@ -34,6 +34,7 @@
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@zumer/snapdom": "2.0.1",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",

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

@ -77,6 +77,10 @@ export interface Api {
/**
* Entity whose event triggered this execution.
*
* <p>
* For front-end scripts, generally there's no origin entity specified since the scripts are run by the user or automatically by the UI (widgets).
* If there is an origin entity specified, then it's going to be a note entity.
*/
originEntity: unknown | null;
@ -278,12 +282,16 @@ export interface Api {
getActiveContextNote(): FNote;
/**
* @returns returns active context (split)
* Obtains the currently active/focused split in the current tab.
*
* Note that this method does not return the note context of the "Quick edit" panel, it will return the note context behind it.
*/
getActiveContext(): NoteContext;
/**
* @returns returns active main context (first split in a tab, represents the tab as a whole)
* Obtains the main context of the current tab. This is the left-most split.
*
* Note that this method does not return the note context of the "Quick edit" panel, it will return the note context behind it.
*/
getActiveMainContext(): NoteContext;

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

@ -1,6 +1,7 @@
import { dayjs } from "@triliumnext/commons";
import type { ViewScope } from "./link.js";
import FNote from "../entities/fnote";
import { snapdom } from "@zumer/snapdom";
const SVG_MIME = "image/svg+xml";
@ -628,16 +629,69 @@ export function createImageSrcUrl(note: FNote) {
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
}
/**
* Given a string representation of an SVG, triggers a download of the file on the client device.
* Helper function to prepare an element for snapdom rendering.
* Handles string parsing and temporary DOM attachment for style computation.
*
* @param source - Either an SVG/HTML string to be parsed, or an existing SVG/HTML element.
* @returns An object containing the prepared element and a cleanup function.
* The cleanup function removes temporarily attached elements from the DOM,
* or is a no-op if the element was already in the DOM.
*/
function prepareElementForSnapdom(source: string | SVGElement | HTMLElement): {
element: SVGElement | HTMLElement;
cleanup: () => void;
} {
if (typeof source === 'string') {
const parser = new DOMParser();
// Detect if content is SVG or HTML
const isSvg = source.trim().startsWith('<svg');
const mimeType = isSvg ? SVG_MIME : 'text/html';
const doc = parser.parseFromString(source, mimeType);
const element = doc.documentElement;
// Temporarily attach to DOM for proper style computation
element.style.position = 'absolute';
element.style.left = '-9999px';
element.style.top = '-9999px';
document.body.appendChild(element);
return {
element,
cleanup: () => document.body.removeChild(element)
};
}
return {
element: source,
cleanup: () => {} // No-op for existing elements
};
}
/**
* Downloads an SVG using snapdom for proper rendering. Can accept either an SVG string, an SVG element, or an HTML element.
*
* @param nameWithoutExtension the name of the file. The .svg suffix is automatically added to it.
* @param svgContent the content of the SVG file download.
* @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be downloaded.
*/
function downloadSvg(nameWithoutExtension: string, svgContent: string) {
const filename = `${nameWithoutExtension}.svg`;
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
triggerDownload(filename, dataUrl);
async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
const { element, cleanup } = prepareElementForSnapdom(svgSource);
try {
const result = await snapdom(element, {
backgroundColor: "transparent",
scale: 2
});
triggerDownload(`${nameWithoutExtension}.svg`, result.url);
} finally {
cleanup();
}
}
/**
@ -658,62 +712,26 @@ function triggerDownload(fileName: string, dataUrl: string) {
document.body.removeChild(element);
}
/**
* Given a string representation of an SVG, renders the SVG to PNG and triggers a download of the file on the client device.
*
* Note that the SVG must specify its width and height as attributes in order for it to be rendered.
* Downloads an SVG as PNG using snapdom. Can accept either an SVG string, an SVG element, or an HTML element.
*
* @param nameWithoutExtension the name of the file. The .png suffix is automatically added to it.
* @param svgContent the content of the SVG file download.
* @returns a promise which resolves if the operation was successful, or rejects if it failed (permissions issue or some other issue).
* @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be converted to PNG.
*/
function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) {
return new Promise<void>((resolve, reject) => {
// First, we need to determine the width and the height from the input SVG.
const result = getSizeFromSvg(svgContent);
if (!result) {
reject();
return;
}
async function downloadAsPng(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
const { element, cleanup } = prepareElementForSnapdom(svgSource);
// Convert the image to a blob.
const { width, height } = result;
// Create an image element and load the SVG.
const imageEl = new Image();
imageEl.width = width;
imageEl.height = height;
imageEl.crossOrigin = "anonymous";
imageEl.onload = () => {
try {
// Draw the image with a canvas.
const canvasEl = document.createElement("canvas");
canvasEl.width = imageEl.width;
canvasEl.height = imageEl.height;
document.body.appendChild(canvasEl);
const ctx = canvasEl.getContext("2d");
if (!ctx) {
reject();
}
ctx?.drawImage(imageEl, 0, 0);
const imgUri = canvasEl.toDataURL("image/png")
triggerDownload(`${nameWithoutExtension}.png`, imgUri);
document.body.removeChild(canvasEl);
resolve();
} catch (e) {
console.warn(e);
reject();
}
};
imageEl.onerror = (e) => reject(e);
imageEl.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
const result = await snapdom(element, {
backgroundColor: "transparent",
scale: 2
});
const pngImg = await result.toPng();
await triggerDownload(`${nameWithoutExtension}.png`, pngImg.src);
} finally {
cleanup();
}
}
export function getSizeFromSvg(svgContent: string) {
const svgDocument = (new DOMParser()).parseFromString(svgContent, SVG_MIME);
@ -925,8 +943,8 @@ export default {
areObjectsEqual,
copyHtmlToClipboard,
createImageSrcUrl,
downloadSvg,
downloadSvgAsPng,
downloadAsSvg,
downloadAsPng,
compareVersions,
isUpdateAvailable,
isLaunchBarConfig

View File

@ -948,7 +948,7 @@
"move-to-available-launchers": "نقل الى المشغلات المتوفرة",
"duplicate-launcher": "تكرار المشغل <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"editable_text": {
"auto-detect-language": "تم اكتشافه تلقائيا"
},
"classic_editor_toolbar": {

View File

@ -986,7 +986,9 @@
"placeholder": "在这里输入您的代码笔记内容..."
},
"editable_text": {
"placeholder": "在这里输入您的笔记内容..."
"placeholder": "在这里输入您的笔记内容...",
"auto-detect-language": "自动检测",
"keeps-crashing": "编辑组件时持续崩溃。请尝试重启 Trilium。如果问题仍然存在请考虑提交错误报告。"
},
"empty": {
"open_note_instruction": "通过在下面的输入框中输入笔记标题或在树中选择笔记来打开笔记。",
@ -1660,10 +1662,6 @@
"move-to-available-launchers": "移动到可用启动器",
"duplicate-launcher": "复制启动器 <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "自动检测",
"keeps-crashing": "编辑组件时持续崩溃。请尝试重启 Trilium。如果问题仍然存在请考虑提交错误报告。"
},
"highlighting": {
"title": "代码块",
"description": "控制文本笔记中代码块的语法高亮,代码笔记不会受到影响。",
@ -2106,5 +2104,10 @@
},
"popup-editor": {
"maximize": "切换至完整编辑器"
},
"server": {
"unknown_http_error_title": "与服务器通讯错误",
"unknown_http_error_content": "状态码: {{statusCode}}\n地址: {{method}} {{url}}\n信息: {{message}}",
"traefik_blocks_requests": "如果您使用 Traefik 反向代理,它引入了一项影响与服务器的通信重大更改。"
}
}

View File

@ -983,7 +983,9 @@
"placeholder": "Gebe hier den Inhalt deiner Codenotiz ein..."
},
"editable_text": {
"placeholder": "Gebe hier den Inhalt deiner Notiz ein..."
"placeholder": "Gebe hier den Inhalt deiner Notiz ein...",
"auto-detect-language": "Automatisch erkannt",
"keeps-crashing": "Die Bearbeitungskomponente stürzt immer wieder ab. Bitte starten Sie Trilium neu. Wenn das Problem weiterhin besteht, erstellen Sie einen Fehlerbericht."
},
"empty": {
"open_note_instruction": "Öffne eine Notiz, indem du den Titel der Notiz in die Eingabe unten eingibst oder eine Notiz in der Baumstruktur auswählst.",
@ -1624,10 +1626,6 @@
"move-to-available-launchers": "Zu verfügbaren Launchern verschieben",
"duplicate-launcher": "Launcher duplizieren <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Automatisch erkannt",
"keeps-crashing": "Die Bearbeitungskomponente stürzt immer wieder ab. Bitte starten Sie Trilium neu. Wenn das Problem weiterhin besteht, erstellen Sie einen Fehlerbericht."
},
"highlighting": {
"description": "Steuert die Syntaxhervorhebung für Codeblöcke in Textnotizen, Code-Notizen sind nicht betroffen.",
"color-scheme": "Farbschema",

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.",
@ -1968,7 +1972,8 @@
"button_title": "Export diagram as PNG"
},
"svg": {
"export_to_png": "The diagram could not be exported to PNG."
"export_to_png": "The diagram could not be exported to PNG.",
"export_to_svg": "The diagram could not be exported to SVG."
},
"code_theme": {
"title": "Appearance",

View File

@ -987,7 +987,8 @@
"placeholder": "Escriba el contenido de su nota de código aquí..."
},
"editable_text": {
"placeholder": "Escribe aquí el contenido de tu nota..."
"placeholder": "Escribe aquí el contenido de tu nota...",
"auto-detect-language": "Detectado automáticamente"
},
"empty": {
"open_note_instruction": "Abra una nota escribiendo el título de la nota en la entrada a continuación o elija una nota en el árbol.",
@ -1812,9 +1813,6 @@
"move-to-available-launchers": "Mover a lanzadores disponibles",
"duplicate-launcher": "Duplicar lanzador <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Detectado automáticamente"
},
"highlighting": {
"title": "Bloques de código",
"description": "Controla el resaltado de sintaxis para bloques de código dentro de las notas de texto, las notas de código no serán afectadas.",

View File

@ -981,7 +981,8 @@
"placeholder": "Saisir le contenu de votre note de code ici..."
},
"editable_text": {
"placeholder": "Saisir le contenu de votre note ici..."
"placeholder": "Saisir le contenu de votre note ici...",
"auto-detect-language": "Détecté automatiquement"
},
"empty": {
"open_note_instruction": "Ouvrez une note en tapant son titre dans la zone ci-dessous ou choisissez une note dans l'arborescence.",
@ -1617,9 +1618,6 @@
"move-to-available-launchers": "Déplacer vers les raccourcis disponibles",
"duplicate-launcher": "Dupliquer le raccourci <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Détecté automatiquement"
},
"highlighting": {
"description": "Contrôle la coloration syntaxique des blocs de code à l'intérieur des notes texte, les notes de code ne seront pas affectées.",
"color-scheme": "Jeu de couleurs",

View File

@ -1491,7 +1491,9 @@
"placeholder": "Digita qui il contenuto della tua nota di codice..."
},
"editable_text": {
"placeholder": "Digita qui il contenuto della tua nota..."
"placeholder": "Digita qui il contenuto della tua nota...",
"auto-detect-language": "Rilevato automaticamente",
"keeps-crashing": "Il componente di modifica continua a bloccarsi. Prova a riavviare Trilium. Se il problema persiste, valuta la possibilità di creare una segnalazione di bug."
},
"empty": {
"open_note_instruction": "Apri una nota digitandone il titolo nel campo sottostante oppure scegli una nota nell'albero.",
@ -1937,10 +1939,6 @@
"move-to-available-launchers": "Passa ai launcher disponibili",
"duplicate-launcher": "Duplica il launcher <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Rilevato automaticamente",
"keeps-crashing": "Il componente di modifica continua a bloccarsi. Prova a riavviare Trilium. Se il problema persiste, valuta la possibilità di creare una segnalazione di bug."
},
"highlighting": {
"title": "Blocchi di codice",
"description": "Controlla l'evidenziazione della sintassi per i blocchi di codice all'interno delle note di testo; le note di codice non saranno interessate.",

View File

@ -1268,10 +1268,6 @@
"duplicate-launcher": "ランチャーの複製 <kbd data-command=\"duplicateSubtree\">",
"reset_launcher_confirm": "本当に「{{title}}」をリセットしますか? このノート(およびその子ノート)のすべてのデータと設定が失われ、ランチャーは元の場所に戻ります。"
},
"editable-text": {
"auto-detect-language": "自動検出",
"keeps-crashing": "編集コンポーネントがクラッシュし続けます。Trilium を再起動してください。問題が解決しない場合は、バグレポートの作成をご検討ください。"
},
"highlighting": {
"title": "コードブロック",
"description": "テキストノート内のコードブロックのシンタックスハイライトを制御します。コードノートには影響しません。",
@ -1778,7 +1774,9 @@
"placeholder": "ここにコードノートの内容を入力..."
},
"editable_text": {
"placeholder": "ここにノートの内容を入力..."
"placeholder": "ここにノートの内容を入力...",
"auto-detect-language": "自動検出",
"keeps-crashing": "編集コンポーネントがクラッシュし続けます。Trilium を再起動してください。問題が解決しない場合は、バグレポートの作成をご検討ください。"
},
"empty": {
"open_note_instruction": "以下の入力欄にノートのタイトルを入力するか、ツリー内のノートを選択してノートを開きます。",

View File

@ -1330,7 +1330,8 @@
"placeholder": "Wpisz tutaj treść swojej notatki kodowej..."
},
"editable_text": {
"placeholder": "Wpisz tutaj treść swojej notatki..."
"placeholder": "Wpisz tutaj treść swojej notatki...",
"auto-detect-language": "Wykryto automatycznie"
},
"empty": {
"open_note_instruction": "Otwórz notatkę, wpisując jej tytuł w poniższe pole lub wybierz notatkę z drzewa.",
@ -2014,9 +2015,6 @@
"move-to-available-launchers": "Przenieś do dostępnych programów uruchamiających",
"duplicate-launcher": "Duplikuj program uruchamiający <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Wykryto automatycznie"
},
"highlighting": {
"title": "Bloki kodu",
"description": "Kontroluje podświetlanie składni dla bloków kodu w notatkach tekstowych, notatki kodowe nie będą miały wpływu.",

View File

@ -954,7 +954,8 @@
"placeholder": "Digite o conteúdo da sua nota de código aqui…"
},
"editable_text": {
"placeholder": "Digite o conteúdo da sua nota aqui…"
"placeholder": "Digite o conteúdo da sua nota aqui…",
"auto-detect-language": "Detetado automaticamente"
},
"empty": {
"open_note_instruction": "Abra uma nota a digitar o título da nota no campo abaixo ou escolha uma nota na árvore.",
@ -1768,9 +1769,6 @@
"move-to-available-launchers": "Mover para lançadores disponíveis",
"duplicate-launcher": "Duplicar o lançador <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Detetado automaticamente"
},
"highlighting": {
"title": "Blocos de Código",
"description": "Controla o destaque de sintaxe para blocos de código dentro de notas de texto, notas de código não serão afetadas.",

View File

@ -1191,7 +1191,8 @@
"placeholder": "Digite o conteúdo da sua nota de código aqui…"
},
"editable_text": {
"placeholder": "Digite o conteúdo da sua nota aqui…"
"placeholder": "Digite o conteúdo da sua nota aqui…",
"auto-detect-language": "Detectado automaticamente"
},
"empty": {
"search_placeholder": "buscar uma nota pelo nome",
@ -1689,9 +1690,6 @@
"move-to-available-launchers": "Mover para lançadores disponíveis",
"duplicate-launcher": "Duplicar o lançador <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Detectado automaticamente"
},
"highlighting": {
"title": "Blocos de Código",
"description": "Controla o destaque de sintaxe para blocos de código dentro de notas de texto, notas de código não serão afetadas.",

View File

@ -491,7 +491,9 @@
"placeholder": "Scrieți conținutul notiței de cod aici..."
},
"editable_text": {
"placeholder": "Scrieți conținutul notiței aici..."
"placeholder": "Scrieți conținutul notiței aici...",
"auto-detect-language": "Automat",
"keeps-crashing": "Componenta de editare se blochează în continuu. Încercați să reporniți Trilium. Dacă problema persistă, luați în considerare să raportați această problemă."
},
"edited_notes": {
"deleted": "(șters)",
@ -1626,10 +1628,6 @@
"move-to-visible-launchers": "Mută în Lansatoare vizibile",
"reset": "Resetează"
},
"editable-text": {
"auto-detect-language": "Automat",
"keeps-crashing": "Componenta de editare se blochează în continuu. Încercați să reporniți Trilium. Dacă problema persistă, luați în considerare să raportați această problemă."
},
"highlighting": {
"color-scheme": "Temă de culori",
"description": "Controlează evidențierea de sintaxă pentru blocurile de cod în interiorul notițelor text, notițele de tip cod nu vor fi afectate de aceste setări.",

View File

@ -726,9 +726,6 @@
"title": "Блоки кода",
"description": "Управляет подсветкой синтаксиса для блоков кода внутри текстовых заметок. Заметки с типом \"Код\" не будут затронуты."
},
"editable-text": {
"auto-detect-language": "Определен автоматически"
},
"launcher_context_menu": {
"reset": "Сбросить",
"add-spacer": "Добавить разделитель",
@ -2026,7 +2023,8 @@
"placeholder": "Введите содержимое для заметки с кодом..."
},
"editable_text": {
"placeholder": "Введите содержимое для заметки..."
"placeholder": "Введите содержимое для заметки...",
"auto-detect-language": "Определен автоматически"
},
"hoisted_note": {
"confirm_unhoisting": "Запрошенная заметка «{{requestedNote}}» находится за пределами поддерева закрепленной заметки \"{{hoistedNote}}\", и для доступа к ней необходимо снять закрепление. Открепить заметку?"

View File

@ -984,7 +984,9 @@
"placeholder": "在這裡輸入您的程式碼筆記內容…"
},
"editable_text": {
"placeholder": "在這裡輸入您的筆記內容…"
"placeholder": "在這裡輸入您的筆記內容…",
"auto-detect-language": "自動檢測",
"keeps-crashing": "編輯元件持續發生崩潰。請嘗試重新啟動 Trilium。若問題仍存在請考慮提交錯誤報告。"
},
"empty": {
"open_note_instruction": "透過在下面的輸入框中輸入筆記標題或在樹中選擇筆記來打開筆記。",
@ -1620,10 +1622,6 @@
"move-to-available-launchers": "移動至可用啟動器",
"duplicate-launcher": "複製啟動器 <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "自動檢測",
"keeps-crashing": "編輯元件持續發生崩潰。請嘗試重新啟動 Trilium。若問題仍存在請考慮提交錯誤報告。"
},
"highlighting": {
"description": "控制文字筆記程式碼區塊中的語法高亮,程式碼筆記不會受到影響。",
"color-scheme": "配色方案",

View File

@ -130,9 +130,6 @@
"move-to-available-launchers": "Перейти до доступних лаунчерів",
"duplicate-launcher": "Дублікат програми запуску <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Автовизначено"
},
"highlighting": {
"color-scheme": "Схема кольорів",
"title": "Блоки коду",
@ -1076,7 +1073,8 @@
"placeholder": "Введіть тут вміст вашої нотатки з кодом..."
},
"editable_text": {
"placeholder": "Введіть тут вміст вашої нотатки..."
"placeholder": "Введіть тут вміст вашої нотатки...",
"auto-detect-language": "Автовизначено"
},
"empty": {
"open_note_instruction": "Відкрийте нотатку, ввівши її заголовок в поле нижче, або виберіть нотатку в дереві.",

View File

@ -48,7 +48,15 @@
background-color: unset !important;
}
.toast .toast-progress {
.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;
@ -57,3 +65,5 @@
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,10 +4,12 @@ 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;
}
@ -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
});
@ -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
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)[];

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

@ -11,6 +11,7 @@ import { useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEve
import { refToJQuerySelector } from "../react/react_utils";
import utils from "../../services/utils";
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
import { snapdom, SnapdomOptions } from "@zumer/snapdom";
const NEW_TOPIC_NAME = "";
@ -45,11 +46,24 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const apiRef = useRef<MindElixirInstance>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const spacedUpdate = useEditorSpacedUpdate({
note,
noteContext,
getData: async () => {
if (!apiRef.current) return;
const result = await snapdom(apiRef.current.nodes, {
backgroundColor: "transparent",
scale: 2
});
// a data URL in the format: "data:image/svg+xml;charset=utf-8,<url-encoded-svg>"
// We need to extract the content after the comma and decode the URL encoding (%3C to <, %20 to space, etc.)
// to get raw SVG content that Trilium's backend can store as an attachment
const svgContent = decodeURIComponent(result.url.split(',')[1]);
return {
content: apiRef.current.getDataString(),
attachments: [
@ -57,7 +71,7 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
role: "image",
title: "mindmap-export.svg",
mime: "image/svg+xml",
content: await apiRef.current.exportSvg().text(),
content: svgContent,
position: 0
}
]
@ -88,13 +102,13 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
// Export as PNG or SVG.
useTriliumEvents([ "exportSvg", "exportPng" ], async ({ ntxId: eventNtxId }, eventName) => {
if (eventNtxId !== ntxId || !apiRef.current) return;
const title = note.title;
const svg = await apiRef.current.exportSvg().text();
const nodes = apiRef.current.nodes;
if (eventName === "exportSvg") {
utils.downloadSvg(title, svg);
await utils.downloadAsSvg(note.title, nodes);
} else {
utils.downloadSvgAsPng(title, svg);
await utils.downloadAsPng(note.title, nodes);
}
});
const onKeyDown = useCallback((e: KeyboardEvent) => {

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

@ -77,14 +77,25 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
}, [ note ]);
// Import/export
useTriliumEvent("exportSvg", ({ ntxId: eventNtxId }) => {
useTriliumEvent("exportSvg", async({ ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId || !svg) return;
utils.downloadSvg(note.title, svg);
try {
const svgEl = containerRef.current?.querySelector("svg");
if (!svgEl) throw new Error("SVG element not found");
await utils.downloadAsSvg(note.title, svgEl);
} catch (e) {
console.warn(e);
toast.showError(t("svg.export_to_svg"));
}
});
useTriliumEvent("exportPng", async ({ ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId || !svg) return;
try {
await utils.downloadSvgAsPng(note.title, svg);
const svgEl = containerRef.current?.querySelector("svg");
if (!svgEl) throw new Error("SVG element not found");
await utils.downloadAsPng(note.title, svgEl);
} catch (e) {
console.warn(e);
toast.showError(t("svg.export_to_png"));

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) {
function useWatchdogCrashHandling() {
const hasCrashed = useRef(false);
const onWatchdogStateChange = useCallback((watchdog: EditorWatchdog) => {
const currentState = watchdog.state;
logInfo(`CKEditor state changed to ${currentState}`);
if (currentState === "ready") {
hasCrashed.current = false;
watchdog.editor?.focus();
}
if (!["crashed", "crashedPermanently"].includes(currentState)) {
return;
}
logError(`CKEditor crash logs: ${JSON.stringify(watchdog.crashes, null, 4)}`);
hasCrashed.current = true;
const formattedCrash = JSON.stringify(watchdog.crashes, null, 4);
logError(`CKEditor crash logs: ${formattedCrash}`);
if (currentState === "crashedPermanently") {
dialog.info(t("editable-text.keeps-crashing"));
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

@ -4,7 +4,7 @@
<h2>Breaking change in Traefik 3.6.4</h2>
<p>Traefik 3.6.4 introduced a <a href="https://doc.traefik.io/traefik/migrate/v3/#encoded-characters-in-request-path">breaking change</a> regarding
how percent-encoded characters are handled in URLs. More specifically some
URLs used by Trilium (such as <code spellcheck="false">search/%23workspace%20%23!template</code>)
URLs used by Trilium (such as <code>search/%23workspace%20%23!template</code>)
are automatically rejected by Traefik, resulting in HTTP 400 errors.</p>
<p>To solve this, the Traefik <a href="https://doc.traefik.io/traefik/getting-started/configuration-overview/#the-install-configuration"><strong>static</strong> configuration</a> must
be modified in order to allow those characters:</p><pre><code class="language-text-x-yaml">entryPoints:
@ -36,7 +36,7 @@
<h3>Setup needed environment variables</h3>
<p>After setting up a reverse proxy, make sure to configure the&nbsp;<a class="reference-link"
href="#root/_help_LLzSMXACKhUs">Trusted proxy</a>.</p>
<h3>Example <code spellcheck="false">docker-compose.yaml</code></h3><pre><code class="language-text-x-yaml">services:
<h3>Example <code>docker-compose.yaml</code></h3><pre><code class="language-text-x-yaml">services:
trilium:
image: triliumnext/trilium
container_name: trilium

View File

@ -5,6 +5,7 @@
<p>Global events are attached to the script note via label. Simply create
e.g. "run" label with some of these values and script note will be executed
once the event occurs.</p>
<figure class="table">
<table>
<thead>
<tr>
@ -14,113 +15,159 @@
</thead>
<tbody>
<tr>
<td><code>run</code>
<td><code spellcheck="false">run</code>
</td>
<td>
<p>Defines on which events script should run. Possible values are:</p>
<ul>
<li><code>frontendStartup</code> - when Trilium frontend starts up (or is refreshed),
but not on mobile.</li>
<li><code>mobileStartup</code> - when Trilium frontend starts up (or is refreshed),
on mobile.</li>
<li><code>backendStartup</code> - when Trilium backend starts up</li>
<li><code>hourly</code> - run once an hour. You can use additional label <code>runAtHour</code> to
specify at which hour, on the back-end.</li>
<li><code>daily</code> - run once a day, on the back-end</li>
<li data-list-item-id="e244b14e102cf1b0d4954e8fd455ea77b"><code spellcheck="false">frontendStartup</code> - when Trilium frontend
starts up (or is refreshed), but not on mobile.</li>
<li data-list-item-id="ea8f8ca86e7b351dd86108848ccb9103a"><code spellcheck="false">mobileStartup</code> - when Trilium frontend starts
up (or is refreshed), on mobile.</li>
<li data-list-item-id="e658488cf1a0862603088ef384e41b8b6"><code spellcheck="false">backendStartup</code> - when Trilium backend starts
up</li>
<li data-list-item-id="ef40ba992fc450d33a18ca4cb031eca66"><code spellcheck="false">hourly</code> - run once an hour. You can use
additional label <code spellcheck="false">runAtHour</code> to specify at
which hour, on the back-end.</li>
<li data-list-item-id="e07458d4f55b6eb42468a5535b8425c5f"><code spellcheck="false">daily</code> - run once a day, on the back-end</li>
</ul>
</td>
</tr>
<tr>
<td><code>runOnInstance</code>
<td><code spellcheck="false">runOnInstance</code>
</td>
<td>Specifies that the script should only run on a particular&nbsp;<a class="reference-link"
href="#root/_help_c5xB8m4g2IY6">Trilium instance</a>.</td>
</tr>
<tr>
<td><code>runAtHour</code>
<td><code spellcheck="false">runAtHour</code>
</td>
<td>On which hour should this run. Should be used together with <code>#run=hourly</code>.
<td>On which hour should this run. Should be used together with <code spellcheck="false">#run=hourly</code>.
Can be defined multiple times for more runs during the day.</td>
</tr>
</tbody>
</table>
</figure>
<h2>Entity events</h2>
<p>Other events are bound to some entity, these are defined as <a href="#root/_help_zEY4DaJG4YT5">relations</a> -
meaning that script is triggered only if note has this script attached
to it through relations (or it can inherit it).</p>
<table>
<figure class="table"
style="width:100%;">
<table class="ck-table-resized">
<colgroup>
<col style="width:20.65%;">
<col style="width:47.48%;">
<col style="width:31.87%;">
</colgroup>
<thead>
<tr>
<th>Relation</th>
<th>Description</th>
<th>Trigger condition</th>
<th>Origin entity (see below)</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>runOnNoteCreation</code>
<td><code spellcheck="false">runOnNoteCreation</code>
</td>
<td>executes when note is created on backend. Use this relation if you want
to run the script for all notes created under a specific subtree. In that
case, create it on the subtree root note and make it inheritable. A new
note created within the subtree (any depth) will trigger the script.</td>
<td>The <code spellcheck="false">BNote</code> that got created.</td>
</tr>
<tr>
<td><code>runOnChildNoteCreation</code>
<td><code spellcheck="false">runOnChildNoteCreation</code>
</td>
<td>executes when new note is created under the note where this relation is
defined</td>
<td>The <code spellcheck="false">BNote</code> of the child that got created.</td>
</tr>
<tr>
<td><code>runOnNoteTitleChange</code>
<td><code spellcheck="false">runOnNoteTitleChange</code>
</td>
<td>executes when note title is changed (includes note creation as well)</td>
<td>The <code spellcheck="false">BNote</code> of the note whose title got changed.</td>
</tr>
<tr>
<td><code>runOnNoteContentChange</code>
<td><code spellcheck="false">runOnNoteContentChange</code>
</td>
<td>executes when note content is changed (includes note creation as well).</td>
<td>The <code spellcheck="false">BNote</code> of the note whose content got
changed.</td>
</tr>
<tr>
<td><code>runOnNoteChange</code>
<td><code spellcheck="false">runOnNoteChange</code>
</td>
<td>executes when note is changed (includes note creation as well). Does not
include content changes</td>
<td>The <code spellcheck="false">BNote</code> of the note that got changed.</td>
</tr>
<tr>
<td><code>runOnNoteDeletion</code>
<td><code spellcheck="false">runOnNoteDeletion</code>
</td>
<td>executes when note is being deleted</td>
<td>The <code spellcheck="false">BNote</code> of the note that got (soft) deleted.</td>
</tr>
<tr>
<td><code>runOnBranchCreation</code>
<td><code spellcheck="false">runOnBranchCreation</code>
</td>
<td>executes when a branch is created. Branch is a link between parent note
and child note and is created e.g. when cloning or moving note.</td>
<td>The <code spellcheck="false">BBranch</code> that got created.</td>
</tr>
<tr>
<td><code>runOnBranchChange</code>
<td><code spellcheck="false">runOnBranchChange</code>
</td>
<td>executes when a branch is updated. (since v0.62)</td>
<td>The <code spellcheck="false">BBranch</code> that got changed.</td>
</tr>
<tr>
<td><code>runOnBranchDeletion</code>
<td><code spellcheck="false">runOnBranchDeletion</code>
</td>
<td>executes when a branch is deleted. Branch is a link between parent note
and child note and is deleted e.g. when moving note (old branch/link is
deleted).</td>
<td>The <code spellcheck="false">BBranch</code> that got (soft) deleted.</td>
</tr>
<tr>
<td><code>runOnAttributeCreation</code>
<td><code spellcheck="false">runOnAttributeCreation</code>
</td>
<td>executes when new attribute is created for the note which defines this
relation</td>
<td>The <code spellcheck="false">BAttribute</code> that got created.</td>
</tr>
<tr>
<td><code>runOnAttributeChange</code>
<td><code spellcheck="false">runOnAttributeChange</code>
</td>
<td>executes when the attribute is changed of a note which defines this relation.
This is triggered also when the attribute is deleted</td>
<td>The <code spellcheck="false">BAttribute</code> that got changed.</td>
</tr>
</tbody>
</table>
</figure>
<h2>Origin entity</h2>
<p>When a script is run by an event such as the ones described above,
<code
spellcheck="false">api.originEntity</code>will get populated with the note, branch or attribute
that triggered the change.</p>
<p>For example, here's a script with <code spellcheck="false">~runOnAttributeChange</code> which
automatically changes the color of a note based on the value of the
<code
spellcheck="false">mycategory</code>label:</p><pre><code class="language-application-javascript-env-frontend">const attr = api.originEntity;
if (attr.name !== "mycategory") return;
const note = api.getNote(attr.noteId);
if (attr.value === "Health") {
note.setLabel("color", "green");
} else {
note.removeLabel("color");
}</code></pre>

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

@ -1,5 +1,5 @@
# Documentation
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/tZh9T30Ojdsq/Documentation_image.png" width="205" height="162">
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/iivQU4mW8qJE/Documentation_image.png" width="205" height="162">
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing <kbd>F1</kbd>.
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.

View File

@ -5,22 +5,39 @@
Global events are attached to the script note via label. Simply create e.g. "run" label with some of these values and script note will be executed once the event occurs.
<table><thead><tr><th>Label</th><th>Description</th></tr></thead><tbody><tr><td><code>run</code></td><td><p>Defines on which events script should run. Possible values are:</p><ul><li><code>frontendStartup</code> - when Trilium frontend starts up (or is refreshed), but not on mobile.</li><li><code>mobileStartup</code> - when Trilium frontend starts up (or is refreshed), on mobile.</li><li><code>backendStartup</code> - when Trilium backend starts up</li><li><code>hourly</code> - run once an hour. You can use additional label <code>runAtHour</code> to specify at which hour, on the back-end.</li><li><code>daily</code> - run once a day, on the back-end</li></ul></td></tr><tr><td><code>runOnInstance</code></td><td>Specifies that the script should only run on a particular&nbsp;<a class="reference-link" href="../../Advanced%20Usage/Configuration%20(config.ini%20or%20environment%20variables)/Trilium%20instance.md">Trilium instance</a>.</td></tr><tr><td><code>runAtHour</code></td><td>On which hour should this run. Should be used together with <code>#run=hourly</code>. Can be defined multiple times for more runs during the day.</td></tr></tbody></table>
<table><thead><tr><th>Label</th><th>Description</th></tr></thead><tbody><tr><td><code spellcheck="false">run</code></td><td><p>Defines on which events script should run. Possible values are:</p><ul><li data-list-item-id="e244b14e102cf1b0d4954e8fd455ea77b"><code spellcheck="false">frontendStartup</code> - when Trilium frontend starts up (or is refreshed), but not on mobile.</li><li data-list-item-id="ea8f8ca86e7b351dd86108848ccb9103a"><code spellcheck="false">mobileStartup</code> - when Trilium frontend starts up (or is refreshed), on mobile.</li><li data-list-item-id="e658488cf1a0862603088ef384e41b8b6"><code spellcheck="false">backendStartup</code> - when Trilium backend starts up</li><li data-list-item-id="ef40ba992fc450d33a18ca4cb031eca66"><code spellcheck="false">hourly</code> - run once an hour. You can use additional label <code spellcheck="false">runAtHour</code> to specify at which hour, on the back-end.</li><li data-list-item-id="e07458d4f55b6eb42468a5535b8425c5f"><code spellcheck="false">daily</code> - run once a day, on the back-end</li></ul></td></tr><tr><td><code spellcheck="false">runOnInstance</code></td><td>Specifies that the script should only run on a particular&nbsp;<a class="reference-link" href="../../Advanced%20Usage/Configuration%20(config.ini%20or%20environment%20variables)/Trilium%20instance.md">Trilium instance</a>.</td></tr><tr><td><code spellcheck="false">runAtHour</code></td><td>On which hour should this run. Should be used together with <code spellcheck="false">#run=hourly</code>. Can be defined multiple times for more runs during the day.</td></tr></tbody></table>
## Entity events
Other events are bound to some entity, these are defined as [relations](../../Advanced%20Usage/Attributes.md) - meaning that script is triggered only if note has this script attached to it through relations (or it can inherit it).
| Relation | Description |
| --- | --- |
| `runOnNoteCreation` | executes when note is created on backend. Use this relation if you want to run the script for all notes created under a specific subtree. In that case, create it on the subtree root note and make it inheritable. A new note created within the subtree (any depth) will trigger the script. |
| `runOnChildNoteCreation` | executes when new note is created under the note where this relation is defined |
| `runOnNoteTitleChange` | executes when note title is changed (includes note creation as well) |
| `runOnNoteContentChange` | executes when note content is changed (includes note creation as well). |
| `runOnNoteChange` | executes when note is changed (includes note creation as well). Does not include content changes |
| `runOnNoteDeletion` | executes when note is being deleted |
| `runOnBranchCreation` | executes when a branch is created. Branch is a link between parent note and child note and is created e.g. when cloning or moving note. |
| `runOnBranchChange` | executes when a branch is updated. (since v0.62) |
| `runOnBranchDeletion` | executes when a branch is deleted. Branch is a link between parent note and child note and is deleted e.g. when moving note (old branch/link is deleted). |
| `runOnAttributeCreation` | executes when new attribute is created for the note which defines this relation |
| `runOnAttributeChange` | executes when the attribute is changed of a note which defines this relation. This is triggered also when the attribute is deleted |
| Relation | Trigger condition | Origin entity (see below) |
| --- | --- | --- |
| `runOnNoteCreation` | executes when note is created on backend. Use this relation if you want to run the script for all notes created under a specific subtree. In that case, create it on the subtree root note and make it inheritable. A new note created within the subtree (any depth) will trigger the script. | The `BNote` that got created. |
| `runOnChildNoteCreation` | executes when new note is created under the note where this relation is defined | The `BNote` of the child that got created. |
| `runOnNoteTitleChange` | executes when note title is changed (includes note creation as well) | The `BNote` of the note whose title got changed. |
| `runOnNoteContentChange` | executes when note content is changed (includes note creation as well). | The `BNote` of the note whose content got changed. |
| `runOnNoteChange` | executes when note is changed (includes note creation as well). Does not include content changes | The `BNote` of the note that got changed. |
| `runOnNoteDeletion` | executes when note is being deleted | The `BNote` of the note that got (soft) deleted. |
| `runOnBranchCreation` | executes when a branch is created. Branch is a link between parent note and child note and is created e.g. when cloning or moving note. | The `BBranch` that got created. |
| `runOnBranchChange` | executes when a branch is updated. (since v0.62) | The `BBranch` that got changed. |
| `runOnBranchDeletion` | executes when a branch is deleted. Branch is a link between parent note and child note and is deleted e.g. when moving note (old branch/link is deleted). | The `BBranch` that got (soft) deleted. |
| `runOnAttributeCreation` | executes when new attribute is created for the note which defines this relation | The `BAttribute` that got created. |
| `runOnAttributeChange` | executes when the attribute is changed of a note which defines this relation. This is triggered also when the attribute is deleted | The `BAttribute` that got changed. |
## Origin entity
When a script is run by an event such as the ones described above, `api.originEntity` will get populated with the note, branch or attribute that triggered the change.
For example, here's a script with `~runOnAttributeChange` which automatically changes the color of a note based on the value of the `mycategory` label:
```javascript
const attr = api.originEntity;
if (attr.name !== "mycategory") return;
const note = api.getNote(attr.noteId);
if (attr.value === "Health") {
note.setLabel("color", "green");
} else {
note.removeLabel("color");
}
```

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;
}

8
pnpm-lock.yaml generated
View File

@ -211,6 +211,9 @@ importers:
'@triliumnext/split.js':
specifier: workspace:*
version: link:../../packages/splitjs
'@zumer/snapdom':
specifier: 2.0.1
version: 2.0.1
autocomplete.js:
specifier: 0.38.1
version: 0.38.1
@ -5882,6 +5885,9 @@ packages:
resolution: {integrity: sha512-PI6UdgpSeVoGvzguKHmy2bwOqI3UYkntLZOCpyJSKIi7234c5aJmQYkJB/P4P2YUJkqhbqvu7iM2/0eJZ178nA==}
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'}
'@zumer/snapdom@2.0.1':
resolution: {integrity: sha512-78/qbYl2FTv4H6qaXcNfAujfIOSzdvs83NW63VbyC9QA3sqNPfPvhn4xYMO6Gy11hXwJUEhd0z65yKiNzDwy9w==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
@ -20833,6 +20839,8 @@ snapshots:
'@zip.js/zip.js@2.8.2': {}
'@zumer/snapdom@2.0.1': {}
abab@2.0.6: {}
abbrev@1.1.1: {}