mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 12:37:04 +02:00
Easy fixes v2 (#9377)
This commit is contained in:
10
.github/copilot-instructions.md
vendored
10
.github/copilot-instructions.md
vendored
@@ -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
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
30
apps/client/src/widgets/dialogs/delete_notes.css
Normal file
30
apps/client/src/widgets/dialogs/delete_notes.css
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
52
apps/client/src/widgets/sidebar/HighlightsList.spec.ts
Normal file
52
apps/client/src/widgets/sidebar/HighlightsList.spec.ts
Normal 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
|
||||
<span class="math-tex">
|
||||
\\(e=mc^2\\)
|
||||
</span>
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
12365
apps/edit-docs/demo/!!!meta.json
vendored
12365
apps/edit-docs/demo/!!!meta.json
vendored
File diff suppressed because it is too large
Load Diff
3
apps/edit-docs/demo/navigation.html
vendored
3
apps/edit-docs/demo/navigation.html
vendored
@@ -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>
|
||||
|
||||
15
apps/edit-docs/demo/root/Trilium Demo.html
vendored
15
apps/edit-docs/demo/root/Trilium Demo.html
vendored
@@ -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
|
||||
<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>
|
||||
|
||||
@@ -14,17 +14,22 @@
|
||||
|
||||
<div class="ck-content">
|
||||
<h2>Main characters</h2>
|
||||
|
||||
<p>… here put main characters …</p>
|
||||
<p> </p>
|
||||
<h2>Plot</h2>
|
||||
<h2>Plot</h2>
|
||||
|
||||
<p>… describe main plot lines …</p>
|
||||
<p> </p>
|
||||
<h2>Tone</h2>
|
||||
<h2>Tone</h2>
|
||||
|
||||
<p> </p>
|
||||
<h2>Genre</h2>
|
||||
<h2>Genre</h2>
|
||||
|
||||
<p>scifi / drama / romance</p>
|
||||
<p> </p>
|
||||
<h2>Similar books</h2>
|
||||
<h2>Similar books</h2>
|
||||
|
||||
<ul>
|
||||
<li data-list-item-id="eebd9f297d5dc97dfc46579ba1f25d7bf">…</li>
|
||||
</ul>
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
@@ -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}}",
|
||||
|
||||
@@ -106,7 +106,7 @@ function getNoteTitleArrayForPath(notePathArray: string[]) {
|
||||
function getNoteTitleForPath(notePathArray: string[]) {
|
||||
const titles = getNoteTitleArrayForPath(notePathArray);
|
||||
|
||||
return titles.join(" / ");
|
||||
return titles.join(" › ");
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -95,6 +95,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"textNoteEmojiCompletionEnabled",
|
||||
"textNoteCompletionEnabled",
|
||||
"textNoteSlashCommandsEnabled",
|
||||
"includeNoteDefaultBoxSize",
|
||||
"layoutOrientation",
|
||||
"backgroundEffects",
|
||||
"allowedHtmlTags",
|
||||
|
||||
@@ -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*$/],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
43
packages/ckeditor5/src/plugins/admonition_toolbar.ts
Normal file
43
packages/ckeditor5/src/plugins/admonition_toolbar.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
71
packages/ckeditor5/src/plugins/admonition_type_dropdown.ts
Normal file
71
packages/ckeditor5/src/plugins/admonition_type_dropdown.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
45
packages/ckeditor5/src/plugins/include_note_toolbar.ts
Normal file
45
packages/ckeditor5/src/plugins/include_note_toolbar.ts
Normal 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");
|
||||
}
|
||||
@@ -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' );
|
||||
} );
|
||||
}
|
||||
|
||||
126
packages/ckeditor5/src/plugins/inline_code_toolbar.ts
Normal file
126
packages/ckeditor5/src/plugins/inline_code_toolbar.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ export type WebSocketMessage = AllTaskDefinitions | {
|
||||
} | {
|
||||
type: "toast",
|
||||
message: string;
|
||||
timeout?: number;
|
||||
} | {
|
||||
type: "api-log-messages",
|
||||
noteId: string,
|
||||
|
||||
Reference in New Issue
Block a user