Easy fixes v2 (#9377)

This commit is contained in:
Elian Doran
2026-04-11 19:19:57 +03:00
committed by GitHub
53 changed files with 7441 additions and 6383 deletions

View File

@@ -338,6 +338,16 @@ Trilium provides powerful user scripting capabilities:
- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json`
- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped
### Storing User Preferences
- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices
- To add a new user preference:
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
## Testing Conventions
- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests

View File

@@ -157,6 +157,16 @@ Trilium provides powerful user scripting capabilities:
- **Do not use `crypto.randomUUID()`** or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS
- Use `randomString()` from `apps/client/src/services/utils.ts` for generating IDs instead
### Storing User Preferences
- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices
- To add a new user preference:
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
### Shared Types Policy
- Types shared between client and server belong in `@triliumnext/commons` (`packages/commons/src/lib/`)
- Import shared types directly from `@triliumnext/commons` - do not re-export them from app-specific modules

View File

@@ -6,10 +6,8 @@ import froca from "./froca";
import server from "./server.js";
// Spy on server methods to track calls
// @ts-expect-error the generic typing is causing issues here
server.put = vi.fn(async <T> (url: string, data?: T) => ({} as T));
// @ts-expect-error the generic typing is causing issues here
server.remove = vi.fn(async <T> (url: string) => ({} as T));
server.put = vi.fn(async () => ({})) as typeof server.put;
server.remove = vi.fn(async () => ({})) as typeof server.remove;
describe("Set boolean with inheritance", () => {
beforeEach(() => {

View File

@@ -18,6 +18,10 @@ async function render(note: FNote, $el: JQuery<HTMLElement>, onError?: ErrorHand
for (const renderNoteId of renderNoteIds) {
const bundle = await server.postWithSilentInternalServerError<Bundle>(`script/bundle/${renderNoteId}`);
if (!bundle) {
throw new Error(`Script note '${renderNoteId}' could not be loaded. It may be protected and require an active protected session.`);
}
const $scriptContainer = $("<div>");
$el.append($scriptContainer);

View File

@@ -33,6 +33,14 @@ export async function formatCodeBlocks($container: JQuery<HTMLElement>) {
applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType);
}
}
// Add click-to-copy for inline code (code elements not inside pre)
if (glob.device !== "print") {
const inlineCodeElements = $container.find("code:not(pre code)");
for (const inlineCode of inlineCodeElements) {
applyInlineCodeCopy($(inlineCode));
}
}
}
export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
@@ -51,6 +59,23 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
$codeBlock.parent().append($copyButton);
}
export function applyInlineCodeCopy($inlineCode: JQuery<HTMLElement>) {
$inlineCode
.addClass("copyable-inline-code")
.attr("title", t("code_block.click_to_copy"))
.off("click")
.on("click", (e) => {
e.stopPropagation();
const text = $inlineCode.text();
if (!isShare) {
copyTextWithToast(text);
} else {
copyText(text);
}
});
}
/**
* Applies syntax highlight to the given code block (assumed to be <pre><code>), using highlight.js.
*/

View File

@@ -134,7 +134,7 @@ async function handleMessage(event: MessageEvent<any>) {
} else if (message.type === "api-log-messages") {
appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
} else if (message.type === "toast") {
toast.showMessage(message.message);
toast.showMessage(message.message, message.timeout);
} else if (message.type === "execute-script") {
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;

View File

@@ -1230,6 +1230,43 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
width: 100%;
}
/* Expandable include note styles */
.include-note-title-row {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.include-note-title-row .include-note-title {
margin: 0;
}
.include-note-toggle {
background: none;
border: none;
padding: 2px;
cursor: pointer;
font-size: 1.2em;
color: var(--main-text-color);
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.include-note-toggle:hover {
color: var(--main-link-color);
}
.include-note-toggle.expanded {
transform: rotate(90deg);
}
.include-note[data-box-size="expandable"] .include-note-content {
margin-top: 10px;
}
.alert {
padding: 8px 14px;
width: auto;

View File

@@ -88,17 +88,23 @@
"also_delete_note": "Also delete the note"
},
"delete_notes": {
"delete_notes_preview": "Delete notes preview",
"title": "Delete notes",
"close": "Close",
"clones_label": "Clones",
"delete_clones_description_one": "Also delete {{count}} other clone. Can be undone in recent changes.",
"delete_clones_description_other": "Also delete {{count}} other clones. Can be undone in recent changes.",
"delete_all_clones_description": "Delete also all clones (can be undone in recent changes)",
"erase_notes_description": "Normal (soft) deletion only marks the notes as deleted and they can be undeleted (in recent changes dialog) within a period of time. Checking this option will erase the notes immediately and it won't be possible to undelete the notes.",
"erase_notes_label": "Erase permanently",
"erase_notes_description": "Erase notes immediately instead of soft deletion. This cannot be undone and will force application reload.",
"erase_notes_warning": "Erase notes permanently (can't be undone), including all clones. This will force application reload.",
"notes_to_be_deleted": "Following notes will be deleted ({{notesCount}})",
"notes_to_be_deleted": "Notes to be deleted ({{notesCount}})",
"no_note_to_delete": "No note will be deleted (only clones).",
"broken_relations_to_be_deleted": "Following relations will be broken and deleted ({{ relationCount}})",
"broken_relations_to_be_deleted": "Broken relations ({{relationCount}})",
"table_note_with_relation": "Note with relation",
"table_relation": "Relation",
"table_points_to": "Points to (deleted)",
"cancel": "Cancel",
"ok": "OK",
"deleted_relation_text": "Note {{- note}} (to be deleted) is referenced by relation {{- relation}} originating from {{- source}}."
"delete": "Delete"
},
"export": {
"export_note_title": "Export note",
@@ -209,6 +215,7 @@
"box_size_small": "small (~ 10 lines)",
"box_size_medium": "medium (~ 30 lines)",
"box_size_full": "full (box shows complete text)",
"box_size_expandable": "expandable (collapsed by default)",
"button_include": "Include note"
},
"info": {
@@ -1876,7 +1883,8 @@
"theme_none": "No syntax highlighting",
"theme_group_light": "Light themes",
"theme_group_dark": "Dark themes",
"copy_title": "Copy to clipboard"
"copy_title": "Copy to clipboard",
"click_to_copy": "Click to copy"
},
"classic_editor_toolbar": {
"title": "Formatting"

View File

@@ -27,7 +27,7 @@ describe("Board data", () => {
froca.branches["note1_note2"] = branch;
froca.getNoteFromCache("note1")!.addChild("note2", "note1_note2", false);
const data = await getBoardData(parentNote, "status", {}, false);
const noteIds = Array.from(data.byColumn.values()).flat().map(item => item.note.noteId);
const noteIds = [...data.byColumn.values()].flat().map(item => item.note.noteId);
expect(noteIds.length).toBe(3);
});
});

View File

@@ -0,0 +1,30 @@
.delete-notes-dialog .tn-card {
margin-bottom: 16px;
}
.delete-notes-dialog .tn-card:last-child {
margin-bottom: 0;
}
.delete-notes-dialog .preview-list {
margin: 0;
padding: 0;
list-style: none;
max-height: 200px;
overflow: auto;
}
.delete-notes-dialog .preview-list li {
padding: 6px 16px;
border-bottom: 1px solid var(--main-border-color);
}
.delete-notes-dialog .preview-list li:last-child {
border-bottom: none;
}
.delete-notes-dialog .preview-list small {
margin-inline-start: 8px;
font-size: 0.8em;
color: var(--muted-text-color);
}

View File

@@ -1,15 +1,22 @@
import { useRef, useState, useEffect } from "preact/hooks";
import { t } from "../../services/i18n.js";
import FormCheckbox from "../react/FormCheckbox.js";
import Modal from "../react/Modal.js";
import "./delete_notes.css";
import type { DeleteNotesPreview } from "@triliumnext/commons";
import server from "../../services/server.js";
import { useEffect, useRef, useState } from "preact/hooks";
import froca from "../../services/froca.js";
import FNote from "../../entities/fnote.js";
import link from "../../services/link.js";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import Button from "../react/Button.jsx";
import Alert from "../react/Alert.jsx";
import { Card, CardSection } from "../react/Card.js";
import FormToggle from "../react/FormToggle.js";
import { useTriliumEvent } from "../react/hooks.jsx";
import Modal from "../react/Modal.js";
import NoteLink from "../react/NoteLink.js";
import OptionsRow from "../type_widgets/options/components/OptionsRow.js";
interface CloneInfo {
totalCloneCount: number;
}
export interface ResolveOptions {
proceed: boolean;
@@ -24,9 +31,9 @@ interface ShowDeleteNotesDialogOpts {
}
interface BrokenRelationData {
note: string;
relation: string;
source: string;
noteId: string;
relationName: string;
sourceNoteId: string;
}
export default function DeleteNotesDialog() {
@@ -34,20 +41,51 @@ export default function DeleteNotesDialog() {
const [ deleteAllClones, setDeleteAllClones ] = useState(false);
const [ eraseNotes, setEraseNotes ] = useState(!!opts.forceDeleteAllClones);
const [ brokenRelations, setBrokenRelations ] = useState<DeleteNotesPreview["brokenRelations"]>([]);
const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState<DeleteNotesPreview["noteIdsToBeDeleted"]>([]);
const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState<DeleteNotesPreview["noteIdsToBeDeleted"]>([]);
const [ shown, setShown ] = useState(false);
const [ cloneInfo, setCloneInfo ] = useState<CloneInfo>({ totalCloneCount: 0 });
const okButtonRef = useRef<HTMLButtonElement>(null);
useTriliumEvent("showDeleteNotesDialog", (opts) => {
setOpts(opts);
setDeleteAllClones(false);
setEraseNotes(!!opts.forceDeleteAllClones);
setShown(true);
})
});
// Calculate clone information when branches change
useEffect(() => {
const { branchIdsToDelete } = opts;
if (!branchIdsToDelete || branchIdsToDelete.length === 0) {
setCloneInfo({ totalCloneCount: 0 });
return;
}
async function calculateCloneInfo() {
const branches = froca.getBranches(branchIdsToDelete!, true);
const uniqueNoteIds = [...new Set(branches.map(b => b.noteId))];
const notes = await froca.getNotes(uniqueNoteIds);
let totalCloneCount = 0;
for (const note of notes) {
const parentBranches = note.getParentBranches();
// Clones are additional parent branches beyond the one being deleted
const otherBranches = parentBranches.filter(b => !branchIdsToDelete!.includes(b.branchId));
totalCloneCount += otherBranches.length;
}
setCloneInfo({ totalCloneCount });
}
calculateCloneInfo();
}, [opts.branchIdsToDelete]);
useEffect(() => {
const { branchIdsToDelete, forceDeleteAllClones } = opts;
if (!branchIdsToDelete || branchIdsToDelete.length === 0) {
return;
}
}
server.post<DeleteNotesPreview>("delete-notes-preview", {
branchIdsToDelete,
@@ -63,16 +101,16 @@ export default function DeleteNotesDialog() {
className="delete-notes-dialog"
size="xl"
scrollable
title={t("delete_notes.delete_notes_preview")}
title={t("delete_notes.title")}
onShown={() => okButtonRef.current?.focus()}
onHidden={() => {
opts.callback?.({ proceed: false })
opts.callback?.({ proceed: false });
setShown(false);
}}
footer={<>
<Button text={t("delete_notes.cancel")}
onClick={() => setShown(false)} />
<Button text={t("delete_notes.ok")} kind="primary"
<Button text={t("delete_notes.delete")} kind="primary"
buttonRef={okButtonRef}
onClick={() => {
opts.callback?.({ proceed: true, deleteAllClones, eraseNotes });
@@ -81,92 +119,117 @@ export default function DeleteNotesDialog() {
</>}
show={shown}
>
<FormCheckbox name="delete-all-clones" label={t("delete_notes.delete_all_clones_description")}
currentValue={deleteAllClones} onChange={setDeleteAllClones}
/>
<FormCheckbox
name="erase-notes" label={t("delete_notes.erase_notes_warning")}
disabled={opts.forceDeleteAllClones}
currentValue={eraseNotes} onChange={setEraseNotes}
/>
<Card>
<CardSection>
<DeleteAllClonesOption
cloneInfo={cloneInfo}
deleteAllClones={deleteAllClones}
setDeleteAllClones={setDeleteAllClones}
/>
<OptionsRow
name="erase-notes"
label={t("delete_notes.erase_notes_label")}
description={t("delete_notes.erase_notes_description")}
>
<FormToggle
disabled={opts.forceDeleteAllClones}
currentValue={eraseNotes}
onChange={setEraseNotes}
/>
</OptionsRow>
</CardSection>
</Card>
<DeletedNotes noteIdsToBeDeleted={noteIdsToBeDeleted} />
<BrokenRelations brokenRelations={brokenRelations} />
<DeletedNotes noteIdsToBeDeleted={noteIdsToBeDeleted} />
</Modal>
);
}
function DeletedNotes({ noteIdsToBeDeleted }: { noteIdsToBeDeleted: DeleteNotesPreview["noteIdsToBeDeleted"] }) {
const [ noteLinks, setNoteLinks ] = useState<string[]>([]);
interface DeleteAllClonesOptionProps {
cloneInfo: CloneInfo;
deleteAllClones: boolean;
setDeleteAllClones: (value: boolean) => void;
}
useEffect(() => {
froca.getNotes(noteIdsToBeDeleted).then(async (notes: FNote[]) => {
const noteLinks: string[] = [];
function DeleteAllClonesOption({ cloneInfo, deleteAllClones, setDeleteAllClones }: DeleteAllClonesOptionProps) {
const { totalCloneCount } = cloneInfo;
for (const note of notes) {
noteLinks.push((await link.createLink(note.noteId, { showNotePath: true })).html());
}
setNoteLinks(noteLinks);
});
}, [noteIdsToBeDeleted]);
if (noteIdsToBeDeleted.length) {
return (
<div className="delete-notes-list-wrapper" style={{paddingTop: "16px"}}>
<h4>{t("delete_notes.notes_to_be_deleted", { notesCount: noteIdsToBeDeleted.length })}</h4>
<ul className="delete-notes-list" style={{ maxHeight: "200px", overflow: "auto"}}>
{noteLinks.map((link, index) => (
<li key={index} dangerouslySetInnerHTML={{ __html: link }} />
))}
</ul>
</div>
);
} else {
return (
<Alert type="info">
{t("delete_notes.no_note_to_delete")}
</Alert>
)
if (totalCloneCount === 0) {
return null;
}
return (
<OptionsRow
name="delete-all-clones"
label={t("delete_notes.clones_label")}
description={t("delete_notes.delete_clones_description", { count: totalCloneCount })}
>
<FormToggle
currentValue={deleteAllClones}
onChange={setDeleteAllClones}
/>
</OptionsRow>
);
}
function DeletedNotes({ noteIdsToBeDeleted }: { noteIdsToBeDeleted: DeleteNotesPreview["noteIdsToBeDeleted"] }) {
return (
<Card heading={t("delete_notes.notes_to_be_deleted", { notesCount: noteIdsToBeDeleted.length })}>
<CardSection noPadding={noteIdsToBeDeleted.length > 0}>
{noteIdsToBeDeleted.length ? (
<ul className="preview-list">
{noteIdsToBeDeleted.map((noteId) => (
<li key={noteId}>
<NoteLink notePath={noteId} showNotePath showNoteIcon />
</li>
))}
</ul>
) : (
<span className="muted-text">{t("delete_notes.no_note_to_delete")}</span>
)}
</CardSection>
</Card>
);
}
function BrokenRelations({ brokenRelations }: { brokenRelations: DeleteNotesPreview["brokenRelations"] }) {
const [ notesWithBrokenRelations, setNotesWithBrokenRelations ] = useState<BrokenRelationData[]>([]);
useEffect(() => {
const noteIds = brokenRelations
.map(relation => relation.noteId)
.filter(noteId => noteId) as string[];
froca.getNotes(noteIds).then(async () => {
const notesWithBrokenRelations: BrokenRelationData[] = [];
for (const attr of brokenRelations) {
notesWithBrokenRelations.push({
note: (await link.createLink(attr.value)).html(),
relation: `<code>${attr.name}</code>`,
source: (await link.createLink(attr.noteId)).html()
});
}
setNotesWithBrokenRelations(notesWithBrokenRelations);
});
}, [brokenRelations]);
if (brokenRelations.length) {
return (
<Alert type="danger" title={t("delete_notes.broken_relations_to_be_deleted", { relationCount: brokenRelations.length })}>
<ul className="broken-relations-list" style={{ maxHeight: "200px", overflow: "auto" }}>
{brokenRelations.map((_, index) => {
return (
<li key={index}>
<span dangerouslySetInnerHTML={{ __html: t("delete_notes.deleted_relation_text", notesWithBrokenRelations[index] as unknown as Record<string, string>) }} />
</li>
);
})}
</ul>
</Alert>
);
} else {
return <></>;
if (!brokenRelations.length) {
return null;
}
const relationsData: BrokenRelationData[] = brokenRelations
.filter((attr) => attr.value && attr.noteId)
.map((attr) => ({
noteId: attr.value!,
relationName: attr.name,
sourceNoteId: attr.noteId!
}));
return (
<Card heading={t("delete_notes.broken_relations_to_be_deleted", { relationCount: brokenRelations.length })}>
<CardSection noPadding>
<div style={{ overflowX: "auto" }}>
<table className="table table-striped">
<thead>
<tr>
<th>{t("delete_notes.table_note_with_relation")}</th>
<th>{t("delete_notes.table_relation")}</th>
<th>{t("delete_notes.table_points_to")}</th>
</tr>
</thead>
<tbody>
{relationsData.map((relation, index) => (
<tr key={index}>
<td><NoteLink notePath={relation.sourceNoteId} showNoteIcon /></td>
<td><code>{relation.relationName}</code></td>
<td><NoteLink notePath={relation.noteId} showNoteIcon /></td>
</tr>
))}
</tbody>
</table>
</div>
</CardSection>
</Card>
);
}

View File

@@ -8,7 +8,7 @@ import Button from "../react/Button";
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
import tree from "../../services/tree";
import froca from "../../services/froca";
import { useTriliumEvent } from "../react/hooks";
import { useTriliumEvent, useTriliumOption } from "../react/hooks";
import { type BoxSize, CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
export interface IncludeNoteOpts {
@@ -18,11 +18,13 @@ export interface IncludeNoteOpts {
export default function IncludeNoteDialog() {
const editorApiRef = useRef<CKEditorApi>(null);
const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
const [boxSize, setBoxSize] = useState<string>("medium");
const [defaultBoxSize, setDefaultBoxSize] = useTriliumOption("includeNoteDefaultBoxSize");
const [boxSize, setBoxSize] = useState<string>(defaultBoxSize);
const [shown, setShown] = useState(false);
useTriliumEvent("showIncludeNoteDialog", ({ editorApi }) => {
editorApiRef.current = editorApi;
setBoxSize(defaultBoxSize); // Reset to default when opening dialog
setShown(true);
});
@@ -35,10 +37,14 @@ export default function IncludeNoteDialog() {
size="lg"
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
onHidden={() => setShown(false)}
onSubmit={() => {
onSubmit={async () => {
if (!suggestion?.notePath || !editorApiRef.current) return;
setShown(false);
includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
await includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
// Save the selected box size as the new default
if (boxSize !== defaultBoxSize) {
setDefaultBoxSize(boxSize);
}
}}
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
show={shown}
@@ -63,6 +69,7 @@ export default function IncludeNoteDialog() {
{ label: t("include_note.box_size_small"), value: "small" },
{ label: t("include_note.box_size_medium"), value: "medium" },
{ label: t("include_note.box_size_full"), value: "full" },
{ label: t("include_note.box_size_expandable"), value: "expandable" },
]}
/>
</FormGroup>

View File

@@ -35,6 +35,14 @@
flex-direction: column;
gap: var(--card-section-gap);
.tn-card-section.tn-no-padding {
padding: 0;
& .table {
margin-bottom: 0;
}
}
.tn-card-section {
&:first-of-type {
border-top-left-radius: var(--card-border-radius);

View File

@@ -50,6 +50,7 @@ export interface CardSectionProps {
subSectionsVisible?: boolean;
highlightOnHover?: boolean;
onAction?: () => void;
noPadding?: boolean;
}
interface CardSectionContextType {
@@ -65,7 +66,8 @@ export function CardSection(props: {children: ComponentChildren} & CardSectionPr
return <>
<section className={clsx("tn-card-section", props.className, {
"tn-card-section-nested": nestingLevel > 0,
"tn-card-highlight-on-hover": props.highlightOnHover || props.onAction
"tn-card-highlight-on-hover": props.highlightOnHover || props.onAction,
"tn-no-padding": props.noPadding
})}
style={{"--tn-card-section-nesting-level": (nestingLevel) ? nestingLevel : null}}
onClick={props.onAction}>

View File

@@ -7,17 +7,22 @@ import { ComponentChildren } from "preact";
interface FormToggleProps {
currentValue: boolean | null;
onChange(newValue: boolean): void;
switchOnName: string;
/** Label shown when toggle is off. If omitted along with switchOffName, no label is shown. */
switchOnName?: string;
switchOnTooltip?: string;
switchOffName: string;
/** Label shown when toggle is on. If omitted along with switchOnName, no label is shown. */
switchOffName?: string;
switchOffTooltip?: string;
helpPage?: string;
disabled?: boolean;
afterName?: ComponentChildren;
/** ID for the input element, useful for accessibility with external labels */
id?: string;
}
export default function FormToggle({ currentValue, helpPage, switchOnName, switchOnTooltip, switchOffName, switchOffTooltip, onChange, disabled, afterName }: FormToggleProps) {
export default function FormToggle({ currentValue, helpPage, switchOnName, switchOnTooltip, switchOffName, switchOffTooltip, onChange, disabled, afterName, id }: FormToggleProps) {
const [ disableTransition, setDisableTransition ] = useState(true);
const hasLabel = switchOnName || switchOffName;
useEffect(() => {
const timeout = setTimeout(() => {
@@ -28,7 +33,7 @@ export default function FormToggle({ currentValue, helpPage, switchOnName, switc
return (
<div className="switch-widget">
<span className="switch-name">{ currentValue ? switchOffName : switchOnName }</span>
{hasLabel && <span className="switch-name">{ currentValue ? switchOffName : switchOnName }</span>}
{ afterName }
<label>
@@ -37,6 +42,7 @@ export default function FormToggle({ currentValue, helpPage, switchOnName, switc
title={currentValue ? switchOffTooltip : switchOnTooltip }
>
<input
id={id}
className="switch-toggle"
type="checkbox"
checked={currentValue === true}

View File

@@ -15,6 +15,7 @@ import attributes from "../../services/attributes";
import froca from "../../services/froca";
import keyboard_actions from "../../services/keyboard_actions";
import { ViewScope } from "../../services/link";
import math from "../../services/math";
import options, { type OptionValue } from "../../services/options";
import protected_session_holder from "../../services/protected_session_holder";
import server from "../../services/server";
@@ -1435,3 +1436,38 @@ export function useColorScheme() {
return prefersDark ? "dark" : "light";
}
/**
* Renders math equations within elements that have the `.math-tex` class.
* Used by sidebar widgets like Table of Contents and Highlights list to display math content.
*
* @param containerRef - Ref to the container element that may contain math elements
* @param deps - Dependencies that trigger re-rendering (e.g., text content)
*/
export function useMathRendering(containerRef: RefObject<HTMLElement>, deps: unknown[]) {
useEffect(() => {
if (!containerRef.current) return;
// Support both read-only (.math-tex) and CKEditor editing view (.ck-math-tex) classes
const mathElements = containerRef.current.querySelectorAll(".math-tex, .ck-math-tex");
for (const mathEl of mathElements) {
// Skip if already rendered by KaTeX
if (mathEl.querySelector(".katex")) continue;
try {
let equation = mathEl.textContent || "";
// CKEditor widgets store equation without delimiters, add them for KaTeX
if (mathEl.classList.contains("ck-math-tex")) {
// Check if it's display mode or inline
const isDisplay = mathEl.classList.contains("ck-math-tex-display");
equation = isDisplay ? `\\[${equation}\\]` : `\\(${equation}\\)`;
}
math.render(equation, mathEl as HTMLElement);
} catch (e) {
console.warn("Failed to render math:", e);
}
}
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { extractHighlightsFromStaticHtml } from "./HighlightsList.js";
describe("extractHighlightsFromStaticHtml", () => {
it("extracts a single highlight containing text and math equation together", () => {
const container = document.createElement("div");
container.innerHTML = `<p>
<span style="background-color:hsl(30,75%,60%);">
Highlighted&nbsp;
<span class="math-tex">
\\(e=mc^2\\)
</span>
&nbsp;math
</span>
</p>`;
document.body.appendChild(container);
const highlights = extractHighlightsFromStaticHtml(container);
// Should extract 1 combined highlight, not 3 separate ones
expect(highlights.length).toBe(1);
// The highlight should contain the full innerHTML of the styled span
const highlight = highlights[0];
expect(highlight.text).toContain("Highlighted");
expect(highlight.text).toContain("math-tex");
expect(highlight.text).toContain("e=mc^2");
expect(highlight.text).toContain("math");
expect(highlight.attrs.background).toBeTruthy();
document.body.removeChild(container);
});
it("extracts separate highlights for differently styled spans", () => {
const container = document.createElement("div");
container.innerHTML = `<p>
<span style="background-color:yellow;">Yellow text</span>
normal text
<span style="background-color:red;">Red text</span>
</p>`;
document.body.appendChild(container);
const highlights = extractHighlightsFromStaticHtml(container);
// Should extract 2 separate highlights (yellow and red)
expect(highlights.length).toBe(2);
expect(highlights[0].text).toBe("Yellow text");
expect(highlights[1].text).toBe("Red text");
document.body.removeChild(container);
});
});

View File

@@ -3,9 +3,8 @@ import { createPortal } from "preact/compat";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import math from "../../services/math";
import { randomString } from "../../services/utils";
import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor, useTriliumOptionJson } from "../react/hooks";
import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useMathRendering, useNoteProperty, useTextEditor, useTriliumOptionJson } from "../react/hooks";
import Modal from "../react/Modal";
import RawHtml from "../react/RawHtml";
import { HighlightsListOptions } from "../type_widgets/options/text_notes";
@@ -111,19 +110,7 @@ function HighlightItem<T extends RawHighlight>({ highlight, onClick }: {
}) {
const contentRef = useRef<HTMLElement>(null);
// Render math equations after component mounts/updates
useEffect(() => {
if (!contentRef.current) return;
const mathElements = contentRef.current.querySelectorAll(".math-tex");
for (const mathEl of mathElements ?? []) {
try {
math.render(mathEl.textContent || "", mathEl as HTMLElement);
} catch (e) {
console.warn("Failed to render math in highlights:", e);
}
}
}, [highlight.text]);
useMathRendering(contentRef, [highlight.text]);
return (
<li onClick={onClick}>
@@ -280,47 +267,65 @@ function ReadOnlyTextHighlightsList() {
/>;
}
function extractHighlightsFromStaticHtml(el: HTMLElement | null) {
export function extractHighlightsFromStaticHtml(el: HTMLElement | null) {
if (!el) return [];
const { color: defaultColor, backgroundColor: defaultBackgroundColor } = getComputedStyle(el);
const walker = document.createTreeWalker(
el,
NodeFilter.SHOW_TEXT,
null
);
const highlights: DomHighlight[] = [];
const processedElements = new Set<Element>();
let node: Node | null;
while ((node = walker.nextNode())) {
const el = node.parentElement;
if (!el || !node.textContent?.trim()) continue;
// Find all elements with inline background-color or color styles
const styledElements = el.querySelectorAll<HTMLElement>('[style*="background-color"], [style*="color"]');
const style = getComputedStyle(el);
for (const styledEl of styledElements) {
if (processedElements.has(styledEl)) continue;
if (!styledEl.textContent?.trim()) continue;
if (
el.closest('strong, em, u') ||
style.color !== defaultColor ||
style.backgroundColor !== defaultBackgroundColor
) {
const attrs: RawHighlight["attrs"] = {
bold: !!el.closest("strong"),
italic: !!el.closest("em"),
underline: !!el.closest("u"),
background: el.style.backgroundColor,
color: el.style.color
};
const attrs: RawHighlight["attrs"] = {
bold: !!styledEl.closest("strong"),
italic: !!styledEl.closest("em"),
underline: !!styledEl.closest("u"),
background: styledEl.style.backgroundColor,
color: styledEl.style.color
};
if (Object.values(attrs).some(Boolean)) {
highlights.push({
id: randomString(),
text: node.textContent,
element: el,
attrs
});
}
if (Object.values(attrs).some(Boolean)) {
processedElements.add(styledEl);
highlights.push({
id: randomString(),
text: styledEl.innerHTML,
element: styledEl,
attrs
});
}
}
// Also find bold, italic, underline elements
const formattingElements = el.querySelectorAll<HTMLElement>("strong, em, u, b, i");
for (const formattedEl of formattingElements) {
// Skip if already processed or inside a processed element
if (processedElements.has(formattedEl)) continue;
if (Array.from(processedElements).some(processed => processed.contains(formattedEl))) continue;
if (!formattedEl.textContent?.trim()) continue;
const attrs: RawHighlight["attrs"] = {
bold: formattedEl.matches("strong, b"),
italic: formattedEl.matches("em, i"),
underline: formattedEl.matches("u"),
background: formattedEl.style.backgroundColor,
color: formattedEl.style.color
};
if (Object.values(attrs).some(Boolean)) {
processedElements.add(formattedEl);
highlights.push({
id: randomString(),
text: formattedEl.innerHTML,
element: formattedEl,
attrs
});
}
}

View File

@@ -5,9 +5,8 @@ import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import math from "../../services/math";
import { randomString } from "../../services/utils";
import { useActiveNoteContext, useContentElement, useGetContextData, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks";
import { useActiveNoteContext, useContentElement, useGetContextData, useIsNoteReadOnly, useMathRendering, useNoteProperty, useTextEditor } from "../react/hooks";
import Icon from "../react/Icon";
import RawHtml from "../react/RawHtml";
import RightPanelWidget from "./RightPanelWidget";
@@ -84,19 +83,7 @@ function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: {
const isActive = heading.id === activeHeadingId;
const contentRef = useRef<HTMLElement>(null);
// Render math equations after component mounts/updates
useEffect(() => {
if (!contentRef.current) return;
const mathElements = contentRef.current.querySelectorAll(".math-tex");
for (const mathEl of mathElements ?? []) {
try {
math.render(mathEl.textContent || "", mathEl as HTMLElement);
} catch (e) {
console.warn("Failed to render math in TOC:", e);
}
}
}, [heading.text]);
useMathRendering(contentRef, [heading.text]);
return (
<>
@@ -273,7 +260,7 @@ function extractTocFromStaticHtml(el: HTMLElement | null) {
headings.push({
id: randomString(),
level: parseInt(headingEl.tagName.substring(1), 10),
text: headingEl.textContent,
text: headingEl.innerHTML,
element: headingEl
});
}

View File

@@ -46,6 +46,16 @@
justify-content: center;
}
.option-row.stacked {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.option-row.stacked .option-row-input {
width: 100%;
}
.option-row-link.use-tn-links {
text-decoration: none;
color: inherit;

View File

@@ -10,14 +10,18 @@ interface OptionsRowProps {
description?: string;
children: VNode;
centered?: boolean;
/** When true, stacks label above input with full-width input */
stacked?: boolean;
}
export default function OptionsRow({ name, label, description, children, centered }: OptionsRowProps) {
export default function OptionsRow({ name, label, description, children, centered, stacked }: OptionsRowProps) {
const id = useUniqueName(name);
const childWithId = cloneElement(children, { id });
const className = `option-row ${centered ? "centered" : ""} ${stacked ? "stacked" : ""}`;
return (
<div className={`option-row ${centered ? "centered" : ""}`}>
<div className={className}>
<div className="option-row-label">
{label && <label for={id}>{label}</label>}
{description && <small className="option-row-description">{description}</small>}

View File

@@ -7,7 +7,7 @@ import link from "../../../services/link";
import { useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteContext, useSyncedRef, useTriliumOption } from "../../react/hooks";
import { buildConfig, BuildEditorOptions } from "./config";
export type BoxSize = "small" | "medium" | "full";
export type BoxSize = "small" | "medium" | "full" | "expandable";
export interface CKEditorApi {
/** returns true if user selected some text, false if there's no selection */

View File

@@ -55,4 +55,14 @@ body.mobile .note-detail-readonly-text {
.edit-text-note-button:hover {
border-color: var(--button-border-color);
}
/* Inline code click-to-copy */
.note-detail-readonly-text-content code.copyable-inline-code {
cursor: pointer;
transition: background-color 0.15s ease;
}
.note-detail-readonly-text-content code.copyable-inline-code:hover {
background-color: var(--accented-background-color);
}

View File

@@ -8,17 +8,77 @@ export async function loadIncludedNote(noteId: string, $el: JQuery<HTMLElement>)
const note = await froca.getNote(noteId);
if (!note) return;
// Get the box size from the parent section element
const $section = $el.closest('section.include-note');
const boxSize = $section.attr('data-box-size');
const isExpandable = boxSize === 'expandable';
const $wrapper = $('<div class="include-note-wrapper">');
const $link = await link.createLink(note.noteId, {
showTooltip: false
});
$wrapper.empty().append($('<h4 class="include-note-title">').append($link));
if (isExpandable) {
// Create expandable structure with toggle
const $titleRow = $('<div class="include-note-title-row">');
const $toggle = $('<button class="include-note-toggle bx bx-chevron-right" aria-expanded="false">');
const $title = $('<h4 class="include-note-title">').append($link);
const { $renderedContent, type } = await content_renderer.getRenderedContent(note);
$wrapper.append($(`<div class="include-note-content type-${type}">`).append($renderedContent));
$titleRow.append($toggle, $title);
$wrapper.append($titleRow);
const { $renderedContent, type } = await content_renderer.getRenderedContent(note);
const $content = $(`<div class="include-note-content type-${type}" style="display: none;">`).append($renderedContent);
$wrapper.append($content);
// Add toggle functionality
$toggle.on('click', (e) => {
e.stopPropagation();
const isExpanded = $toggle.attr('aria-expanded') === 'true';
$toggle.attr('aria-expanded', String(!isExpanded));
$toggle.toggleClass('expanded');
$content.slideToggle(200);
});
} else {
// Standard display
$wrapper.append($('<h4 class="include-note-title">').append($link));
const { $renderedContent, type } = await content_renderer.getRenderedContent(note);
$wrapper.append($(`<div class="include-note-content type-${type}">`).append($renderedContent));
}
$el.empty().append($wrapper);
// Watch for box-size attribute changes and re-render
setupBoxSizeObserver($section[0], noteId, $el);
}
// Track observers to avoid duplicates
const boxSizeObservers = new WeakMap<Element, MutationObserver>();
function setupBoxSizeObserver(section: Element, noteId: string, $el: JQuery<HTMLElement>) {
// Clean up existing observer if any
const existingObserver = boxSizeObservers.get(section);
if (existingObserver) {
existingObserver.disconnect();
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-box-size') {
// Re-render the included note with the new box size
loadIncludedNote(noteId, $el);
break;
}
}
});
observer.observe(section, {
attributes: true,
attributeFilter: ['data-box-size']
});
boxSizeObservers.set(section, observer);
}
export function refreshIncludedNote(container: HTMLDivElement, noteId: string) {

File diff suppressed because it is too large Load Diff

View File

@@ -538,6 +538,9 @@
<li><a href="root/Trilium%20Demo/Scripting%20examples/Statistics/Attribute%20count/template/js/renderPieChart.js"
target="detail">renderPieChart</a>
<ul>
<li><a href="root/Trilium%20Demo/Scripting%20examples/Weight%20Tracker/Implementation/JS%20code/chart.js"
target="detail">chart.js</a>
</li>
<li><a href="root/Trilium%20Demo/Scripting%20examples/Statistics/Attribute%20count/template/js/renderPieChart/chartjs-plugin-datalabe.min.js"
target="detail">chartjs-plugin-datalabels.min.js</a>
</li>

View File

@@ -18,6 +18,7 @@
width="150" height="150">
</figure>
<p><strong>Welcome to Trilium Notes!</strong>
</p>
<p>This is a "demo" document packaged with Trilium to showcase some of its
features and also give you some ideas on how you might structure your notes.
@@ -25,16 +26,20 @@
you wish.</p>
<p>If you need any help, visit <a href="https://triliumnotes.org">triliumnotes.org</a> or
our <a href="https://github.com/TriliumNext">GitHub repository</a>.</p>
<h2>Cleanup</h2>
<h2>Cleanup</h2>
<p>Once you're finished with experimenting and want to cleanup these pages,
you can simply delete them all.</p>
<h2>Formatting</h2>
<h2>Formatting</h2>
<p>Trilium supports classic formatting like <em>italic</em>, <strong>bold</strong>, <em><strong>bold and italic</strong></em>.
You can add links pointing to <a href="https://triliumnotes.org/">external pages</a> or&nbsp;&nbsp;
<a
class="reference-link" href="Trilium%20Demo/Formatting%20examples">Formatting examples</a>.</p>
<h3>Lists</h3>
<h3>Lists</h3>
<p><strong>Ordered:</strong>
</p>
<ol>
<li data-list-item-id="e877cc655d0239b8bb0f38696ad5d8abb">First Item</li>
@@ -49,6 +54,7 @@
</li>
</ol>
<p><strong>Unordered:</strong>
</p>
<ul>
<li data-list-item-id="e68bf4b518a16671c314a72073c3d900a">Item</li>
@@ -58,7 +64,8 @@
</ul>
</li>
</ul>
<h3>Block quotes</h3>
<h3>Block quotes</h3>
<blockquote>
<p>Whereof one cannot speak, thereof one must be silent”</p>
<p> Ludwig Wittgenstein</p>

View File

@@ -14,17 +14,22 @@
<div class="ck-content">
<h2>Main characters</h2>
<p>… here put main characters …</p>
<p>&nbsp;</p>
<h2>Plot</h2>
<h2>Plot</h2>
<p>… describe main plot lines …</p>
<p>&nbsp;</p>
<h2>Tone</h2>
<h2>Tone</h2>
<p>&nbsp;</p>
<h2>Genre</h2>
<h2>Genre</h2>
<p>scifi / drama / romance</p>
<p>&nbsp;</p>
<h2>Similar books</h2>
<h2>Similar books</h2>
<ul>
<li data-list-item-id="eebd9f297d5dc97dfc46579ba1f25d7bf"></li>
</ul>

View File

@@ -0,0 +1,21 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../../../../../../../style.css">
<base target="_parent">
<title data-trilium-title>chart.js</title>
</head>
<body>
<div class="content">
<h1 data-trilium-h1>chart.js</h1>
<div class="ck-content">
<p>This is a clone of a note. Go to its <a href="../../../../../Weight%20Tracker/Implementation/JS%20code/chart.js">primary location</a>.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,9 +1,11 @@
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
import { createZipFromDirectory, extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js";
import debounce from "@triliumnext/client/src/services/debounce.js";
import fs from "fs/promises";
import { join } from "path";
import cls from "@triliumnext/server/src/services/cls.js";
import type { NoteMetaFile } from "@triliumnext/server/src/services/meta/note_meta.js";
import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js";
// Paths are relative to apps/edit-docs/dist.
const DEMO_ZIP_PATH = join(__dirname, "../../server/src/assets/db/demo.zip");
@@ -50,8 +52,10 @@ async function registerHandlers() {
eraseService.eraseUnusedAttachmentsNow();
await exportData();
await fs.rmdir(DEMO_ZIP_DIR_PATH, { recursive: true }).catch(() => {});
await fs.rm(DEMO_ZIP_DIR_PATH, { recursive: true }).catch(() => {});
await extractZip(DEMO_ZIP_PATH, DEMO_ZIP_DIR_PATH);
await cleanUpMeta(DEMO_ZIP_DIR_PATH);
await createZipFromDirectory(DEMO_ZIP_DIR_PATH, DEMO_ZIP_PATH);
}, 10_000);
events.subscribe(events.ENTITY_CHANGED, async (e) => {
if (e.entityName === "options") {
@@ -68,4 +72,28 @@ async function exportData() {
await exportToZipFile("root", "html", DEMO_ZIP_PATH);
}
const EXPANDED_NOTE_IDS = new Set([
"root",
"rvaX6hEaQlmk" // Trilium Demo
]);
async function cleanUpMeta(dirPath: string) {
const metaPath = join(dirPath, "!!!meta.json");
const meta = JSON.parse(await fs.readFile(metaPath, "utf-8")) as NoteMetaFile;
for (const file of meta.files) {
file.notePosition = 1;
traverse(file);
}
function traverse(el: NoteMeta) {
el.isExpanded = EXPANDED_NOTE_IDS.has(el.noteId);
for (const child of el.children || []) {
traverse(child);
}
}
await fs.writeFile(metaPath, JSON.stringify(meta, null, 4));
}
main();

View File

@@ -103,6 +103,14 @@ function waitForEnd(archive: Archiver, stream: WriteStream) {
});
}
export async function createZipFromDirectory(dirPath: string, zipPath: string) {
const archive = archiver("zip", { zlib: { level: 5 } });
const outputStream = fsExtra.createWriteStream(zipPath);
archive.directory(dirPath, false);
archive.pipe(outputStream);
await waitForEnd(archive, outputStream);
}
export async function extractZip(zipFilePath: string, outputPath: string, ignoredFiles?: Set<string>) {
const promise = deferred<void>();
setTimeout(async () => {

Binary file not shown.

View File

@@ -446,6 +446,9 @@
"desktop": {
"instance_already_running": "There's already an instance running, focusing that instance instead."
},
"script": {
"wrong-environment": "Cannot execute note \"{{- noteTitle}}\" ({{- noteId}}). This is a {{- actualEnv}} script, but execution was attempted in the {{- expectedEnv}}."
},
"search": {
"error": {
"in-context": "Error in {{- context}}: {{- message}}",

View File

@@ -106,7 +106,7 @@ function getNoteTitleArrayForPath(notePathArray: string[]) {
function getNoteTitleForPath(notePathArray: string[]) {
const titles = getNoteTitleArrayForPath(notePathArray);
return titles.join(" / ");
return titles.join(" ");
}
export default {

View File

@@ -409,7 +409,10 @@ async function findSimilarNotes(noteId: string): Promise<SimilarNote[] | undefin
}
for (const candidateNote of Object.values(becca.notes)) {
if (candidateNote.noteId === baseNote.noteId || hasConnectingRelation(candidateNote, baseNote) || hasConnectingRelation(baseNote, candidateNote)) {
if (candidateNote.noteId === baseNote.noteId
|| candidateNote.isHiddenCompletely()
|| hasConnectingRelation(candidateNote, baseNote)
|| hasConnectingRelation(baseNote, candidateNote)) {
continue;
}

View File

@@ -6,6 +6,16 @@
// Migrations should be kept in descending order, so the latest migration is first.
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
// Clean up obsolete keyboard shortcut options from renamed actions
{
version: 237,
sql: /*sql*/`
DELETE FROM options WHERE name = 'keyboardShortcutsShowNoteRevisions';
DELETE FROM entity_changes WHERE entityName = 'options' AND entityId = 'keyboardShortcutsShowNoteRevisions';
DELETE FROM options WHERE name = 'keyboardShortcutsForceSaveNoteRevision';
DELETE FROM entity_changes WHERE entityName = 'options' AND entityId = 'keyboardShortcutsForceSaveNoteRevision';
`
},
// Add text representation column to blobs table
{
version: 236,

View File

@@ -95,6 +95,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"textNoteEmojiCompletionEnabled",
"textNoteCompletionEnabled",
"textNoteSlashCommandsEnabled",
"includeNoteDefaultBoxSize",
"layoutOrientation",
"backgroundEffects",
"allowedHtmlTags",

View File

@@ -49,7 +49,10 @@ function sanitize(dirtyHtml: string) {
allowedStyles: {
"*": {
color: colorRegex,
"background-color": colorRegex
"background-color": colorRegex,
"margin-left": sizeRegex,
"padding-left": sizeRegex,
"text-align": [/^\s*(left|center|right|justify)\s*$/]
},
figure: {
float: [/^\s*(left|right|none)\s*$/],

View File

@@ -223,6 +223,7 @@ const defaultOptions: DefaultOption[] = [
{ name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true },
{ name: "textNoteCompletionEnabled", value: "true", isSynced: true },
{ name: "textNoteSlashCommandsEnabled", value: "true", isSynced: true },
{ name: "includeNoteDefaultBoxSize", value: "medium", isSynced: true },
// HTML import configuration
{ name: "layoutOrientation", value: "vertical", isSynced: false },

View File

@@ -1,3 +1,4 @@
import { t } from "i18next";
import { transform } from "sucrase";
import becca from "../becca/becca.js";
@@ -6,6 +7,7 @@ import type { ApiParams } from "./backend_script_api_interface.js";
import cls from "./cls.js";
import log from "./log.js";
import ScriptContext from "./script_context.js";
import ws from "./ws.js";
export interface Bundle {
note?: BNote;
@@ -19,9 +21,25 @@ export interface Bundle {
type ScriptParams = any[];
function executeNote(note: BNote, apiParams: ApiParams) {
if (!note.isJavaScript() || note.getScriptEnv() !== "backend" || !note.isContentAvailable()) {
if (!note.isContentAvailable()) {
throw new Error(`Cannot execute script note '${note.noteId}' because it is protected and protected session is not available. Enter protected session and try again.`);
}
if (!note.isJavaScript() || note.getScriptEnv() !== "backend") {
log.info(`Cannot execute note ${note.noteId} "${note.title}", note must be of type "Code: JS backend"`);
// Warn the user if they're trying to run a frontend script in the backend
const actualEnv = note.getScriptEnv();
if (note.isJavaScript() && actualEnv === "frontend") {
const message = t("script.wrong-environment", {
noteTitle: note.title,
noteId: note.noteId,
actualEnv: "frontend",
expectedEnv: "backend"
});
ws.sendMessageToAllClients({ type: "toast", message, timeout: 10000 });
}
return;
}
@@ -119,6 +137,20 @@ function getParams(params?: ScriptParams) {
}
function getScriptBundleForFrontend(note: BNote, script?: string, params?: ScriptParams) {
// Warn the user if they're trying to run a backend script in the frontend
if (note.isJavaScript() && note.getScriptEnv() === "backend") {
log.info(`Cannot execute note ${note.noteId} "${note.title}" in frontend, note is of type "Code: JS backend"`);
const message = t("script.wrong-environment", {
noteTitle: note.title,
noteId: note.noteId,
actualEnv: "backend",
expectedEnv: "frontend"
});
ws.sendMessageToAllClients({ type: "toast", message, timeout: 10000 });
return;
}
let overrideContent: string | null = null;
if (script) {
@@ -143,7 +175,7 @@ function getScriptBundleForFrontend(note: BNote, script?: string, params?: Scrip
export function getScriptBundle(note: BNote, root: boolean = true, scriptEnv: string | null = null, includedNoteIds: string[] = [], overrideContent: string | null = null): Bundle | undefined {
if (!note.isContentAvailable()) {
return;
throw new Error(`Cannot execute script note '${note.noteId}' because it is protected and protected session is not available. Enter protected session and try again.`);
}
if (!(note.isJavaScript() || note.isHtml() || note.isJsx())) {

View File

@@ -31,6 +31,11 @@ import CodeBlockLanguageDropdown from "./plugins/code_block_language_dropdown.js
import MoveBlockUpDownPlugin from "./plugins/move_block_updown.js";
import ScrollOnUndoRedoPlugin from "./plugins/scroll_on_undo_redo.js"
import InlineCodeNoSpellcheck from "./plugins/inline_code_no_spellcheck.js";
import InlineCodeToolbar from "./plugins/inline_code_toolbar.js";
import AdmonitionTypeDropdown from "./plugins/admonition_type_dropdown.js";
import AdmonitionToolbar from "./plugins/admonition_toolbar.js";
import IncludeNoteBoxSizeDropdown from "./plugins/include_note_box_size_dropdown.js";
import IncludeNoteToolbar from "./plugins/include_note_toolbar.js";
/**
* Plugins that are specific to Trilium and not part of the CKEditor 5 core, included in both text editors but not in the attribute editor.
@@ -53,6 +58,11 @@ const TRILIUM_PLUGINS: typeof Plugin[] = [
MoveBlockUpDownPlugin,
ScrollOnUndoRedoPlugin,
InlineCodeNoSpellcheck,
InlineCodeToolbar,
AdmonitionTypeDropdown,
AdmonitionToolbar,
IncludeNoteBoxSizeDropdown,
IncludeNoteToolbar,
];
/**

View File

@@ -0,0 +1,43 @@
import { Plugin, ViewDocumentFragment, WidgetToolbarRepository, type ViewNode } from "ckeditor5";
import { Admonition } from "@triliumnext/ckeditor5-admonition";
import AdmonitionTypeDropdown from "./admonition_type_dropdown";
export default class AdmonitionToolbar extends Plugin {
static get requires() {
return [WidgetToolbarRepository, Admonition, AdmonitionTypeDropdown] as const;
}
afterInit() {
const editor = this.editor;
const widgetToolbarRepository = editor.plugins.get(WidgetToolbarRepository);
widgetToolbarRepository.register("admonition", {
items: [
"admonitionTypeDropdown"
],
balloonClassName: "ck-toolbar-container admonition-type-list",
getRelatedElement(selection) {
const selectionPosition = selection.getFirstPosition();
if (!selectionPosition) {
return null;
}
let parent: ViewNode | ViewDocumentFragment | null = selectionPosition.parent;
while (parent) {
if (parent.is("element", "aside") || parent.is("element", "div")) {
// Check if it's an admonition by looking for the admonition class
const classes = (parent as any).getAttribute?.("class") || "";
if (typeof classes === "string" && classes.includes("admonition")) {
return parent;
}
}
parent = parent.parent;
}
return null;
}
});
}
}

View File

@@ -0,0 +1,71 @@
import { Plugin, type ListDropdownButtonDefinition, Collection, ViewModel, createDropdown, addListToDropdown, DropdownButtonView } from "ckeditor5";
import { Admonition, ADMONITION_TYPES, type AdmonitionCommand, type AdmonitionType } from "@triliumnext/ckeditor5-admonition";
/**
* Toolbar item which displays the list of admonition types in a dropdown.
* Uses the same styling as the main admonition toolbar button.
*/
export default class AdmonitionTypeDropdown extends Plugin {
static get requires() {
return [Admonition] as const;
}
public init() {
const editor = this.editor;
const componentFactory = editor.ui.componentFactory;
const itemDefinitions = this._getTypeListItemDefinitions();
const command = editor.commands.get("admonition") as AdmonitionCommand;
componentFactory.add("admonitionTypeDropdown", _locale => {
const dropdownView = createDropdown(editor.locale, DropdownButtonView);
dropdownView.buttonView.set({
withText: true
});
dropdownView.bind("isEnabled").to(command, "value", value => !!value);
dropdownView.buttonView.bind("label").to(command, "value", (value) => {
if (!value) return "";
const typeDef = ADMONITION_TYPES[value as AdmonitionType];
return typeDef?.title ?? value;
});
dropdownView.on("execute", evt => {
const source = evt.source as any;
editor.execute("admonition", {
forceValue: source.commandParam
});
editor.editing.view.focus();
});
addListToDropdown(dropdownView, itemDefinitions);
return dropdownView;
});
}
private _getTypeListItemDefinitions(): Collection<ListDropdownButtonDefinition> {
const editor = this.editor;
const command = editor.commands.get("admonition") as AdmonitionCommand;
const itemDefinitions = new Collection<ListDropdownButtonDefinition>();
for (const [type, typeDef] of Object.entries(ADMONITION_TYPES)) {
const definition: ListDropdownButtonDefinition = {
type: "button",
model: new ViewModel({
commandParam: type,
label: typeDef.title,
class: `ck-tn-admonition-option ck-tn-admonition-${type}`,
role: "menuitemradio",
withText: true
})
};
definition.model.bind("isOn").to(command, "value", value => {
return value === type;
});
itemDefinitions.add(definition);
}
return itemDefinitions;
}
}

View File

@@ -38,28 +38,43 @@ export class CopyToClipboardCommand extends Command {
this.executeCallback = this.editor.config.get("clipboard")?.copy;
}
// Try code block first
const codeBlockEl = selection.getFirstPosition()?.findAncestor("codeBlock");
if (!codeBlockEl) {
console.warn("Unable to find code block element to copy from.");
if (codeBlockEl) {
const codeText = Array.from(codeBlockEl.getChildren())
.map(child => "data" in child ? child.data : "\n")
.join("");
this.copyText(codeText, "code block");
return;
}
const codeText = Array.from(codeBlockEl.getChildren())
.map(child => "data" in child ? child.data : "\n")
.join("");
if (codeText) {
if (!this.executeCallback) {
navigator.clipboard.writeText(codeText).then(() => {
console.log('Code block copied to clipboard');
}).catch(err => {
console.error('Failed to copy code block', err);
});
} else {
this.executeCallback(codeText);
// Try inline code (text with 'code' attribute)
const position = selection.getFirstPosition();
if (position) {
const textNode = position.textNode || position.nodeBefore || position.nodeAfter;
if (textNode && "data" in textNode && textNode.hasAttribute?.("code")) {
this.copyText(textNode.data as string, "inline code");
return;
}
}
console.warn("No code block or inline code found to copy from.");
}
private copyText(text: string, source: string) {
if (!text) {
console.warn(`No text found in ${source}.`);
return;
}
if (!this.executeCallback) {
navigator.clipboard.writeText(text).then(() => {
console.log(`${source} copied to clipboard`);
}).catch(err => {
console.error(`Failed to copy ${source}`, err);
});
} else {
console.warn('No code block selected or found.');
this.executeCallback(text);
}
}

View File

@@ -0,0 +1,71 @@
import { Plugin, type ListDropdownButtonDefinition, Collection, ViewModel, createDropdown, addListToDropdown, DropdownButtonView, type Command } from "ckeditor5";
import IncludeNote, { BOX_SIZE_COMMAND_NAME, BOX_SIZES, type BoxSizeValue } from "./includenote.js";
/**
* Toolbar item which displays the list of box sizes for include notes in a dropdown.
*/
export default class IncludeNoteBoxSizeDropdown extends Plugin {
static get requires() {
return [IncludeNote] as const;
}
public init() {
const editor = this.editor;
const componentFactory = editor.ui.componentFactory;
const itemDefinitions = this._getBoxSizeListItemDefinitions();
const command = editor.commands.get(BOX_SIZE_COMMAND_NAME) as Command & { value: BoxSizeValue | null };
componentFactory.add("includeNoteBoxSizeDropdown", _locale => {
const dropdownView = createDropdown(editor.locale, DropdownButtonView);
dropdownView.buttonView.set({
withText: true,
tooltip: true,
label: "Box size"
});
dropdownView.bind("isEnabled").to(command, "isEnabled");
dropdownView.buttonView.bind("label").to(command, "value", (value) => {
if (!value) return "Box size";
const sizeDef = BOX_SIZES.find(s => s.value === value);
return sizeDef?.label ?? value;
});
dropdownView.on("execute", evt => {
const source = evt.source as any;
editor.execute(BOX_SIZE_COMMAND_NAME, {
value: source._boxSizeValue
});
editor.editing.view.focus();
});
addListToDropdown(dropdownView, itemDefinitions);
return dropdownView;
});
}
private _getBoxSizeListItemDefinitions(): Collection<ListDropdownButtonDefinition> {
const editor = this.editor;
const command = editor.commands.get(BOX_SIZE_COMMAND_NAME) as Command & { value: BoxSizeValue | null };
const itemDefinitions = new Collection<ListDropdownButtonDefinition>();
for (const sizeDef of BOX_SIZES) {
const definition: ListDropdownButtonDefinition = {
type: "button",
model: new ViewModel({
_boxSizeValue: sizeDef.value,
label: sizeDef.label,
role: "menuitemradio",
withText: true
})
};
definition.model.bind("isOn").to(command, "value", value => {
return value === sizeDef.value;
});
itemDefinitions.add(definition);
}
return itemDefinitions;
}
}

View File

@@ -0,0 +1,45 @@
import { Plugin, WidgetToolbarRepository, isWidget, type ViewElement } from "ckeditor5";
import IncludeNote from "./includenote.js";
import IncludeNoteBoxSizeDropdown from "./include_note_box_size_dropdown.js";
export default class IncludeNoteToolbar extends Plugin {
static get requires() {
return [WidgetToolbarRepository, IncludeNote, IncludeNoteBoxSizeDropdown] as const;
}
afterInit() {
const editor = this.editor;
const widgetToolbarRepository = editor.plugins.get(WidgetToolbarRepository);
widgetToolbarRepository.register("includeNote", {
items: [
"includeNoteBoxSizeDropdown"
],
balloonClassName: "ck-toolbar-container include-note-toolbar",
getRelatedElement(selection) {
const selectedElement = selection.getSelectedElement();
if (selectedElement && isIncludeNoteWidget(selectedElement)) {
return selectedElement;
}
return null;
}
});
}
}
function isIncludeNoteWidget(element: ViewElement): boolean {
if (!isWidget(element)) {
return false;
}
if (!element.is("element", "section")) {
return false;
}
const classes = element.getAttribute("class") || "";
return typeof classes === "string" && classes.includes("include-note");
}

View File

@@ -2,6 +2,16 @@ import { ButtonView, Command, Plugin, toWidget, Widget, type Editor, type Observ
import noteIcon from '../icons/note.svg?raw';
export const COMMAND_NAME = 'insertIncludeNote';
export const BOX_SIZE_COMMAND_NAME = 'includeNoteBoxSize';
export const BOX_SIZES = [
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'full', label: 'Full' },
{ value: 'expandable', label: 'Expandable' }
] as const;
export type BoxSizeValue = typeof BOX_SIZES[number]['value'];
export default class IncludeNote extends Plugin {
static get requires() {
@@ -54,6 +64,7 @@ class IncludeNoteEditing extends Plugin {
this._defineConverters();
this.editor.commands.add( COMMAND_NAME, new InsertIncludeNoteCommand( this.editor ) );
this.editor.commands.add( BOX_SIZE_COMMAND_NAME, new IncludeNoteBoxSizeCommand( this.editor ) );
}
_defineSchema() {
@@ -133,6 +144,29 @@ class IncludeNoteEditing extends Plugin {
return toWidget( section, viewWriter, { label: 'include note widget' } );
}
} );
// Handle boxSize attribute changes on existing elements
conversion.for( 'editingDowncast' ).add( dispatcher => {
dispatcher.on( 'attribute:boxSize:includeNote', ( evt, data, conversionApi ) => {
const viewElement = conversionApi.mapper.toViewElement( data.item );
if ( !viewElement ) {
return;
}
const viewWriter = conversionApi.writer;
const oldBoxSize = data.attributeOldValue as string;
const newBoxSize = data.attributeNewValue as string;
// Remove old class and add new class
if ( oldBoxSize ) {
viewWriter.removeClass( 'box-size-' + oldBoxSize, viewElement );
}
if ( newBoxSize ) {
viewWriter.addClass( 'box-size-' + newBoxSize, viewElement );
viewWriter.setAttribute( 'data-box-size', newBoxSize, viewElement );
}
} );
} );
}
}
@@ -154,6 +188,41 @@ class InsertIncludeNoteCommand extends Command {
}
}
class IncludeNoteBoxSizeCommand extends Command {
declare value: BoxSizeValue | null;
override execute( options: { value: BoxSizeValue } ) {
const model = this.editor.model;
const includeNoteElement = this._getSelectedIncludeNote();
if ( includeNoteElement ) {
model.change( writer => {
writer.setAttribute( 'boxSize', options.value, includeNoteElement );
} );
}
}
override refresh() {
const includeNoteElement = this._getSelectedIncludeNote();
this.isEnabled = !!includeNoteElement;
this.value = includeNoteElement?.getAttribute( 'boxSize' ) as BoxSizeValue | null ?? null;
}
private _getSelectedIncludeNote() {
const selection = this.editor.model.document.selection;
const selectedElement = selection.getSelectedElement();
if ( selectedElement?.name === 'includeNote' ) {
return selectedElement;
}
// Check if we're inside an include note
const firstPosition = selection.getFirstPosition();
return firstPosition?.findAncestor( 'includeNote' ) ?? null;
}
}
/**
* Hack coming from https://github.com/ckeditor/ckeditor5/issues/4465
* Source issue: https://github.com/zadam/trilium/issues/1117
@@ -163,7 +232,17 @@ function preventCKEditorHandling( domElement: HTMLElement, editor: Editor ) {
// commenting out click events to allow link click handler to still work
//domElement.addEventListener( 'click', stopEventPropagationAndHackRendererFocus, { capture: true } );
domElement.addEventListener( 'mousedown', stopEventPropagationAndHackRendererFocus, { capture: true } );
domElement.addEventListener( 'mousedown', ( evt: Event ) => {
evt.stopPropagation();
// This prevents rendering changed view selection thus preventing to changing DOM selection while inside a widget.
//@ts-expect-error: We are accessing a private field.
editor.editing.view._renderer.isFocused = false;
// Select the widget so the toolbar can appear
selectIncludeNoteWidget( domElement, editor );
}, { capture: true } );
domElement.addEventListener( 'focus', stopEventPropagationAndHackRendererFocus, { capture: true } );
// Prevents TAB handling or other editor keys listeners which might be executed on editors selection.
@@ -176,3 +255,31 @@ function preventCKEditorHandling( domElement: HTMLElement, editor: Editor ) {
editor.editing.view._renderer.isFocused = false;
}
}
function selectIncludeNoteWidget( domElement: HTMLElement, editor: Editor ) {
// Find the parent section element (the widget container)
const sectionElement = domElement.closest( 'section.include-note' ) as HTMLElement | null;
if ( !sectionElement ) {
return;
}
// Get the view element from the DOM element
const viewElement = editor.editing.view.domConverter.mapDomToView( sectionElement );
if ( !viewElement || !viewElement.is( 'element' ) ) {
return;
}
// Get the model element from the view element
const modelElement = editor.editing.mapper.toModelElement( viewElement );
if ( !modelElement ) {
return;
}
// Focus the editor view first to ensure selection sync works
editor.editing.view.focus();
// Select the model element using a non-undoable batch so it doesn't affect undo
editor.model.enqueueChange( { isUndoable: false }, writer => {
writer.setSelection( modelElement, 'on' );
} );
}

View File

@@ -0,0 +1,126 @@
import { BalloonPanelView, ButtonView, Plugin, ToolbarView } from "ckeditor5";
import CopyToClipboardButton from "./copy_to_clipboard_button";
import copyIcon from "../icons/copy.svg?raw";
/**
* Shows a small toolbar with a copy button when the cursor is on inline code.
*/
export default class InlineCodeToolbar extends Plugin {
static get requires() {
return [CopyToClipboardButton] as const;
}
private balloon?: BalloonPanelView;
private toolbar?: ToolbarView;
init() {
const editor = this.editor;
// Create toolbar with copy button
this.toolbar = new ToolbarView(editor.locale);
const copyButton = new ButtonView(editor.locale);
copyButton.set({
icon: copyIcon,
tooltip: "Copy to clipboard"
});
copyButton.on("execute", () => {
editor.execute("copyToClipboard");
this.hideToolbar();
});
this.toolbar.items.add(copyButton);
// Create balloon panel
this.balloon = new BalloonPanelView(editor.locale);
this.balloon.content.add(this.toolbar);
this.balloon.class = "ck-toolbar-container";
editor.ui.view.body.add(this.balloon);
// Show/hide based on selection
this.listenTo(editor.model.document.selection, "change:range", () => {
this.updateToolbarVisibility();
});
// Hide on editor blur
this.listenTo(editor.ui.focusTracker, "change:isFocused", (_evt, _name, isFocused) => {
if (!isFocused) {
this.hideToolbar();
}
});
}
private updateToolbarVisibility() {
const editor = this.editor;
const selection = editor.model.document.selection;
const position = selection.getFirstPosition();
// Don't show for code blocks (they have their own toolbar)
if (position?.findAncestor("codeBlock")) {
this.hideToolbar();
return;
}
// Check if cursor is on inline code
const textNode = position?.textNode;
if (textNode?.hasAttribute("code")) {
this.showToolbar(textNode);
} else {
this.hideToolbar();
}
}
private showToolbar(textNode: unknown) {
if (!this.balloon) return;
const editor = this.editor;
const view = editor.editing.view;
const mapper = editor.editing.mapper;
// Map model text node to view element
const viewRange = mapper.toViewRange(editor.model.createRangeOn(textNode as any));
const viewElement = viewRange.getContainedElement();
if (!viewElement) {
this.hideToolbar();
return;
}
const domElement = view.domConverter.mapViewToDom(viewElement);
if (!domElement || !(domElement instanceof HTMLElement)) {
this.hideToolbar();
return;
}
const rect = domElement.getBoundingClientRect();
this.balloon.pin({
target: {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
width: rect.width,
height: rect.height
}
});
this.balloon.isVisible = true;
}
private hideToolbar() {
if (this.balloon) {
this.balloon.isVisible = false;
this.balloon.unpin();
}
}
override destroy() {
super.destroy();
if (this.balloon) {
this.balloon.destroy();
}
if (this.toolbar) {
this.toolbar.destroy();
}
}
}

View File

@@ -39,7 +39,11 @@ export default class InternalLinkPlugin extends Plugin {
class InsertInternalLinkCommand extends Command {
refresh() {
this.isEnabled = !this.editor.isReadOnly;
const selection = this.editor.model.document.selection;
const position = selection.getFirstPosition();
const isInCodeBlock = position?.findAncestor("codeBlock");
this.isEnabled = !this.editor.isReadOnly && !isInCodeBlock;
}
execute() {

View File

@@ -142,6 +142,9 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
seenCallToActions: string;
experimentalFeatures: string;
// Include note settings
includeNoteDefaultBoxSize: "small" | "medium" | "full" | "expandable";
// AI / LLM
/** JSON array of configured LLM providers with their API keys */
llmProviders: string;

View File

@@ -18,5 +18,5 @@ export const ALLOWED_PROTOCOLS = [
'gopher', 'imap', 'irc', 'irc6', 'jabber', 'jar', 'lastfm', 'ldap', 'ldaps', 'magnet', 'message',
'mumble', 'nfs', 'onenote', 'pop', 'rmi', 's3', 'sftp', 'skype', 'sms', 'spotify', 'steam', 'svn', 'udp',
'view-source', 'vlc', 'vnc', 'ws', 'wss', 'xmpp', 'jdbc', 'slack', 'tel', 'smb', 'zotero', 'geo',
'logseq', 'mid', 'obsidian'
'logseq', 'mid', 'obsidian', 'bookends', 'highlights'
];

View File

@@ -1,7 +1,33 @@
const MAX_LOG_DEPTH = 3;
/**
* Creates a JSON replacer that handles circular references and limits depth.
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#examples
*/
function getCircularReplacer() {
const ancestors: object[] = [];
return function (this: object, _key: string, value: unknown) {
if (typeof value !== "object" || value === null) {
return value;
}
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
ancestors.pop();
}
if (ancestors.includes(value)) {
return "[Circular]";
}
if (ancestors.length >= MAX_LOG_DEPTH) {
return "[Object]";
}
ancestors.push(value);
return value;
};
}
export function formatLogMessage(message: string | object) {
if (typeof message === "object") {
try {
return JSON.stringify(message, null, 4);
return JSON.stringify(message, getCircularReplacer(), 4);
} catch (e) {
return message.toString();
}

View File

@@ -120,6 +120,7 @@ export type WebSocketMessage = AllTaskDefinitions | {
} | {
type: "toast",
message: string;
timeout?: number;
} | {
type: "api-log-messages",
noteId: string,