Basic Markdown support (#9475)

This commit is contained in:
Elian Doran
2026-04-17 22:30:54 +03:00
committed by GitHub
68 changed files with 2365 additions and 797 deletions

View File

@@ -25,6 +25,15 @@ export type GetTextEditorCallback = (editor: CKTextEditor) => void;
export type SaveState = "saved" | "saving" | "unsaved" | "error";
const READ_ONLY_CAPABLE_TYPES: string[] = [
"text",
"code",
"mermaid",
"canvas",
"mindMap",
"spreadsheet"
];
export interface NoteContextDataMap {
toc: HeadingContext;
pdfPages: {
@@ -303,8 +312,12 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return false;
}
// "readOnly" is a state valid only for text/code notes
if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) {
if (!this.note) {
return false;
}
// Note types that support a read-only state (via the #readOnly label, source view, or auto-readonly).
if (!READ_ONLY_CAPABLE_TYPES.includes(this.note.type)) {
return false;
}
@@ -320,6 +333,11 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return true;
}
// Auto read-only based on content size is only configurable for text/code.
if (this.note.type !== "text" && this.note.type !== "code") {
return false;
}
// Store the initial decision about read-only status in the viewScope
// This will be "remembered" until the viewScope is refreshed
if (!this.viewScope) {

View File

@@ -1069,6 +1069,10 @@ export default class FNote {
return this.mime === "text/x-sqlite;schema=trilium";
}
isMarkdown() {
return this.type === "code" && (this.mime === "text/markdown" || this.mime === "text/x-markdown" || this.mime === "text/x-gfm");
}
isTriliumScript() {
return this.mime.startsWith("application/javascript");
}

View File

@@ -39,6 +39,7 @@ export interface MenuCommandItem<T> {
title: string;
command?: T;
type?: string;
mime?: string;
/**
* The icon to display in the menu item.
*

View File

@@ -288,7 +288,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
}
async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
async selectMenuItemHandler({ command, type, mime, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
const notePath = treeService.getNotePath(this.node);
if (utils.isMobile()) {
@@ -305,6 +305,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
target: "after",
targetBranchId: this.node.data.branchId,
type,
mime,
isProtected,
templateNoteId
});
@@ -313,6 +314,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
noteCreateService.createNote(parentNotePath, {
type,
mime,
isProtected: this.node.data.isProtected,
templateNoteId
});

View File

@@ -1,6 +1,7 @@
import "./content_renderer.css";
import { normalizeMimeTypeForCKEditor, type TextRepresentationResponse } from "@triliumnext/commons";
import { normalizeMimeTypeForCKEditor, renderToHtml, type TextRepresentationResponse } from "@triliumnext/commons";
import DOMPurify from "dompurify";
import { h, render } from "preact";
import WheelZoom from 'vanilla-js-wheel-zoom';
@@ -8,7 +9,7 @@ import FAttachment from "../entities/fattachment.js";
import FNote from "../entities/fnote.js";
import imageContextMenuService from "../menus/image_context_menu.js";
import { t } from "../services/i18n.js";
import renderText from "./content_renderer_text.js";
import renderText, { postProcessRichContent, renderChildrenList } from "./content_renderer_text.js";
import renderDoc from "./doc_renderer.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
import openService from "./open.js";
@@ -54,6 +55,8 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
if (type === "text" || type === "book") {
await renderText(entity, $renderedContent, options);
} else if (type === "markdown") {
await renderMarkdown(entity, $renderedContent, options);
} else if (type === "code") {
await renderCode(entity, $renderedContent);
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
@@ -119,6 +122,31 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
};
}
/**
* Renders a markdown note by converting its source to CKEditor-compatible HTML,
* then running the same post-render pipeline as text notes (included notes,
* math, reference links, Mermaid, code highlight) so the preview matches what
* the user sees in the Markdown note type's preview pane.
*/
async function renderMarkdown(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions) {
const blob = await note.getBlob();
const source = blob?.content ?? "";
if (!source.trim()) {
if (note instanceof FNote && !options.noChildrenList) {
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
}
return;
}
const html = renderToHtml(source, note.title, {
sanitize: (dirty) => DOMPurify.sanitize(dirty),
wikiLink: { formatHref: (id) => `#root/${id}` }
});
$renderedContent.append($('<div class="ck-content">').html(html));
await postProcessRichContent(note, $renderedContent, options);
}
/**
* Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type.
*/
@@ -330,6 +358,8 @@ function getRenderingType(entity: FNote | FAttachment) {
if (type === "file" && mime === "application/pdf") {
type = "pdf";
} else if (type === "code" && entity instanceof FNote && entity.isMarkdown()) {
type = "markdown";
} else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime) && !isIconPack) {
type = "code";
} else if (type === "file" && mime && mime.startsWith("audio/")) {

View File

@@ -15,37 +15,47 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
if (blob && !isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
if (!options.noIncludedNotes) {
await renderIncludedNotes($renderedContent[0], seenNoteIds);
} else {
$renderedContent.find("section.include-note").remove();
}
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find<HTMLAnchorElement>("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
for (const el of referenceLinks) {
const innerSpan = document.createElement("span");
await link.loadReferenceLinkTitle($(innerSpan), el.href);
el.replaceChildren(innerSpan);
}
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
await formatCodeBlocks($renderedContent);
await postProcessRichContent(note, $renderedContent, options);
} else if (note instanceof FNote && !options.noChildrenList) {
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
}
}
/**
* Apply the post-render passes that make CKEditor-compatible HTML fully
* interactive: expand `<section class="include-note">`, render inline math and
* Mermaid diagrams, rewrite reference-link titles, and highlight code blocks.
* Assumes the caller has already appended the HTML inside a `.ck-content` child
* of `$renderedContent`.
*/
export async function postProcessRichContent(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
if (!options.noIncludedNotes) {
await renderIncludedNotes($renderedContent[0], seenNoteIds);
} else {
$renderedContent.find("section.include-note").remove();
}
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find<HTMLAnchorElement>("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
for (const el of referenceLinks) {
const innerSpan = document.createElement("span");
await link.loadReferenceLinkTitle($(innerSpan), el.href);
el.replaceChildren(innerSpan);
}
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
await formatCodeBlocks($renderedContent);
}
async function renderIncludedNotes(contentEl: HTMLElement, seenNoteIds: Set<string>) {
// TODO: Consider duplicating with server's share/content_renderer.ts.
const includeNoteEls = contentEl.querySelectorAll("section.include-note");
@@ -101,19 +111,107 @@ export async function rewriteMermaidDiagramsInContainer(container: HTMLDivElemen
}
}
/**
* Per-container cache of rendered mermaid SVG keyed by diagram source text.
* Populated after each successful render; reused on subsequent renders to
* avoid flicker when the preview HTML is regenerated (e.g. live markdown
* editing). Entries for diagrams no longer present in the container are
* evicted on each run so the cache can't grow unbounded.
*/
const mermaidSvgCache = new WeakMap<HTMLElement, Map<string, string>>();
/**
* Per-container, ordered snapshot of the most recently rendered SVGs. Used as
* a positional placeholder so edits to a diagram's source keep the previous
* SVG visible while the new one renders offscreen.
*/
const mermaidLastRenderedByPosition = new WeakMap<HTMLElement, string[]>();
export async function applyInlineMermaid(container: HTMLDivElement) {
// Initialize mermaid
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
if (!nodes.length) {
mermaidLastRenderedByPosition.delete(container);
return;
}
let cache = mermaidSvgCache.get(container);
if (!cache) {
cache = new Map();
mermaidSvgCache.set(container, cache);
}
const lastRendered = mermaidLastRenderedByPosition.get(container) ?? [];
// Decide per node: exact cache hit → paint final SVG; source changed →
// paint the previous SVG (by position) as a placeholder and queue an
// offscreen re-render. This way the user keeps seeing the old diagram
// until mermaid has finished producing the new one.
const pending: Array<{ visible: HTMLElement; source: string }> = [];
const seenSources = new Set<string>();
for (const [ index, node ] of nodes.entries()) {
const source = (node.textContent ?? "").trim();
seenSources.add(source);
const cached = cache.get(source);
if (cached) {
node.innerHTML = cached;
node.setAttribute("data-processed", "true");
continue;
}
pending.push({ visible: node, source });
const placeholder = lastRendered[index];
if (placeholder) {
node.innerHTML = placeholder;
}
}
// Evict cache entries whose source is no longer present.
for (const key of [ ...cache.keys() ]) {
if (!seenSources.has(key)) cache.delete(key);
}
if (!pending.length) {
mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML));
return;
}
const mermaid = (await import("mermaid")).default;
mermaid.initialize(getMermaidConfig());
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
// Render clones offscreen so the visible nodes keep showing the placeholder
// until the new SVG is ready. Keeps mermaid away from our placeholder SVG
// (which would otherwise confuse its text-based parser).
const offscreen = document.createElement("div");
offscreen.style.cssText = "position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;visibility:hidden;";
document.body.appendChild(offscreen);
const pairs = pending.map(({ visible, source }) => {
const clone = document.createElement("div");
clone.className = "mermaid-diagram";
clone.textContent = source;
offscreen.appendChild(clone);
return { visible, clone, source };
});
try {
await mermaid.run({ nodes });
await mermaid.run({ nodes: pairs.map((p) => p.clone) });
for (const { visible, clone, source } of pairs) {
if (clone.getAttribute("data-processed") !== "true") continue;
const svg = clone.innerHTML;
visible.innerHTML = svg;
visible.setAttribute("data-processed", "true");
cache.set(source, svg);
}
} catch (e) {
console.log(e);
console.error(e);
} finally {
offscreen.remove();
}
mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML));
}
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote, includeArchivedNotes: boolean) {
export async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote, includeArchivedNotes: boolean) {
let childNoteIds = note.getChildNoteIds();
if (!childNoteIds.length) {

View File

@@ -27,7 +27,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
// The default note type (always the first item)
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true, isNew: true },
// Text notes group
{ type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" },
@@ -49,6 +49,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
// Code notes
{ type: "code", mime: "text/plain", title: t("note_types.code"), icon: "bx-code" },
{ type: "code", mime: "text/x-markdown", title: t("note_types.markdown"), icon: "bxl-markdown", isNew: true },
// Reserved types (cannot be created by the user)
{ type: "contentWidget", mime: "", title: t("note_types.widget"), reserved: true },
@@ -100,6 +101,7 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandName
title: nt.title,
command,
type: nt.type,
mime: nt.mime,
uiIcon: `bx ${nt.icon}`,
badges: []
};

View File

@@ -131,11 +131,16 @@ button.tn-low-profile:hover {
color: var(--icon-button-color);
}
:root .btn-group .icon-action:last-child {
:root .btn-group .icon-action:not(:first-child) {
border-top-left-radius: unset !important;
border-bottom-left-radius: unset !important;
}
:root .btn-group .icon-action:not(:last-child) {
border-top-right-radius: unset !important;
border-bottom-right-radius: unset !important;
}
.btn-group .tn-tool-button + .tn-tool-button {
margin-inline-start: 4px !important;
}

View File

@@ -635,6 +635,7 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
inset-inline-end: 0.35em;
}
.ck-content h1,
.ck-content h2,
.ck-content h3,
.ck-content h4,
@@ -694,7 +695,7 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
outline: none;
box-shadow: 0 0 0 2px var(--link-selection-outline-color);
background: var(--link-hover-background);
}
}
/* Reference link */

View File

@@ -1671,6 +1671,7 @@
"note_types": {
"text": "Text",
"code": "Code",
"markdown": "Markdown",
"saved-search": "Saved Search",
"relation-map": "Relation Map",
"note-map": "Note Map",
@@ -2075,6 +2076,11 @@
"unlock-editing": "Unlock editing",
"lock-editing": "Lock editing"
},
"display_mode": {
"source": "Source view",
"split": "Split view",
"preview": "Preview"
},
"png_export_button": {
"button_title": "Export diagram as PNG"
},

View File

@@ -20,7 +20,8 @@ import tree from "../services/tree";
import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils";
import { ViewTypeOptions } from "./collections/interface";
import ActionButton, { ActionButtonProps } from "./react/ActionButton";
import { useIsNoteReadOnly, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
import { ButtonGroup } from "./react/Button";
import { useIsNoteReadOnly, useNoteLabel, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
import NoteLink from "./react/NoteLink";
import RawHtml from "./react/RawHtml";
@@ -47,8 +48,9 @@ export type FloatingButtonsList = ((context: FloatingButtonContext) => false | V
export const DESKTOP_FLOATING_BUTTONS: FloatingButtonsList = [
RefreshBackendLogButton,
SwitchSplitOrientationButton,
ToggleReadOnlyButton,
SwitchSplitOrientationButton,
DisplayModeSwitcher,
EditButton,
ShowTocWidgetButton,
ShowHighlightsListWidgetButton,
@@ -80,9 +82,13 @@ function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefault
}
function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = note.type === "mermaid" && note.isContentAvailable() && !isReadOnly && isDefaultViewMode;
const [ displayMode ] = useNoteLabel(note, "displayMode");
const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation");
const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal";
const effectiveMode = displayMode === "source" || displayMode === "split" || displayMode === "preview"
? displayMode
: isReadOnly ? "preview" : "split";
const isEnabled = note.type === "mermaid" && note.isContentAvailable() && effectiveMode === "split" && isDefaultViewMode;
return isEnabled && <FloatingButton
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
@@ -94,7 +100,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
function ToggleReadOnlyButton({ note, isDefaultViewMode }: FloatingButtonContext) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
const isEnabled = ([ "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite)
&& note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <FloatingButton
@@ -104,6 +110,33 @@ function ToggleReadOnlyButton({ note, isDefaultViewMode }: FloatingButtonContext
/>;
}
function DisplayModeSwitcher({ note, isDefaultViewMode }: FloatingButtonContext) {
const [ displayMode, setDisplayMode ] = useNoteLabel(note, "displayMode");
const isEnabled = (note.isMarkdown() || note.type === "mermaid") && note.isContentAvailable() && isDefaultViewMode;
if (!isEnabled) return false;
const mode = displayMode === "source" || displayMode === "preview" ? displayMode : "split";
const buttons: Array<{ value: "source" | "split" | "preview"; icon: string; text: string }> = [
{ value: "source", icon: "bx bx-code", text: t("display_mode.source") },
{ value: "split", icon: "bx bxs-dock-left", text: t("display_mode.split") },
{ value: "preview", icon: "bx bx-show", text: t("display_mode.preview") }
];
return (
<ButtonGroup size="sm">
{buttons.map(({ value, icon, text }) => (
<FloatingButton
key={value}
icon={icon}
text={text}
active={mode === value}
onClick={() => setDisplayMode(value)}
/>
))}
</ButtonGroup>
);
}
function EditButton({ note, noteContext }: FloatingButtonContext) {
const [animationClass, setAnimationClass] = useState("");
const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext);

View File

@@ -352,7 +352,9 @@ export async function getExtendedWidgetType(note: FNote | null | undefined, note
resultingType = "readOnlyText";
} else if (note.isTriliumSqlite()) {
resultingType = "sqlConsole";
} else if ((type === "code" || type === "mermaid") && (await noteContext?.isReadOnly())) {
} else if (note.isMarkdown()) {
resultingType = "markdown";
} else if (type === "code" && (await noteContext?.isReadOnly())) {
resultingType = "readOnlyCode";
} else if (type === "text") {
resultingType = "editableText";

View File

@@ -63,7 +63,7 @@ export default class FindInText {
const findResultElement = editorEl?.querySelectorAll(".ck-find-result");
const scrollingContainer = editorEl?.closest('.scrolling-container');
const containerTop = scrollingContainer?.getBoundingClientRect().top ?? 0;
const closestIndex = Array.from(findResultElement ?? []).findIndex((el) => el.getBoundingClientRect().top >= containerTop);
const closestIndex = Array.from(findResultElement ?? []).findIndex((el: Element) => el.getBoundingClientRect().top >= containerTop);
currentFound = closestIndex >= 0 ? closestIndex : 0;
}
}

View File

@@ -75,6 +75,7 @@ function shouldShow(note: FNote | null | undefined, type: NoteType | undefined,
if (viewScope?.viewMode !== "default") return false;
if (note?.noteId?.startsWith("_options")) return true;
if (note?.isTriliumSqlite()) return false;
if (note?.isMarkdown()) return false;
return type && supportedNoteTypes.has(type);
}

View File

@@ -41,7 +41,7 @@ export default function NoteTypeSwitcher() {
const currentNoteTypeData = useMemo(() => NOTE_TYPES.find(t => t.type === currentNoteType), [ currentNoteType ]);
const { builtinTemplates, collectionTemplates } = useBuiltinTemplates();
return (currentNoteType && supportedNoteTypes.has(currentNoteType) && !note?.isTriliumSqlite() &&
return (currentNoteType && supportedNoteTypes.has(currentNoteType) && !note?.isTriliumSqlite() && !note?.isMarkdown() &&
<div
className="note-type-switcher"
onWheel={onWheelHorizontalScroll}

View File

@@ -12,7 +12,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget";
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
* for protected session or attachment information.
*/
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "readOnlyOCRText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "readOnlyOCRText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "markdown" | "llmChat";
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
@@ -147,6 +147,12 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
className: "sql-console-widget-container",
isFullHeight: true
},
markdown: {
view: () => import("./type_widgets/code/Markdown"),
className: "note-detail-markdown",
printable: true,
isFullHeight: true
},
spreadsheet: {
view: () => import("./type_widgets/spreadsheet/Spreadsheet"),
className: "note-detail-spreadsheet",

View File

@@ -84,9 +84,9 @@ function Button({ name, buttonRef, className, text, onClick, keyboardShortcut, i
);
}
export function ButtonGroup({ children }: { children: ComponentChildren }) {
export function ButtonGroup({ size, className, children }: { size?: "sm" | "lg"; className?: string; children: ComponentChildren }) {
return (
<div className="btn-group" role="group">
<div className={`btn-group ${size ? `btn-group-${size}` : ""} ${className ?? ""}`} role="group">
{children}
</div>
);

View File

@@ -2,7 +2,7 @@ import { CKTextEditor } from "@triliumnext/ckeditor5";
import { FilterLabelsByType, KeyboardActionNames, NoteType, OptionNames, RelationNames } from "@triliumnext/commons";
import { Tooltip } from "bootstrap";
import Mark from "mark.js";
import { RefObject, VNode } from "preact";
import { Ref, RefObject, VNode } from "preact";
import { CSSProperties, useSyncExternalStore } from "preact/compat";
import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
@@ -964,11 +964,13 @@ export function useLegacyImperativeHandlers(handlers: Record<string, Function>)
}, [ handlers ]);
}
export function useSyncedRef<T>(externalRef?: RefObject<T>, initialValue: T | null = null): RefObject<T> {
export function useSyncedRef<T>(externalRef?: Ref<T>, initialValue: T | null = null): RefObject<T> {
const ref = useRef<T>(initialValue);
useEffect(() => {
if (externalRef) {
if (typeof externalRef === "function") {
externalRef(ref.current);
} else if (externalRef) {
externalRef.current = ref.current;
}
}, [ ref, externalRef ]);
@@ -1140,6 +1142,29 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
return { isReadOnly, enableEditing, temporarilyEditable };
}
/**
* Synchronous effective read-only state for widgets that honor the `#readOnly` label
* (mermaid, canvas, mind map, spreadsheet). Combines the label with the temporary
* "enable editing" toggle (driven by `readOnlyTemporarilyDisabled`) so clicking the
* read-only badge unlocks the widget.
*/
export function useEffectiveReadOnly(note: FNote | null | undefined, noteContext: NoteContext | undefined) {
const [ readOnlyLabel ] = useNoteLabelBoolean(note, "readOnly");
const [ tempDisabled, setTempDisabled ] = useState<boolean>(!!noteContext?.viewScope?.readOnlyTemporarilyDisabled);
useEffect(() => {
setTempDisabled(!!noteContext?.viewScope?.readOnlyTemporarilyDisabled);
}, [ note, noteContext, noteContext?.viewScope ]);
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
if (noteContext?.ntxId === eventNoteContext?.ntxId) {
setTempDisabled(!!eventNoteContext?.viewScope?.readOnlyTemporarilyDisabled);
}
});
return readOnlyLabel && !tempDisabled;
}
async function isNoteReadOnly(note: FNote, noteContext: NoteContext) {
if (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) {

View File

@@ -1,3 +1,31 @@
body.mobile .note-actions-custom:not(:empty) {
margin-bottom: calc(var(--bs-dropdown-divider-margin-y) * 2);
}
body.mobile .note-actions-custom-display-mode {
display: grid;
grid-template-columns: repeat(3, 1fr);
& > .dropdown-item {
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.25em;
padding-inline: 0.25em;
font-size: 0.85em;
border-radius: 0 !important;
border: 0 !important;
}
& > .dropdown-item:first-child {
border-start-start-radius: var(--bs-border-radius) !important;
border-end-start-radius: var(--bs-border-radius) !important;
}
& > .dropdown-item:last-child {
border-start-end-radius: var(--bs-border-radius) !important;
border-end-end-radius: var(--bs-border-radius) !important;
}
}

View File

@@ -14,6 +14,7 @@ import { createImageSrcUrl, isMobile, openInAppHelpFromUrl } from "../../service
import { ViewTypeOptions } from "../collections/interface";
import { buildSaveSqlToNoteHandler } from "../FloatingButtonsDefinitions";
import ActionButton, { ActionButtonProps } from "../react/ActionButton";
import { ButtonGroup } from "../react/Button";
import { FormFileUploadActionButton, FormFileUploadFormListItem, FormFileUploadProps } from "../react/FormFileUpload";
import { FormListItem } from "../react/FormList";
import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
@@ -72,7 +73,7 @@ export default function NoteActionsCustom(props: NoteActionsCustomProps) {
<AddChildButton {...innerProps} />
<RunActiveNoteButton {...innerProps } />
<SwitchSplitOrientationButton {...innerProps} />
<ToggleReadOnlyButton {...innerProps} />
<DisplayModeSwitcher {...innerProps} />
<SaveToNoteButton {...innerProps} />
<RefreshButton {...innerProps} />
<CopyReferenceToClipboardButton {...innerProps} />
@@ -189,28 +190,66 @@ function RefreshButton({ note, noteType, isDefaultViewMode, parentComponent, not
function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: NoteActionsCustomInnerProps) {
const isShown = note.type === "mermaid" && !cachedIsMobile && note.isContentAvailable() && isDefaultViewMode;
const [ displayMode ] = useNoteLabel(note, "displayMode");
const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation");
const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal";
const effectiveMode = displayMode === "source" || displayMode === "split" || displayMode === "preview"
? displayMode
: isReadOnly ? "preview" : "split";
return isShown && <NoteAction
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
onClick={() => setSplitEditorOrientation(upcomingOrientation)}
disabled={isReadOnly}
disabled={effectiveMode !== "split"}
/>;
}
export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
const isEnabled = ([ "mermaid", "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite)
&& note.isContentAvailable() && isDefaultViewMode;
function DisplayModeSwitcher({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) {
const [ displayMode, setDisplayMode ] = useNoteLabel(note, "displayMode");
const isEnabled = (note.isMarkdown() || note.type === "mermaid") && note.isContentAvailable() && isDefaultViewMode;
if (!isEnabled) return null;
return isEnabled && <NoteAction
text={isReadOnly ? t("toggle_read_only_button.unlock-editing") : t("toggle_read_only_button.lock-editing")}
icon={isReadOnly ? "bx bx-lock-open-alt" : "bx bx-lock-alt"}
onClick={() => setReadOnly(!isReadOnly)}
/>;
const mode = displayMode === "source" || displayMode === "preview" ? displayMode : "split";
const buttons: Array<{ value: "source" | "split" | "preview"; icon: string; text: string }> = [
{ value: "source", icon: "bx bx-code", text: t("display_mode.source") },
{ value: "split", icon: "bx bxs-dock-left", text: t("display_mode.split") },
{ value: "preview", icon: "bx bx-show", text: t("display_mode.preview") }
];
if (cachedIsMobile) {
return (
<div className="note-actions-custom-display-mode">
{buttons.map(({ value, icon, text }) => (
<NoteAction
key={value}
icon={icon}
text={text}
active={mode === value}
onClick={() => setDisplayMode(value)}
/>
))}
</div>
);
}
return (
<>
<div className="note-actions-custom-spacer" />
<ButtonGroup size="sm">
{buttons.map(({ value, icon, text }) => (
<NoteAction
key={value}
icon={icon}
text={text}
active={mode === value}
onClick={() => setDisplayMode(value)}
/>
))}
</ButtonGroup>
<div className="note-actions-custom-spacer" />
</>
);
}
function RunActiveNoteButton({ noteMime }: NoteActionsCustomInnerProps) {
@@ -268,12 +307,12 @@ function AddChildButton({ parentComponent, noteType, ntxId, isReadOnly }: NoteAc
}
//#endregion
function NoteAction({ text, ...props }: Pick<ActionButtonProps, "text" | "icon" | "disabled" | "triggerCommand"> & {
function NoteAction({ text, active, ...props }: Pick<ActionButtonProps, "text" | "icon" | "disabled" | "triggerCommand" | "active"> & {
onClick?: ((e: MouseEvent) => void) | undefined;
}) {
return (cachedIsMobile
? <FormListItem {...props}>{text}</FormListItem>
: <ActionButton text={text} {...props} />
: <ActionButton text={text} active={active} {...props} />
);
}

View File

@@ -9,7 +9,8 @@ export default function ScrollPadding() {
const isEnabled = ["text", "code"].includes(note?.type ?? "")
&& viewScope?.viewMode === "default"
&& note?.isContentAvailable()
&& !note?.isTriliumSqlite();
&& !note?.isTriliumSqlite()
&& !note?.isMarkdown();
const refreshHeight = () => {
if (!ref.current) return;

View File

@@ -1,6 +1,6 @@
import "./TableOfContents.css";
import { attributeChangeAffectsHeading, CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
import { attributeChangeAffectsHeading, CKTextEditor, ModelElement, type ModelNode } from "@triliumnext/ckeditor5";
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
@@ -230,7 +230,7 @@ function extractTocFromTextEditor(editor: CKTextEditor) {
// Fallback to plain text if DOM conversion fails
if (!text) {
text = Array.from( item.getChildren() )
.map( c => c.is( '$text' ) ? c.data : '' )
.map( (c: ModelNode) => c.is( '$text' ) ? c.data : '' )
.join( '' );
}

View File

@@ -12,7 +12,7 @@ import { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
import utils from "../../services/utils";
import { useColorScheme, useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
import { useColorScheme, useEditorSpacedUpdate, useEffectiveReadOnly, useSyncedRef, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
import { refToJQuerySelector } from "../react/react_utils";
import { TypeWidgetProps } from "./type_widget";
@@ -46,7 +46,7 @@ function buildMindElixirLangPack(): LangPack {
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const apiRef = useRef<MindElixirInstance>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isReadOnly = useEffectiveReadOnly(note, noteContext);
const spacedUpdate = useEditorSpacedUpdate({

View File

@@ -1,7 +1,7 @@
import { Excalidraw } from "@excalidraw/excalidraw";
import { TypeWidgetProps } from "../type_widget";
import "@excalidraw/excalidraw/index.css";
import { useColorScheme, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { useColorScheme, useEffectiveReadOnly, useTriliumOption } from "../../react/hooks";
import { useCallback, useMemo, useRef } from "preact/hooks";
import { type ExcalidrawImperativeAPI, type AppState } from "@excalidraw/excalidraw/types";
import options from "../../../services/options";
@@ -18,7 +18,7 @@ window.EXCALIDRAW_ASSET_PATH = `${window.location.pathname}/node_modules/@excali
export default function Canvas({ note, noteContext }: TypeWidgetProps) {
const apiRef = useRef<ExcalidrawImperativeAPI>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isReadOnly = useEffectiveReadOnly(note, noteContext);
const colorScheme = useColorScheme();
const [ locale ] = useTriliumOption("locale");
const persistence = useCanvasPersistence(note, noteContext, apiRef, colorScheme, isReadOnly);

View File

@@ -2,6 +2,7 @@ import "./code.css";
import { default as VanillaCodeMirror, getThemeById } from "@triliumnext/codemirror";
import { NoteType } from "@triliumnext/commons";
import { Ref } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import appContext, { CommandListenerData } from "../../../components/app_context";
@@ -31,9 +32,11 @@ export interface EditableCodeProps extends TypeWidgetProps, Omit<CodeEditorProps
/** Invoked after the content of the note has been uploaded to the server, using a spaced update. */
dataSaved?: () => void;
placeholder?: string;
/** Optional external ref to the underlying CodeMirror `EditorView`. Populated once the editor has initialized. */
editorRef?: Ref<VanillaCodeMirror>;
}
export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWidgetProps) {
export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent, editorRef }: TypeWidgetProps & { editorRef?: Ref<VanillaCodeMirror> }) {
const [ content, setContent ] = useState("");
const blob = useNoteBlob(note);
const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth");
@@ -54,6 +57,7 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi
return (
<CodeEditor
ntxId={ntxId} parentComponent={parentComponent}
editorRef={editorRef}
className="note-detail-readonly-code-content"
content={content}
mime={note.mime}
@@ -81,9 +85,14 @@ function formatViewSource(note: FNote, content: string) {
return content;
}
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, noteType = "code", onContentChanged, dataSaved, placeholder, ...editorProps }: EditableCodeProps) {
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, noteType = "code", onContentChanged, dataSaved, placeholder, editorRef: externalEditorRef, ...editorProps }: EditableCodeProps) {
const editorRef = useRef<VanillaCodeMirror>(null);
const containerRef = useRef<HTMLPreElement>(null);
const combinedEditorRef = (view: VanillaCodeMirror | null) => {
editorRef.current = view;
if (typeof externalEditorRef === "function") externalEditorRef(view);
else if (externalEditorRef) externalEditorRef.current = view;
};
const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth");
const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs");
@@ -122,7 +131,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC
<>
<CodeEditor
ntxId={ntxId} parentComponent={parentComponent}
editorRef={editorRef} containerRef={containerRef}
editorRef={combinedEditorRef} containerRef={containerRef}
mime={mime ?? "text/plain"}
className="note-detail-code-editor"
placeholder={placeholder ?? t("editable_code.placeholder")}
@@ -217,11 +226,13 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta
indentSize={editorProps.indentSize ?? (parseInt(codeNoteTabWidth) || 4)}
useTabs={editorProps.useTabs ?? codeNoteIndentWithTabs}
onInitialized={() => {
if (externalContainerRef && containerRef.current) {
externalContainerRef.current = containerRef.current;
if (containerRef.current) {
if (typeof externalContainerRef === "function") externalContainerRef(containerRef.current);
else if (externalContainerRef) externalContainerRef.current = containerRef.current;
}
if (externalEditorRef && codeEditorRef.current) {
externalEditorRef.current = codeEditorRef.current;
if (codeEditorRef.current) {
if (typeof externalEditorRef === "function") externalEditorRef(codeEditorRef.current);
else if (externalEditorRef) externalEditorRef.current = codeEditorRef.current;
}
initialized.current.resolve();
onInitialized?.();

View File

@@ -1,14 +1,14 @@
import { useEffect, useRef } from "preact/hooks";
import { EditorConfig, default as VanillaCodeMirror } from "@triliumnext/codemirror";
import { useSyncedRef } from "../../react/hooks";
import { RefObject } from "preact";
import { Ref } from "preact";
export interface CodeMirrorProps extends Omit<EditorConfig, "parent"> {
content?: string;
mime: string;
className?: string;
editorRef?: RefObject<VanillaCodeMirror>;
containerRef?: RefObject<HTMLPreElement>;
editorRef?: Ref<VanillaCodeMirror>;
containerRef?: Ref<HTMLPreElement>;
onInitialized?: () => void;
}
@@ -25,9 +25,8 @@ export default function CodeMirror({ className, content, mime, editorRef: extern
...extraOpts
});
codeEditorRef.current = codeEditor;
if (externalEditorRef) {
externalEditorRef.current = codeEditor;
}
if (typeof externalEditorRef === "function") externalEditorRef(codeEditor);
else if (externalEditorRef) externalEditorRef.current = codeEditor;
onInitialized?.();
return () => codeEditor.destroy();

View File

@@ -0,0 +1,34 @@
.note-detail-markdown {
.note-detail-code-editor {
margin-top: 0 !important;
.cm-editor .cm-scroller {
padding-block: 0.25em;
}
}
.markdown-preview {
box-sizing: border-box;
height: 100%;
overflow: auto;
padding: 0.5em 1em;
user-select: text;
.mermaid-diagram,
.mermaid-diagram svg {
max-width: 100%;
}
}
.markdown-preview [data-source-line] {
border-left: 2px solid transparent;
padding-left: 0.5em;
margin-left: -0.5em;
}
[data-source-line].markdown-preview-active {
border-left-color: var(--main-text-color);
}
}

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { renderWithSourceLines } from "./Markdown.js";
describe("renderWithSourceLines", () => {
function extractLines(html: string): number[] {
return [ ...html.matchAll(/data-source-line="(\d+)"/g) ].map((m) => parseInt(m[1], 10));
}
it("returns empty string for empty input", () => {
expect(renderWithSourceLines("")).toBe("");
});
it("tags a single block as line 1", () => {
const html = renderWithSourceLines("hello");
expect(extractLines(html)).toEqual([ 1 ]);
expect(html).toContain("hello");
});
it("assigns correct source lines to consecutive blocks separated by blank lines", () => {
const src = [
"# Heading", // line 1
"", // line 2
"A paragraph.", // line 3
"", // line 4
"Another one." // line 5
].join("\n");
expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 3, 5 ]);
});
it("counts multi-line blocks so subsequent blocks get the right line", () => {
const src = [
"```", // 1
"code", // 2
"more code", // 3
"```", // 4
"", // 5
"after" // 6
].join("\n");
expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 6 ]);
});
it("renders standard markdown constructs inside the wrappers", () => {
const html = renderWithSourceLines("## Heading\n\n- item\n");
expect(html).toContain("<h2>Heading</h2>");
expect(html).toContain("<ul>");
expect(html).toContain("<li>item</li>");
});
it("keeps H1 as H1 in the preview (no title-row context to avoid)", () => {
const html = renderWithSourceLines("# Top level");
expect(html).toContain("<h1>Top level</h1>");
});
it("preserves reference-style links across per-block parsing", () => {
const src = [
"[trilium][t]", // 1
"", // 2
"[t]: https://example.com"
].join("\n");
const html = renderWithSourceLines(src);
expect(html).toContain('href="https://example.com"');
});
it("normalizes fenced code languages to CKEditor MIME identifiers for syntax highlighting", () => {
const html = renderWithSourceLines("```javascript\nconst x = 1;\n```");
expect(html).toMatch(/class="language-application-javascript-env-(backend|frontend)"/);
});
it("produces CKEditor admonition markup for GFM callouts", () => {
const html = renderWithSourceLines("> [!NOTE]\n> heads up");
expect(html).toContain('<aside class="admonition note">');
});
it("preserves the `mermaid` fence language so the mermaid rewrite can match it", () => {
const html = renderWithSourceLines("```mermaid\ngraph TD;\nA-->B;\n```");
expect(html).toContain('class="language-mermaid"');
});
it("produces math-tex spans for inline math", () => {
const html = renderWithSourceLines("Energy: $e=mc^2$.");
expect(html).toContain('<span class="math-tex">');
});
it("renders [[wikilinks]] with hash-router hrefs so the preview navigates correctly", () => {
const html = renderWithSourceLines("See [[abc123]] for details.");
expect(html).toContain('class="reference-link"');
expect(html).toContain('href="#root/abc123"');
});
});

View File

@@ -0,0 +1,193 @@
import "./Markdown.css";
import VanillaCodeMirror from "@triliumnext/codemirror";
import { renderToHtml } from "@triliumnext/commons";
import DOMPurify from "dompurify";
import { Marked } from "marked";
import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import SplitEditor from "../helpers/SplitEditor";
import { ReadOnlyTextContent } from "../text/ReadOnlyText";
import { TypeWidgetProps } from "../type_widget";
const marked = new Marked({ breaks: true, gfm: true });
interface MarkdownContextValue {
html: string;
setEditorView: (view: VanillaCodeMirror | null) => void;
setPreviewEl: (el: HTMLDivElement | null) => void;
}
const MarkdownContext = createContext<MarkdownContextValue | null>(null);
function useMarkdownContext() {
const ctx = useContext(MarkdownContext);
if (!ctx) throw new Error("useMarkdownContext must be used within a Markdown component");
return ctx;
}
export default function Markdown(props: TypeWidgetProps) {
const [ content, setContent ] = useState("");
const [ editorView, setEditorView ] = useState<VanillaCodeMirror | null>(null);
const [ previewEl, setPreviewEl ] = useState<HTMLDivElement | null>(null);
const html = useMemo(() => renderWithSourceLines(content), [ content ]);
useSyncedScrolling(editorView, previewEl);
useSyncedHighlight(editorView, previewEl, html);
const ctx = useMemo<MarkdownContextValue>(
() => ({ html, setEditorView, setPreviewEl }),
[ html ]
);
return (
<MarkdownContext.Provider value={ctx}>
<SplitEditor
noteType="code"
{...props}
editorRef={setEditorView}
onContentChanged={setContent}
previewContent={<MarkdownPreview ntxId={props.ntxId} />}
forceOrientation="horizontal"
/>
</MarkdownContext.Provider>
);
}
function MarkdownPreview({ ntxId }: { ntxId: TypeWidgetProps["ntxId"] }) {
const { html, setPreviewEl } = useMarkdownContext();
return (
<ReadOnlyTextContent
html={html}
ntxId={ntxId}
className="markdown-preview"
contentRef={setPreviewEl}
/>
);
}
//#region Synced scrolling
/**
* One-directional (editor → preview) scroll sync. On editor scroll, finds the
* top visible source line via the CodeMirror `EditorView`, then scrolls the
* preview so the block tagged with that line is at the top — interpolating to
* the next block for smoothness.
*/
function useSyncedScrolling(view: VanillaCodeMirror | null, preview: HTMLDivElement | null) {
useEffect(() => {
if (!view || !preview) return;
const scroller = view.scrollDOM;
function onScroll() {
if (!view || !preview) return;
const topLine = view.state.doc.lineAt(view.lineBlockAtHeight(scroller.scrollTop).from).number;
const blocks = preview.querySelectorAll<HTMLElement>("[data-source-line]");
if (!blocks.length) return;
let before: HTMLElement | null = null;
let after: HTMLElement | null = null;
for (const el of blocks) {
const l = parseInt(el.dataset.sourceLine!, 10);
if (l <= topLine) before = el;
else { after = el; break; }
}
if (!before) { preview.scrollTop = 0; return; }
const previewTop = preview.getBoundingClientRect().top - preview.scrollTop;
const beforeOffset = before.getBoundingClientRect().top - previewTop;
const beforeLine = parseInt(before.dataset.sourceLine!, 10);
if (!after) { preview.scrollTop = beforeOffset; return; }
const afterOffset = after.getBoundingClientRect().top - previewTop;
const afterLine = parseInt(after.dataset.sourceLine!, 10);
const ratio = (topLine - beforeLine) / (afterLine - beforeLine);
preview.scrollTop = beforeOffset + (afterOffset - beforeOffset) * ratio;
}
scroller.addEventListener("scroll", onScroll, { passive: true });
return () => scroller.removeEventListener("scroll", onScroll);
}, [ view, preview ]);
}
/**
* Highlights the preview block that corresponds to the editor's active line,
* matching the built-in `cm-activeLine` behavior. Re-runs when the rendered
* HTML changes so newly inserted blocks pick up the current cursor position.
*/
function useSyncedHighlight(view: VanillaCodeMirror | null, preview: HTMLDivElement | null, html: string) {
useEffect(() => {
if (!view || !preview) return;
let current: HTMLElement | null = null;
function update() {
if (!view || !preview) return;
const activeLine = view.state.doc.lineAt(view.state.selection.main.head).number;
const blocks = preview.querySelectorAll<HTMLElement>("[data-source-line]");
let match: HTMLElement | null = null;
for (const el of blocks) {
if (parseInt(el.dataset.sourceLine!, 10) <= activeLine) match = el;
else break;
}
if (match === current) return;
current?.classList.remove("markdown-preview-active");
match?.classList.add("markdown-preview-active");
current = match;
}
update();
const unsubscribe = view.addUpdateListener((v) => {
if (v.selectionSet || v.docChanged) update();
});
return unsubscribe;
}, [ view, preview, html ]);
}
/** Token types the parser emits but which don't produce top-level block HTML. */
const NON_RENDERED_TOKENS = new Set([ "space", "def" ]);
/**
* Render markdown and tag each top-level block with its 1-indexed source line,
* so the preview can be scrolled to match the editor. Uses the shared
* `renderToHtml` pipeline (admonitions, math, tables, etc.) with DOMPurify for
* sanitization, then walks the rendered DOM and pairs each top-level child
* with the matching lexer token's start line. Marked does not emit source
* positions (markedjs/marked#1267) so we count newlines in `raw` ourselves.
*/
export function renderWithSourceLines(src: string): string {
// Compute the start line of each renderable top-level token in source order.
const tokens = marked.lexer(src);
const lines: number[] = [];
let line = 1;
for (const token of tokens) {
const startLine = line;
line += (token.raw.match(/\n/g) ?? []).length;
if (!NON_RENDERED_TOKENS.has(token.type)) lines.push(startLine);
}
const html = renderToHtml(src, "", {
sanitize: (h) => DOMPurify.sanitize(h),
wikiLink: { formatHref: (id) => `#root/${id}` },
demoteH1: false
});
if (!html) return "";
const container = document.createElement("div");
container.innerHTML = html;
const parts: string[] = [];
const children = Array.from(container.children);
for (let i = 0; i < children.length; i++) {
const sourceLine = lines[i] ?? lines[lines.length - 1] ?? 1;
parts.push(`<div data-source-line="${sourceLine}">${children[i].outerHTML}</div>`);
}
return parts.join("");
}
//#endregion

View File

@@ -17,6 +17,10 @@
flex-grow: 1;
min-width: 0;
min-height: 0;
>* {
height: 100%;
}
}
.note-detail-split .note-detail-split-editor .note-detail-code {
@@ -91,11 +95,26 @@ body.desktop .note-detail-split .note-detail-code-editor {
/* Read-only view */
.note-detail-split.split-read-only .note-detail-split-editor-col {
display: none;
}
.note-detail-split.split-read-only .note-detail-split-preview-col {
width: 100%;
height: 100%;
}
/* Source-only view */
.note-detail-split.split-source-only .note-detail-split-preview-col {
display: none;
}
.note-detail-split.split-source-only .note-detail-split-editor-col {
width: 100%;
height: 100%;
}
/* #region SVG */
.note-detail-split.svg-editor .render-container {
height: 100%;

View File

@@ -8,8 +8,8 @@ import { DEFAULT_GUTTER_SIZE } from "../../../services/resizer";
import utils, { isMobile } from "../../../services/utils";
import ActionButton, { ActionButtonProps } from "../../react/ActionButton";
import Admonition from "../../react/Admonition";
import { useNoteBlob, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { EditableCode, EditableCodeProps } from "../code/Code";
import { useEffectiveReadOnly, useNoteBlob, useNoteLabel, useTriliumOption } from "../../react/hooks";
import { EditableCode, EditableCodeProps, ReadOnlyCode } from "../code/Code";
export interface SplitEditorProps extends EditableCodeProps {
className?: string;
@@ -25,38 +25,60 @@ export interface SplitEditorProps extends EditableCodeProps {
/**
* Abstract `TypeWidget` which contains a preview and editor pane, each displayed on half of the available screen.
*
* The active view is driven by the `#displayMode` label (`source`, `split`, `preview`); when unset
* it falls back to the `#readOnly` label (truthy → preview, falsy → split). `#displayMode` always
* wins so an explicit choice is never overridden by `#readOnly`. The editor and preview panes are
* always mounted; switching modes only toggles a CSS class so CodeMirror state, scroll position and
* pending edits survive the change.
*
* Features:
*
* - The two panes are resizeable via a split, on desktop. The split can be optionally customized via {@link buildSplitExtraOptions}.
* - Can display errors to the user via {@link setError}.
* - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
*/
export default function SplitEditor(props: SplitEditorProps) {
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
if (readOnly) {
return <ReadOnlyView {...props} />;
}
return <EditorWithSplit {...props} />;
}
function EditorWithSplit({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, extraContent, ...editorProps }: SplitEditorProps) {
export default function SplitEditor({ note, noteContext, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, extraContent, ...editorProps }: SplitEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const splitEditorOrientation = useSplitOrientation(forceOrientation);
const [ displayMode ] = useNoteLabel(note, "displayMode");
const readOnly = useEffectiveReadOnly(note, noteContext);
const mode = displayMode === "source" || displayMode === "split" || displayMode === "preview"
? displayMode
: readOnly ? "preview" : "split";
const editor = (
// Lazy-mount each pane on first need, then keep it mounted so subsequent switches stay instant.
const editorMounted = useRef(mode !== "preview");
const previewMounted = useRef(mode !== "source");
if (mode !== "preview") editorMounted.current = true;
if (mode !== "source") previewMounted.current = true;
// The editor only feeds content to the preview when it's an `EditableCode`. `ReadOnlyCode`
// doesn't expose `onContentChanged`, and in preview-only mode the editor isn't mounted at all —
// in both cases we read the blob directly so the preview stays populated.
const editorPropagatesContent = editorMounted.current && !readOnly;
const fallbackBlob = useNoteBlob(editorPropagatesContent ? null : note);
const onContentChangedRef = useRef(editorProps.onContentChanged);
useEffect(() => { onContentChangedRef.current = editorProps.onContentChanged; });
useEffect(() => {
if (!editorPropagatesContent && fallbackBlob) {
onContentChangedRef.current?.(fallbackBlob.content ?? "");
}
}, [ fallbackBlob, editorPropagatesContent ]);
const editor = editorMounted.current && (
<div className="note-detail-split-editor-col">
{editorBefore}
<div className="note-detail-split-editor">
<EditableCode
note={note}
lineWrapping={false}
updateInterval={750} debounceUpdate
noBackgroundChange
{...editorProps}
/>
{readOnly
? <ReadOnlyCode note={note} noteContext={noteContext} {...editorProps} />
: <EditableCode
note={note}
noteContext={noteContext}
lineWrapping={false}
updateInterval={750} debounceUpdate
noBackgroundChange
{...editorProps}
/>}
</div>
{error && (
<Admonition type="caution" className="note-detail-error-container">
@@ -67,15 +89,17 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
</div>
);
const preview = <PreviewContainer
const preview = previewMounted.current && <PreviewContainer
error={error}
previewContent={previewContent}
previewButtons={previewButtons}
/>;
useEffect(() => {
if (!utils.isDesktop() || !containerRef.current) return;
const elements = Array.from(containerRef.current?.children) as HTMLElement[];
if (mode !== "split" || !utils.isDesktop() || !containerRef.current) return;
// Only the visible (non-display:none) panes participate in the split.
const elements = (Array.from(containerRef.current.children) as HTMLElement[])
.filter(el => el.offsetParent !== null);
const splitInstance = Split(elements, {
rtl: glob.isRtl,
sizes: [ 50, 50 ],
@@ -85,10 +109,14 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
});
return () => splitInstance.destroy();
}, [ splitEditorOrientation ]);
}, [ splitEditorOrientation, mode ]);
const layoutClass = mode === "source" ? "split-source-only"
: mode === "preview" ? "split-read-only"
: `split-${splitEditorOrientation}`;
return (
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${className ?? ""}`}>
<div ref={containerRef} className={`note-detail-split note-detail-printable ${layoutClass} ${className ?? ""}`}>
{splitEditorOrientation === "horizontal"
? <>{editor}{preview}</>
: <>{preview}{editor}</>}
@@ -96,26 +124,6 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
);
}
function ReadOnlyView({ ...props }: SplitEditorProps) {
const { note, onContentChanged } = props;
const content = useNoteBlob(note);
const onContentChangedRef = useRef(onContentChanged);
useEffect(() => {
onContentChangedRef.current = onContentChanged;
});
useEffect(() => {
onContentChangedRef.current?.(content?.content ?? "");
}, [ content ]);
return (
<div className={`note-detail-split note-detail-printable ${props.className} split-read-only`}>
<PreviewContainer {...props} />
</div>
);
}
function PreviewContainer({ error, previewContent, previewButtons }: {
error?: string | null;
previewContent: ComponentChildren;

View File

@@ -24,12 +24,12 @@ import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks";
import { useColorScheme, useEffectiveReadOnly, useTriliumEvent } from "../../react/hooks";
import { TypeWidgetProps } from "../type_widget";
import usePersistence from "./persistence";
export default function Spreadsheet(props: TypeWidgetProps) {
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
const readOnly = useEffectiveReadOnly(props.note, props.noteContext);
// Use readOnly as key to force full remount (and data reload) when it changes.
return <SpreadsheetEditor key={String(readOnly)} {...props} readOnly={readOnly} />;

View File

@@ -1,24 +1,30 @@
/* h1 should not be used at all since semantically that's a note title */
.note-detail-readonly-text h1 { font-size: 1.8em; }
.note-detail-readonly-text h2 { font-size: 1.6em; }
.note-detail-readonly-text h3 { font-size: 1.4em; }
.note-detail-readonly-text h4 { font-size: 1.2em; }
.note-detail-readonly-text h5 { font-size: 1.1em; }
.note-detail-readonly-text h6 { font-size: 1.0em; }
.note-detail-readonly-text-content {
h1 { font-size: 1.8em; }
h2 { font-size: 1.6em; }
h3 { font-size: 1.4em; }
h4 { font-size: 1.2em; }
h5 { font-size: 1.1em; }
h6 { font-size: 1.0em; }
}
body.heading-style-markdown .note-detail-readonly-text h1::before { content: "#\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h2::before { content: "##\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h3::before { content: "###\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h4:not(.include-note-title)::before { content: "####\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h5::before { content: "#####\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text h6::before { content: "######\2004"; color: var(--muted-text-color); }
body.heading-style-markdown .note-detail-readonly-text-content {
h1::before { content: "#\2004"; color: var(--muted-text-color); }
h2::before { content: "##\2004"; color: var(--muted-text-color); }
h3::before { content: "###\2004"; color: var(--muted-text-color); }
h4:not(.include-note-title)::before { content: "####\2004"; color: var(--muted-text-color); }
h5::before { content: "#####\2004"; color: var(--muted-text-color); }
h6::before { content: "######\2004"; color: var(--muted-text-color); }
}
body.heading-style-underline .note-detail-readonly-text h1 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h2 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h3 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h4:not(.include-note-title) { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h5 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text h6 { border-bottom: 1px solid var(--main-border-color); }
body.heading-style-underline .note-detail-readonly-text-content {
h1 { border-bottom: 1px solid var(--main-border-color); }
h2 { border-bottom: 1px solid var(--main-border-color); }
h3 { border-bottom: 1px solid var(--main-border-color); }
h4:not(.include-note-title) { border-bottom: 1px solid var(--main-border-color); }
h5 { border-bottom: 1px solid var(--main-border-color); }
h6 { border-bottom: 1px solid var(--main-border-color); }
}
.note-detail-readonly-text {
padding-inline-start: 24px;
@@ -65,4 +71,4 @@ body.mobile .note-detail-readonly-text {
.note-detail-readonly-text-content code.copyable-inline-code:hover {
background-color: var(--accented-background-color);
}
}

View File

@@ -5,7 +5,8 @@ import "./ReadOnlyText.css";
import "@triliumnext/ckeditor5";
import clsx from "clsx";
import { useEffect, useMemo, useRef } from "preact/hooks";
import { Ref } from "preact";
import { useEffect, useLayoutEffect, useMemo } from "preact/hooks";
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
@@ -13,7 +14,7 @@ import { applyInlineMermaid, rewriteMermaidDiagramsInContainer } from "../../../
import { getLocaleById } from "../../../services/i18n";
import { renderMathInElement } from "../../../services/math";
import { formatCodeBlocks } from "../../../services/syntax_highlight";
import { useNoteBlob, useNoteLabel, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import { useNoteBlob, useNoteLabel, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import { RawHtmlBlock } from "../../react/RawHtml";
import TouchBar, { TouchBarButton, TouchBarSpacer } from "../../react/TouchBar";
import { TypeWidgetProps } from "../type_widget";
@@ -22,51 +23,14 @@ import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./util
export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetProps) {
const blob = useNoteBlob(note);
const contentRef = useRef<HTMLDivElement>(null);
const [ codeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap");
const [ codeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth");
const { isRtl } = useNoteLanguage(note);
useEffect(() => {
document.body.style.setProperty("--code-block-tab-width", codeBlockTabWidth || "4");
}, [codeBlockTabWidth]);
// Apply necessary transforms.
useEffect(() => {
const container = contentRef.current;
if (!container) return;
appContext.triggerEvent("contentElRefreshed", { ntxId, contentEl: container });
rewriteMermaidDiagramsInContainer(container);
applyInlineMermaid(container);
applyIncludedNotes(container);
applyMath(container);
applyReferenceLinks(container);
formatCodeBlocks($(container));
setupImageOpening(container, true);
}, [ blob ]);
// React to included note changes.
useTriliumEvent("refreshIncludedNote", ({ noteId }) => {
if (!contentRef.current) return;
refreshIncludedNote(contentRef.current, noteId);
});
// Search integration.
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId || !contentRef.current) return;
resolve($(contentRef.current));
});
return (
<>
<RawHtmlBlock
containerRef={contentRef}
className={clsx("note-detail-readonly-text-content ck-content use-tn-links selectable-text", codeBlockWordWrap && "word-wrap")}
tabindex={100}
<ReadOnlyTextContent
html={blob?.content ?? ""}
ntxId={ntxId}
dir={isRtl ? "rtl" : "ltr"}
html={blob?.content}
/>
<TouchBar>
@@ -85,6 +49,77 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro
);
}
interface ReadOnlyTextContentProps {
/** CKEditor-compatible HTML to render. */
html: string;
/** Note context id — enables `contentElRefreshed` / `executeWithContentElement` integrations when provided. */
ntxId?: string | null;
dir?: "ltr" | "rtl";
/** Extra classes appended to the content div. */
className?: string;
/** Optional external ref to the rendered content div (e.g. to drive scroll sync). */
contentRef?: Ref<HTMLDivElement>;
}
/**
* Renders arbitrary CKEditor-style HTML with the same pipeline as {@link ReadOnlyText}:
* mermaid rewriting, inline mermaid, included-note expansion, KaTeX math, reference-link
* titles, code-block syntax highlighting, and image click handling. Transforms re-run
* whenever `html` changes.
*/
export function ReadOnlyTextContent({ html, ntxId, dir, className, contentRef: externalContentRef }: ReadOnlyTextContentProps) {
const contentRef = useSyncedRef(externalContentRef);
const [ codeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap");
const [ codeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth");
useEffect(() => {
document.body.style.setProperty("--code-block-tab-width", codeBlockTabWidth || "4");
}, [codeBlockTabWidth]);
// Apply necessary transforms. Runs in a layout effect so the synchronous
// DOM mutations (mermaid rewrite + cached-SVG repaint, math, etc.) happen
// before the browser paints — prevents a flash of raw `<pre>` content
// during live preview re-renders.
useLayoutEffect(() => {
const container = contentRef.current;
if (!container) return;
if (ntxId) {
appContext.triggerEvent("contentElRefreshed", { ntxId, contentEl: container });
}
rewriteMermaidDiagramsInContainer(container);
applyInlineMermaid(container);
applyIncludedNotes(container);
applyMath(container);
applyReferenceLinks(container);
formatCodeBlocks($(container));
setupImageOpening(container, true);
}, [ html, ntxId, contentRef ]);
// React to included note changes.
useTriliumEvent("refreshIncludedNote", ({ noteId }) => {
if (!contentRef.current) return;
refreshIncludedNote(contentRef.current, noteId);
});
// Search integration.
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
if (!ntxId || eventNtxId !== ntxId || !contentRef.current) return;
resolve($(contentRef.current));
});
return (
<RawHtmlBlock
containerRef={contentRef}
className={clsx("note-detail-readonly-text-content ck-content use-tn-links selectable-text", codeBlockWordWrap && "word-wrap", className)}
tabindex={100}
dir={dir}
html={html}
/>
);
}
function useNoteLanguage(note: FNote) {
const [ language ] = useNoteLabel(note, "language");
const isRtl = useMemo(() => {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,31 @@
<p>Split view is a feature of&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;and&nbsp;
<a
class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6RM1Q7ppFVoj">Markdown</a>&nbsp;notes which displays both the source code on one side
and the preview of the content on the other.</p>
<p><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;also
allow changing between a horizontal or a vertical split, to accommodate
for the various sizes of diagrams.</p>
<h2>Display modes and interaction</h2>
<p>The split comes with three different display modes:</p>
<ul>
<li><em>Split view</em>, in which both the source code is available on one
side and can be edited, and the preview is available on the other side.
<ul>
<li>In this mode, the size of either the source pane or the preview pane can
be adjusted by dragging the small border between them.</li>
</ul>
</li>
<li><em>Source view</em> which shows the source code on the entire screen for
a more focused editing experience.</li>
<li><em>Preview</em> which displays only the rendering of the diagram or text
in full screen, especially useful for read-only notes.</li>
</ul>
<p>These buttons can be found near the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_8YBEPzcpUgxw">Note buttons</a>&nbsp;section
on the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_IjZS7iK5EXtb">New Layout</a>,
or in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_XpOYSgsLkTJy">Floating buttons</a>&nbsp;on
the old layout.</p>
<p>The display node is stored at note level.</p>
<h2>Relation to read-only notes</h2>
<p>If a note is marked as <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/BFs8mudNFgCS/_help_CoFPLs3dRlXc">read-only</a>,
the source view will not be editable. While in preview mode, marking a
note as read-only has no effect since the preview itself is not editable.</p>

View File

@@ -1,4 +1,4 @@
<p><span class="text-big">GNU AFFERO GENERAL PUBLIC LICENSE</span>
<p>GNU AFFERO GENERAL PUBLIC LICENSE
<br>Version 3, 19 November 2007</p>
<p>Copyright (C) 2007 Free Software Foundation, Inc. <a href="https://fsf.org/">https://fsf.org/</a>
</p>
@@ -157,18 +157,16 @@
<li>b) The work must carry prominent notices stating that it is released under
this License and any conditions added under section 7. This requirement
modifies the requirement in section 4 to "keep intact all notices".</li>
<li
>c) You must license the entire work, as a whole, under this License to
<li>c) You must license the entire work, as a whole, under this License to
anyone who comes into possession of a copy. This License will therefore
apply, along with any applicable section 7 additional terms, to the whole
of the work, and all its parts, regardless of how they are packaged. This
License gives no permission to license the work in any other way, but it
does not invalidate such permission if you have separately received it.</li>
<li
>d) If the work has interactive user interfaces, each must display Appropriate
Legal Notices; however, if the Program has interactive interfaces that
do not display Appropriate Legal Notices, your work need not make them
do so.</li>
<li>d) If the work has interactive user interfaces, each must display Appropriate
Legal Notices; however, if the Program has interactive interfaces that
do not display Appropriate Legal Notices, your work need not make them
do so.</li>
</ul>
<p>A compilation of a covered work with other separate and independent works,
which are not by their nature extensions of the covered work, and which
@@ -186,8 +184,7 @@
<li>a) Convey the object code in, or embodied in, a physical product (including
a physical distribution medium), accompanied by the Corresponding Source
fixed on a durable physical medium customarily used for software interchange.</li>
<li
>b) Convey the object code in, or embodied in, a physical product (including
<li>b) Convey the object code in, or embodied in, a physical product (including
a physical distribution medium), accompanied by a written offer, valid
for at least three years and valid for as long as you offer spare parts
or customer support for that product model, to give anyone who possesses
@@ -197,25 +194,25 @@
your reasonable cost of physically performing this conveying of source,
or (2) access to copy the Corresponding Source from a network server at
no charge.</li>
<li>c) Convey individual copies of the object code with a copy of the written
offer to provide the Corresponding Source. This alternative is allowed
only occasionally and noncommercially, and only if you received the object
code with such an offer, in accord with subsection 6b.</li>
<li>d) Convey the object code by offering access from a designated place (gratis
or for a charge), and offer equivalent access to the Corresponding Source
in the same way through the same place at no further charge. You need not
require recipients to copy the Corresponding Source along with the object
code. If the place to copy the object code is a network server, the Corresponding
Source may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain clear
directions next to the object code saying where to find the Corresponding
Source. Regardless of what server hosts the Corresponding Source, you remain
obligated to ensure that it is available for as long as needed to satisfy
these requirements.</li>
<li>e) Convey the object code using peer-to-peer transmission, provided you
inform other peers where the object code and Corresponding Source of the
work are being offered to the general public at no charge under subsection
6d.</li>
<li>c) Convey individual copies of the object code with a copy of the written
offer to provide the Corresponding Source. This alternative is allowed
only occasionally and noncommercially, and only if you received the object
code with such an offer, in accord with subsection 6b.</li>
<li>d) Convey the object code by offering access from a designated place (gratis
or for a charge), and offer equivalent access to the Corresponding Source
in the same way through the same place at no further charge. You need not
require recipients to copy the Corresponding Source along with the object
code. If the place to copy the object code is a network server, the Corresponding
Source may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain clear
directions next to the object code saying where to find the Corresponding
Source. Regardless of what server hosts the Corresponding Source, you remain
obligated to ensure that it is available for as long as needed to satisfy
these requirements.</li>
<li>e) Convey the object code using peer-to-peer transmission, provided you
inform other peers where the object code and Corresponding Source of the
work are being offered to the general public at no charge under subsection
6d.</li>
</ul>
<p>A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be included

View File

@@ -9,7 +9,8 @@
note where to place the new one and select:</p>
<ul>
<li><em>Insert note after</em>, to put the new note underneath the one selected.</li>
<li><em>Insert child note</em>, to insert the note as a child of the selected
<li
><em>Insert child note</em>, to insert the note as a child of the selected
note.</li>
</ul>
<p>
@@ -20,7 +21,8 @@
<li>When adding a <a href="#root/_help_QEAPj01N5f7w">link</a> in a&nbsp;<a class="reference-link"
href="#root/_help_iPIMuisry3hd">Text</a>&nbsp;note, type the desired title of
the new note and press Enter. Afterwards the type of the note will be asked.</li>
<li>Similarly, when creating a new tab, type the desired title and press Enter.</li>
<li
>Similarly, when creating a new tab, type the desired title and press Enter.</li>
</ul>
<h2>Changing the type of a note</h2>
<p>It is possible to change the type of a note after it has been created
@@ -30,94 +32,96 @@
edit the <a href="#root/_help_4FahAwuGTAwC">source of a note</a>.</p>
<h2>Supported note types</h2>
<p>The following note types are supported by Trilium:</p>
<table>
<thead>
<tr>
<th>Note Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><a class="reference-link" href="#root/_help_iPIMuisry3hd">Text</a>
</td>
<td>The default note type, which allows for rich text formatting, images,
admonitions and right-to-left support.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>
</td>
<td>Uses a mono-space font and can be used to store larger chunks of code
or plain text than a text note, and has better syntax highlighting.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>
</td>
<td>Stores the information about a search (the search text, criteria, etc.)
for later use. Can be used for quick filtering of a large amount of notes,
for example. The search can easily be triggered.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>
</td>
<td>Allows easy creation of notes and relations between them. Can be used
for mainly relational data such as a family tree.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_bdUJEHsAPYQR">Note Map</a>
</td>
<td>Displays the relationships between the notes, whether via relations or
their hierarchical structure.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>
</td>
<td>Used in&nbsp;<a class="reference-link" href="#root/_help_CdNpE2pqjmI6">Scripting</a>,
it displays the HTML content of another note. This allows displaying any
kind of content, provided there is a script behind it to generate it.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Collections</a>
</td>
<td>Displays the children of the note either as a grid, a list, or for a more
specialized case: a calendar.&nbsp;
<br>
<br>Generally useful for easy reading of short notes.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>
</td>
<td>Displays diagrams such as bar charts, flow charts, state diagrams, etc.
Requires a bit of technical knowledge since the diagrams are written in
a specialized format.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_grjYqerjn243">Canvas</a>
</td>
<td>Allows easy drawing of sketches, diagrams, handwritten content. Uses the
same technology behind <a href="https://excalidraw.com">excalidraw.com</a>.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_1vHRoWCEjj0L">Web View</a>
</td>
<td>Displays the content of an external web page, similar to a browser.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_gBbsAeiuUxI5">Mind Map</a>
</td>
<td>Easy for brainstorming ideas, by placing them in a hierarchical layout.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>
</td>
<td>Displays the children of the note as a geographical map, one use-case
would be to plan vacations. It even has basic support for tracks. Notes
can also be created from it.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_W8vYD3Q1zjCR">File</a>
</td>
<td>Represents an uploaded file such as PDFs, images, video or audio files.</td>
</tr>
</tbody>
</table>
<figure class="table">
<table>
<thead>
<tr>
<th>Note Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><a class="reference-link" href="#root/_help_iPIMuisry3hd">Text</a>
</td>
<td>The default note type, which allows for rich text formatting, images,
admonitions and right-to-left support.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>
</td>
<td>Uses a mono-space font and can be used to store larger chunks of code
or plain text than a text note, and has better syntax highlighting.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>
</td>
<td>Stores the information about a search (the search text, criteria, etc.)
for later use. Can be used for quick filtering of a large amount of notes,
for example. The search can easily be triggered.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>
</td>
<td>Allows easy creation of notes and relations between them. Can be used
for mainly relational data such as a family tree.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_bdUJEHsAPYQR">Note Map</a>
</td>
<td>Displays the relationships between the notes, whether via relations or
their hierarchical structure.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>
</td>
<td>Used in&nbsp;<a class="reference-link" href="#root/_help_CdNpE2pqjmI6">Scripting</a>,
it displays the HTML content of another note. This allows displaying any
kind of content, provided there is a script behind it to generate it.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Collections</a>
</td>
<td>Displays the children of the note either as a grid, a list, or for a more
specialized case: a calendar.&nbsp;&nbsp;
<br>
<br>Generally useful for easy reading of short notes.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>
</td>
<td>Displays diagrams such as bar charts, flow charts, state diagrams, etc.
Requires a bit of technical knowledge since the diagrams are written in
a specialized format.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_grjYqerjn243">Canvas</a>
</td>
<td>Allows easy drawing of sketches, diagrams, handwritten content. Uses the
same technology behind <a href="https://excalidraw.com">excalidraw.com</a>.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_1vHRoWCEjj0L">Web View</a>
</td>
<td>Displays the content of an external web page, similar to a browser.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_gBbsAeiuUxI5">Mind Map</a>
</td>
<td>Easy for brainstorming ideas, by placing them in a hierarchical layout.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>
</td>
<td>Displays the children of the note as a geographical map, one use-case
would be to plan vacations. It even has basic support for tracks. Notes
can also be created from it.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_W8vYD3Q1zjCR">File</a>
</td>
<td>Represents an uploaded file such as PDFs, images, video or audio files.</td>
</tr>
</tbody>
</table>
</figure>

View File

@@ -5,7 +5,8 @@
create a <em>File</em> note type directly:</p>
<ul>
<li>Drag a file into the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.</li>
<li>Right click a note and select <em>Import into note</em> and point it to
<li
>Right click a note and select <em>Import into note</em> and point it to
one of the supported files.</li>
</ul>
<h2>Supported file types</h2>
@@ -82,28 +83,30 @@
href="#root/_help_BlN9DFI679QC">Ribbon</a>.
<ul>
<li><em>Download</em>, which will download the file for local use.</li>
<li><em>Open</em>, will will open the file with the system-default application.</li>
<li>Upload new revision to replace the file with a new one.</li>
<li
><em>Open</em>, will will open the file with the system-default application.</li>
<li
>Upload new revision to replace the file with a new one.</li>
</ul>
</li>
<li>It is <strong>not</strong> possible to change the note type of a <em>File</em> note.</li>
<li>Convert into an <a href="#root/_help_0vhv7lsOLy82">attachment</a> from the <a href="#root/_help_8YBEPzcpUgxw">note menu</a>.</li>
</li>
<li>It is <strong>not</strong> possible to change the note type of a <em>File</em> note.</li>
<li
>Convert into an <a href="#root/_help_0vhv7lsOLy82">attachment</a> from the <a href="#root/_help_8YBEPzcpUgxw">note menu</a>.</li>
</ul>
<h2>Relation with other notes</h2>
<ul>
<li>
<p>Files are also displayed in the&nbsp;<a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;based
on their type:</p>
<img class="image_resized" style="aspect-ratio:853/315;width:50%;"
src="4_File_image.png" width="853" height="315">
</li>
<li>
<p>Non-image files can be embedded into text notes as read-only widgets via
the&nbsp;<a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;functionality.</p>
</li>
<li>
<p>Image files can be embedded into text notes like normal images via&nbsp;
<a
class="reference-link" href="#root/_help_0Ofbk1aSuVRu">Image references</a>.</p>
<p>
<img class="image_resized" style="aspect-ratio:853/315;width:50%;" src="4_File_image.png"
width="853" height="315">
</p>
</li>
<li>Non-image files can be embedded into text notes as read-only widgets via
the&nbsp;<a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;functionality.</li>
<li
>Image files can be embedded into text notes like normal images via&nbsp;
<a
class="reference-link" href="#root/_help_0Ofbk1aSuVRu">Image references</a>.</li>
</ul>

View File

@@ -0,0 +1,140 @@
<p>Trilium has always supported Markdown through its <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/mHbBMPDPkVV5/_help_Oau6X9rCuegd">import feature</a>,
however the file was either transformed to a&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a>&nbsp;note
(converted to Trilium's internal HTML format) or saved as a&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6f9hih2hXXZk">Code</a>&nbsp;note
with only syntax highlight.</p>
<p>This note type is a split view, meaning that both the source code and
a preview of the document are displayed side-by-side. See&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_SL5f1Auq7sVN">Note types with split view</a>&nbsp;for
more information.</p>
<h2>Rationale</h2>
<p>The goal of this note type is to fill a gap: rendering Markdown but not
altering its structure or its whitespace which would inevitably change
otherwise through import/export.</p>
<p>Even if Markdown is now specially treated by having a preview mechanism,
Trilium remains at its core a WYSWYG editor so Markdown will not replace
text notes.</p>
<aside class="admonition note">
<p>Feature requests regarding the Markdown implementation will be considered,
but if they are outside the realm of Trilium they will not be implemented.
One of the core aspects of the Markdown integration is that it reuses components
that are already available through other features of the application.</p>
</aside>
<h2>Features</h2>
<h3>Source view pane</h3>
<ul>
<li>Syntax highlighting for the Markdown syntax.</li>
<li>Nested syntax highlighting for code inside code blocks.</li>
<li>When editing larger documents, the preview scrolls along with the source
editor.</li>
</ul>
<h3>Preview pane</h3>
<p>The following features are supported by Trilium's Markdown format and
will show up in the preview pane:</p>
<ul>
<li>All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)</li>
<li
>Code blocks with syntax highlight (e.g. <code spellcheck="false">```js</code>)
and automatic syntax highlight</li>
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</li>
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_YfYAtQBcfo5V">Math Equations</a>
</li>
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;using
<code
spellcheck="false">```mermaid</code>
</li>
<li>
<p><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_nBAXQFj20hS1">Include Note</a>&nbsp;(no
builtin Markdown syntax, but HTML syntax works just fine):</p><pre><code class="language-text-x-trilium-auto">&lt;section class="include-note" data-note-id="vJDjQm0VK8Na" data-box-size="expandable"&gt;
&amp;nbsp;
&lt;/section&gt;n</code></pre>
</li>
<li>
<p><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/QEAPj01N5f7w/_help_hrZ1D00cLbal">Internal (reference) links</a>&nbsp;via
its HTML syntax, or through a <em>Wikilinks</em>-like format (only&nbsp;
<a
class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_m1lbrzyKDaRB">Note ID</a>):</p><pre><code class="language-text-x-trilium-auto">[[Hg8TS5ZOxti6]]</code></pre>
</li>
</ul>
<h2>Creating Markdown notes</h2>
<p>There are two ways to create a Markdown note:</p>
<ol>
<li>Create a new note (e.g. in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a>)
and select the type <em>Markdown</em>, just like all the other note types.</li>
<li
>Create a note of type&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6f9hih2hXXZk">Code</a>&nbsp;and
select as the language either <em>Markdown </em>or <em>GitHub-Flavored Markdown</em>.
This maintains compatibility with your existing notes prior to the introduction
of this feature.</li>
</ol>
<aside class="admonition note">
<p>There is no distinction between the new Markdown note type and code notes
of type Markdown; internally both are represented as&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6f9hih2hXXZk">Code</a>&nbsp;notes
with the proper MIME type (e.g. <code spellcheck="false">text/x-markdown</code>).</p>
</aside>
<h2>Import/export</h2>
<ul>
<li>
<p>By default, when importing a single Markdown file it automatically gets
converted to a&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a>&nbsp;note.
To avoid that and have it imported as a Markdown note instead:</p>
<ul>
<li>
<p>Right click the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;and
select <em>Import into note</em>.</p>
</li>
<li>
<p>Select the file normally.</p>
</li>
<li>
<p>Uncheck <em>Import HTML, Markdown and TXT as text notes if it's unclear from the metadata</em>.</p>
</li>
</ul>
</li>
<li>
<p>When exporting Markdown files, the extension is preserved and the content
remains the same as in the source view.</p>
</li>
<li>
<p>Once exported as a Trilium ZIP, the ZIP will preserve the Markdown type
without converting to text notes thanks to the meta-information in it.</p>
</li>
</ul>
<h2>Conversion between text notes and Markdown notes</h2>
<p>Currently there is no built-in functionality to convert a&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a>&nbsp;note
into a Markdown note or vice-versa. We do have plans to address this in
the future.</p>
<p>This can be achieved manually, for a single note:</p>
<ol>
<li>Export the file as Markdown, with single format.</li>
<li>Import the file again, but unchecking <em>Import HTML, Markdown and TXT as text notes if it's unclear from the metadata</em>.</li>
</ol>
<p>For multiple notes, the process is slightly more involved:</p>
<ol>
<li>Export the file as Markdown, ZIP.</li>
<li>Extract the archive.</li>
<li>Remove the <code spellcheck="false">!!!meta.json</code> file.</li>
<li>Compress the extracted files back into an archive.</li>
<li>Import the newly create archive, but unchecking <em>Import HTML, Markdown and TXT as text notes if it's unclear from the metadata</em>.</li>
</ol>
<h2>Sync-scrolling &amp; block highlight</h2>
<p>When scrolling through the editing pane, the preview pane will attempt
to synchronize its position to make it easier to see the preview.</p>
<p>In addition, the block in the preview matching the position of the cursor
in the source view will appear slightly highlighted.</p>
<p>The sync is currently one-way only, scrolling the preview will not synchronize
the position of the editor.</p>
<p>This feature cannot be disabled as of now; if the scrolling feels distracting,
consider temporarily switching to the editor mode and then switching to
preview mode when ready.</p>
<aside class="admonition note">
<p>This feature of synchronizing the scroll is based on blocks but it's provided
on a best-effort basis since our underlying Markdown library doesn't support
this feature natively, so we had to implement our own algorithm. Feel free
to <a href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/_help_wy8So3yZZlH9">report issues</a>,
but always provide a sample Markdown file to be able to reproduce it.</p>
</aside>

View File

@@ -6,11 +6,15 @@
<img style="aspect-ratio:886/663;" src="2_Mermaid Diagrams_image.png"
width="886" height="663">
</figure>
<h2>Types of diagrams</h2>
<p>Trilium supports Mermaid, which adds support for various diagrams such
as flowchart, sequence diagram, class diagram, state diagram, pie charts,
etc., all using a text description of the chart instead of manually drawing
the diagram.</p>
<p>This note type is a split view, meaning that both the source code and
a preview of the document are displayed side-by-side. See&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_SL5f1Auq7sVN">Note types with split view</a>&nbsp;for
more information.</p>
<h2>Sample diagrams</h2>
<p>Starting with v0.103.0, Mermaid diagrams no longer start with a sample
flowchart, but instead a pane at the bottom will show all the supported
diagrams with sample code for each:</p>
@@ -48,30 +52,34 @@
<img src="1_Mermaid Diagrams_image.png">
</li>
<li>The preview can be moved around by holding the left mouse button and dragging.</li>
<li>Zooming can also be done by using the scroll wheel.</li>
<li>The zoom and position on the preview will remain fixed as the diagram
changes, to be able to work more easily with large diagrams.</li>
</ul>
<li
>Zooming can also be done by using the scroll wheel.</li>
<li>The zoom and position on the preview will remain fixed as the diagram
changes, to be able to work more easily with large diagrams.</li>
</ul>
</li>
<li>The size of the source/preview panes can be adjusted by hovering over
the border between them and dragging it with the mouse.</li>
<li>In the&nbsp;<a class="reference-link" href="#root/_help_XpOYSgsLkTJy">Floating buttons</a>&nbsp;area:
<ul>
<li>The source/preview can be laid out left-right or bottom-top via the <em>Move editing pane to the left / bottom</em> option.</li>
<li>Press <em>Lock editing</em> to automatically mark the note as read-only.
<li
>Press <em>Lock editing</em> to automatically mark the note as read-only.
In this mode, the code pane is hidden and the diagram is displayed full-size.
Similarly, press <em>Unlock editing</em> to mark a read-only note as editable.</li>
<li>Press the <em>Copy image reference to the clipboard</em> to be able to insert
the image representation of the diagram into a text note. See&nbsp;<a class="reference-link"
href="#root/_help_0Ofbk1aSuVRu">Image references</a>&nbsp;for more information.</li>
<li>Press the <em>Export diagram as SVG</em> to download a scalable/vector rendering
of the diagram. Can be used to present the diagram without degrading when
zooming.</li>
<li
>Press the <em>Copy image reference to the clipboard</em> to be able to insert
the image representation of the diagram into a text note. See&nbsp;<a class="reference-link"
href="#root/_help_0Ofbk1aSuVRu">Image references</a>&nbsp;for more information.</li>
<li
>Press the <em>Export diagram as SVG</em> to download a scalable/vector rendering
of the diagram. Can be used to present the diagram without degrading when
zooming.</li>
<li>Press the <em>Export diagram as PNG</em> to download a normal image (at
1x scale, raster) of the diagram. Can be used to send the diagram in more
traditional channels such as e-mail.</li>
</ul>
</li>
</ul>
</li>
</ul>
<h2>Errors in the diagram</h2>
<p>If there is an error in the source code, the error will be displayed in

View File

@@ -13,11 +13,13 @@
<ol>
<li>HTML language for the legacy/vanilla method, with what needs to be displayed
(for example <code spellcheck="false">&lt;p&gt;Hello world.&lt;/p&gt;</code>).</li>
<li>JSX for the Preact-based approach (see below).</li>
</ol>
<li
>JSX for the Preact-based approach (see below).</li>
</ol>
</li>
<li>Create a&nbsp;<a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>.</li>
<li>Assign the <code spellcheck="false">renderNote</code> <a href="#root/_help_zEY4DaJG4YT5">relation</a> to
<li
>Assign the <code spellcheck="false">renderNote</code> <a href="#root/_help_zEY4DaJG4YT5">relation</a> to
point at the previously created code note.</li>
</ol>
<h2>Legacy scripting using jQuery</h2>
@@ -46,10 +48,9 @@ $dateEl.text(new Date());</code></pre>
need to provide a HTML anymore.</p>
<p>Here are the steps to creating a simple render note:</p>
<ol>
<li>
<p>Create a note of type&nbsp;<a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>.</p>
</li>
<li>
<li>Create a note of type&nbsp;<a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>.</li>
<li
>
<p>Create a child&nbsp;<a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>&nbsp;note
with JSX as the language.
<br>As an example, use the following content:</p><pre><code class="language-text-x-trilium-auto">export default function() {
@@ -59,24 +60,21 @@ $dateEl.text(new Date());</code></pre>
&lt;/&gt;
);
}</code></pre>
</li>
<li>
<p>In the parent render note, define a <code spellcheck="false">~renderNote</code> relation
pointing to the newly created child.</p>
</li>
<li>
<p>Refresh the render note and it should display a “Hello world” message.</p>
</li>
</li>
<li>In the parent render note, define a <code spellcheck="false">~renderNote</code> relation
pointing to the newly created child.</li>
<li>Refresh the render note and it should display a “Hello world” message.</li>
</ol>
<h2>Refreshing the note</h2>
<p>It's possible to refresh the note via:</p>
<ul>
<li>The corresponding button in&nbsp;<a class="reference-link" href="#root/_help_XpOYSgsLkTJy">Floating buttons</a>.</li>
<li>The “Render active note” <a href="#root/_help_A9Oc6YKKc65v">keyboard shortcut</a> (not
<li
>The “Render active note” <a href="#root/_help_A9Oc6YKKc65v">keyboard shortcut</a> (not
assigned by default).</li>
</ul>
<h2>Examples</h2>
<ul>
<li><a class="reference-link" href="#root/_help_R7abl2fc6Mxi">Weight Tracker</a>&nbsp;which
is present in the&nbsp;<a class="reference-link" href="#root/_help_6tZeKvSHEUiB">Demo Notes</a>.</li>
<li><a class="reference-link" href="#root/_help_R7abl2fc6Mxi">[missing note]</a>&nbsp;which
is present in the&nbsp;<a class="reference-link" href="#root/_help_6tZeKvSHEUiB">[missing note]</a>.</li>
</ul>

View File

@@ -64,15 +64,15 @@
yet:</p>
<ul>
<li>Trilium-specific formulas (e.g. to obtain the title of a note).</li>
<li>User-defined formulas</li>
<li>Cross-workbook calculation</li>
<li
>User-defined formulas</li>
<li>Cross-workbook calculation</li>
</ul>
<p>If you would like us to work on these features, consider <a href="https://triliumnotes.org/en/support-us">supporting us</a>.</p>
<h2>Known limitations</h2>
<ul>
<li>
<p>It is possible to share a spreadsheet, case in which a best-effort HTML
rendering of the spreadsheet is done.</p>
<li>It is possible to share a spreadsheet, case in which a best-effort HTML
rendering of the spreadsheet is done.
<ul>
<li>For more advanced use cases, this will most likely not work as intended.
Feel free to <a href="#root/_help_wy8So3yZZlH9">report issues</a>, but keep in
@@ -80,12 +80,9 @@
the features of Univer.</li>
</ul>
</li>
<li>
<p>There is currently no export functionality, as stated previously.</p>
</li>
<li>
<p>There is no dedicated mobile support. Mobile support is currently experimental
in Univer and when it becomes stable, we could potentially integrate it
into Trilium as well.</p>
</li>
<li>There is currently no export functionality, as stated previously.</li>
<li
>There is no dedicated mobile support. Mobile support is currently experimental
in Univer and when it becomes stable, we could potentially integrate it
into Trilium as well.</li>
</ul>

View File

@@ -20,168 +20,171 @@
<p>Fore more information see&nbsp;<a class="reference-link" href="#root/_help_nRhnJkTT8cPs">Formatting toolbar</a>.</p>
<h2>Features and formatting</h2>
<p>Here's a list of various features supported by text notes:</p>
<table>
<thead>
<tr>
<th>Dedicated article</th>
<th>Feature</th>
</tr>
</thead>
<tbody>
<tr>
<td><a class="reference-link" href="#root/_help_Gr6xFaF6ioJ5">General formatting</a>
</td>
<td>
<ul>
<li>Headings (section titles, paragraph)</li>
<li>Font size</li>
<li>Bold, italic, underline, strike-through</li>
<li>Superscript, subscript</li>
<li>Font color &amp; background color</li>
<li>Remove formatting</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_S6Xx8QIWTV66">Lists</a>
</td>
<td>
<ul>
<li>Bulleted lists</li>
<li>Numbered lists</li>
<li>To-do lists</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</td>
<td>
<ul>
<li>Block quotes</li>
<li>Admonitions</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_NdowYOC1GFKS">Tables</a>
</td>
<td>
<ul>
<li>Basic tables</li>
<li>Merging cells</li>
<li>Styling tables and cells.</li>
<li>Table captions</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_UYuUB1ZekNQU">Developer-specific formatting</a>
</td>
<td>
<ul>
<li>Inline code</li>
<li>Code blocks</li>
<li>Keyboard shortcuts</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_AgjCISero73a">Footnotes</a>
</td>
<td>
<ul>
<li>Footnotes</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_mT0HEkOsz6i1">Images</a>
</td>
<td>
<ul>
<li>Images</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_QEAPj01N5f7w">Links</a>
</td>
<td>
<ul>
<li>External links</li>
<li>Internal Trilium links</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>
</td>
<td>
<ul>
<li>Include note</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_CohkqWQC1iBv">Insert buttons</a>
</td>
<td>
<ul>
<li>Symbols</li>
<li><a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>
</li>
<li>Mermaid diagrams</li>
<li>Horizontal ruler</li>
<li>Page break</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_dEHYtoWWi8ct">Other features</a>
</td>
<td>
<ul>
<li>Indentation
<ul>
<li>Markdown import</li>
</ul>
</li>
<li><a class="reference-link" href="#root/_help_2x0ZAX9ePtzV">Cut to subnote</a>
</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_gLt3vA97tMcp">Premium features</a>
</td>
<td>
<ul>
<li><a class="reference-link" href="#root/_help_ZlN4nump6EbW">Slash Commands</a>
</li>
<li><a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>
</li>
<li><a class="reference-link" href="#root/_help_5wZallV2Qo1t">Format Painter</a>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
<h2>Read-Only vs. Editing Mode</h2>
<p>Text notes are usually opened in edit mode. However, they may open in
read-only mode if the note is too big or the note is explicitly marked
as read-only. For more information, see&nbsp;<a class="reference-link"
href="#root/_help_CoFPLs3dRlXc">Read-Only Notes</a>.</p>
<h2>Keyboard shortcuts</h2>
<p>There are numerous keyboard shortcuts to format the text without having
to use the mouse. For a reference of all the key combinations, see&nbsp;
<a
class="reference-link" href="#root/_help_A9Oc6YKKc65v">Keyboard Shortcuts</a>. In addition, see&nbsp;<a class="reference-link"
href="#root/_help_QrtTYPmdd1qq">Markdown-like formatting</a>&nbsp;as an alternative
to the keyboard shortcuts.</p>
<h2>Technical details</h2>
<p>For the text editing functionality, Trilium uses a commercial product
(with an open-source base) called&nbsp;<a class="reference-link" href="#root/_help_MI26XDLSAlCD">CKEditor</a>.
This brings the benefit of having a powerful WYSIWYG (What You See Is What
You Get) editor.</p>
<figure
class="table">
<table>
<thead>
<tr>
<th>Dedicated article</th>
<th>Feature</th>
</tr>
</thead>
<tbody>
<tr>
<td><a class="reference-link" href="#root/_help_Gr6xFaF6ioJ5">General formatting</a>
</td>
<td>
<ul>
<li>Headings (section titles, paragraph)</li>
<li>Font size</li>
<li>Bold, italic, underline, strike-through</li>
<li>Superscript, subscript</li>
<li>Font color &amp; background color</li>
<li>Remove formatting</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_S6Xx8QIWTV66">Lists</a>
</td>
<td>
<ul>
<li>Bulleted lists</li>
<li>Numbered lists</li>
<li>To-do lists</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</td>
<td>
<ul>
<li>Block quotes</li>
<li>Admonitions</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_NdowYOC1GFKS">Tables</a>
</td>
<td>
<ul>
<li>Basic tables</li>
<li>Merging cells</li>
<li>Styling tables and cells.</li>
<li>Table captions</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_UYuUB1ZekNQU">Developer-specific formatting</a>
</td>
<td>
<ul>
<li>Inline code</li>
<li>Code blocks</li>
<li>Keyboard shortcuts</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_AgjCISero73a">Footnotes</a>
</td>
<td>
<ul>
<li>Footnotes</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_mT0HEkOsz6i1">Images</a>
</td>
<td>
<ul>
<li>Images</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_QEAPj01N5f7w">Links</a>
</td>
<td>
<ul>
<li>External links</li>
<li>Internal Trilium links</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>
</td>
<td>
<ul>
<li>Include note</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_CohkqWQC1iBv">Insert buttons</a>
</td>
<td>
<ul>
<li>Symbols</li>
<li><a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>
</li>
<li>Mermaid diagrams</li>
<li>Horizontal ruler</li>
<li>Page break</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_dEHYtoWWi8ct">Other features</a>
</td>
<td>
<ul>
<li>Indentation
<ul>
<li>Markdown import</li>
</ul>
</li>
<li><a class="reference-link" href="#root/_help_2x0ZAX9ePtzV">Cut to subnote</a>
</li>
</ul>
</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_gLt3vA97tMcp">Premium features</a>
</td>
<td>
<ul>
<li><a class="reference-link" href="#root/_help_ZlN4nump6EbW">Slash Commands</a>
</li>
<li><a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>
</li>
<li><a class="reference-link" href="#root/_help_5wZallV2Qo1t">Format Painter</a>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
</figure>
<h2>Read-Only vs. Editing Mode</h2>
<p>Text notes are usually opened in edit mode. However, they may open in
read-only mode if the note is too big or the note is explicitly marked
as read-only. For more information, see&nbsp;<a class="reference-link"
href="#root/_help_CoFPLs3dRlXc">Read-Only Notes</a>.</p>
<h2>Keyboard shortcuts</h2>
<p>There are numerous keyboard shortcuts to format the text without having
to use the mouse. For a reference of all the key combinations, see&nbsp;
<a
class="reference-link" href="#root/_help_A9Oc6YKKc65v">Keyboard Shortcuts</a>. In addition, see&nbsp;<a class="reference-link"
href="#root/_help_QrtTYPmdd1qq">Markdown-like formatting</a>&nbsp;as an alternative
to the keyboard shortcuts.</p>
<h2>Technical details</h2>
<p>For the text editing functionality, Trilium uses a commercial product
(with an open-source base) called&nbsp;<a class="reference-link" href="#root/_help_MI26XDLSAlCD">CKEditor</a>.
This brings the benefit of having a powerful WYSIWYG (What You See Is What
You Get) editor.</p>

View File

@@ -1,16 +1,10 @@
import { ADMONITION_TYPE_MAPPINGS } from "@triliumnext/commons";
import { gfm } from "@triliumnext/turndown-plugin-gfm";
import Turnish, { type Rule } from "turnish";
let instance: Turnish | null = null;
// TODO: Move this to a dedicated file someday.
export const ADMONITION_TYPE_MAPPINGS: Record<string, string> = {
note: "NOTE",
tip: "TIP",
important: "IMPORTANT",
caution: "CAUTION",
warning: "WARNING"
};
export { ADMONITION_TYPE_MAPPINGS };
export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPE_MAPPINGS.note;

View File

@@ -14,4 +14,16 @@ describe("Note type mappings", () => {
mime: "text/vnd.mermaid"
});
});
it("exports markdown code notes with a .md extension", () => {
// `mime-types` doesn't recognize Trilium's custom `text/x-markdown`;
// without the explicit fallback this was exporting as `.code`.
for (const mime of [ "text/x-markdown", "text/markdown", "text/x-gfm" ]) {
const note = buildNote({ type: "code", mime, title: "Doc" });
expect(mapByNoteType(note, "# hi", "markdown")).toMatchObject({
extension: "md",
mime
});
}
});
});

View File

@@ -34,6 +34,21 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
taskContext.taskSucceeded(null);
}
/**
* Extension fallback for MIME types the `mime-types` package doesn't recognize —
* mostly Trilium's `text/x-` custom MIMEs.
*/
export function mapCodeMimeToExtension(mime: string): string | null {
switch (mime) {
case "text/x-markdown":
case "text/markdown":
case "text/x-gfm":
return "md";
default:
return null;
}
}
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) {
let payload, extension, mime;
@@ -60,7 +75,11 @@ export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferL
}
} else if (note.type === "code") {
payload = content;
extension = mimeTypes.extension(note.mime) || "code";
// Our own map wins over the `mime-types` lookup so markdown MIMEs get
// the conventional `.md` rather than `.mkd` (what the lib returns for
// `text/x-markdown`) or `.code` (what the fallback produced when the
// lib didn't recognize `text/markdown` at all).
extension = mapCodeMimeToExtension(note.mime) || mimeTypes.extension(note.mime) || "code";
mime = note.mime;
} else if (note.type === "canvas") {
payload = content;

View File

@@ -5,6 +5,7 @@ import mimeTypes from "mime-types";
import type BBranch from "../../../becca/entities/bbranch.js";
import type BNote from "../../../becca/entities/bnote.js";
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
import { mapCodeMimeToExtension } from "../single.js";
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
@@ -84,7 +85,7 @@ export abstract class ZipExportProvider {
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
return "txt";
}
return mimeTypes.extension(mime) || "dat";
return mapCodeMimeToExtension(mime) || mimeTypes.extension(mime) || "dat";
}

View File

@@ -1,233 +1,11 @@
import { renderToHtml as renderToHtmlShared } from "@triliumnext/commons";
import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO, normalizeMimeTypeForCKEditor, transclusionExtension, wikiLinkExtension } from "@triliumnext/commons";
import { parse, Renderer, type Tokens, use } from "marked";
import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js";
import htmlSanitizer from "../html_sanitizer.js";
import utils from "../utils.js";
import importUtils from "./utils.js";
const escape = utils.escapeHtml;
/**
* Keep renderer code up to date with https://github.com/markedjs/marked/blob/master/src/Renderer.ts.
*/
class CustomMarkdownRenderer extends Renderer {
override heading(data: Tokens.Heading): string {
// Treat h1 as raw text.
if (data.depth === 1) {
return `<h1>${data.text}</h1>`;
}
return super.heading(data).trimEnd();
}
override paragraph(data: Tokens.Paragraph): string {
return super.paragraph(data).trimEnd();
}
override code({ text, lang }: Tokens.Code): string {
if (!text) {
return "";
}
// Escape the HTML.
text = escape(text);
// Unescape &quot
text = text.replace(/&quot;/g, '"');
const ckEditorLanguage = getNormalizedMimeFromMarkdownLanguage(lang);
return `<pre><code class="language-${ckEditorLanguage}">${text}</code></pre>`;
}
override list(token: Tokens.List): string {
let result = super.list(token)
.replace("\n", "") // we replace the first one only.
.trimEnd();
// Handle todo-list in the CKEditor format.
if (token.items.some(item => item.task)) {
result = result.replace(/^<ul>/, "<ul class=\"todo-list\">");
}
return result;
}
override checkbox({ checked }: Tokens.Checkbox): string {
return `<input type="checkbox"${
checked ? 'checked="checked" ' : ''
}disabled="disabled">`;
}
override listitem(item: Tokens.ListItem): string {
// Handle todo-list in the CKEditor format.
if (item.task) {
let itemBody = '';
const checkbox = this.checkbox({ checked: !!item.checked, raw: "- [ ]", type: "checkbox" });
if (item.loose) {
if (item.tokens[0]?.type === 'paragraph') {
item.tokens[0].text = checkbox + item.tokens[0].text;
if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') {
item.tokens[0].tokens[0].text = checkbox + escape(item.tokens[0].tokens[0].text);
item.tokens[0].tokens[0].escaped = true;
}
} else {
item.tokens.unshift({
type: 'text',
raw: checkbox,
text: checkbox,
escaped: true,
});
}
} else {
itemBody += checkbox;
}
itemBody += `<span class="todo-list__label__description">${this.parser.parse(item.tokens.filter(t => t.type !== "checkbox"))}</span>`;
return `<li><label class="todo-list__label">${itemBody}</label></li>`;
}
return super.listitem(item).trimEnd();
}
override image(token: Tokens.Image): string {
return super.image(token)
.replace(` alt=""`, "");
}
override blockquote({ tokens }: Tokens.Blockquote): string {
const body = renderer.parser.parse(tokens);
const admonitionMatch = /^<p>\[\!([A-Z]+)\]/.exec(body);
if (Array.isArray(admonitionMatch) && admonitionMatch.length === 2) {
const type = admonitionMatch[1].toLowerCase();
if (ADMONITION_TYPE_MAPPINGS[type]) {
const bodyWithoutHeader = body
.replace(/^<p>\[\!([A-Z]+)\]\s*/, "<p>")
.replace(/^<p><\/p>/, ""); // Having a heading will generate an empty paragraph that we need to remove.
return `<aside class="admonition ${type}">${bodyWithoutHeader.trim()}</aside>`;
}
}
return `<blockquote>${body}</blockquote>`;
}
codespan({ text }: Tokens.Codespan): string {
return `<code spellcheck="false">${escape(text)}</code>`;
}
function renderToHtml(content: string, title: string): string {
return renderToHtmlShared(content, title, { sanitize: htmlSanitizer.sanitize });
}
function renderToHtml(content: string, title: string) {
// Double-escape slashes in math expression because they are otherwise consumed by the parser somewhere.
content = content.replaceAll("\\$", "\\\\$");
// Extract formulas and replace them with placeholders to prevent interference from Markdown rendering
const { processedText, placeholderMap: formulaMap } = extractFormulas(content);
use({
// Order is important, especially for wikilinks.
extensions: [
transclusionExtension,
wikiLinkExtension
]
});
let html = parse(processedText, {
async: false,
renderer
}) as string;
// After rendering, replace placeholders back with the formula HTML
html = restoreFromMap(html, formulaMap);
// h1 handling needs to come before sanitization
html = importUtils.handleH1(html, title);
html = htmlSanitizer.sanitize(html);
// Add a trailing semicolon to CSS styles.
html = html.replaceAll(/(<(img|figure|col).*?style=".*?)"/g, "$1;\"");
// Remove slash for self-closing tags to match CKEditor's approach.
html = html.replace(/<(\w+)([^>]*)\s+\/>/g, "<$1$2>");
// Normalize non-breaking spaces to entity.
html = html.replaceAll("\u00a0", "&nbsp;");
return html;
}
function getNormalizedMimeFromMarkdownLanguage(language: string | undefined) {
if (language) {
const mimeDefinition = getMimeTypeFromMarkdownName(language);
if (mimeDefinition) {
return normalizeMimeTypeForCKEditor(mimeDefinition.mime);
}
}
return MIME_TYPE_AUTO;
}
function extractCodeBlocks(text: string): { processedText: string, placeholderMap: Map<string, string> } {
const codeMap = new Map<string, string>();
let id = 0;
const timestamp = Date.now();
// Multi-line code block and Inline code
text = text.replace(/```[\s\S]*?```/g, (m) => {
const key = `<!--CODE_BLOCK_${timestamp}_${id++}-->`;
codeMap.set(key, m);
return key;
}).replace(/`[^`\n]+`/g, (m) => {
const key = `<!--INLINE_CODE_${timestamp}_${id++}-->`;
codeMap.set(key, m);
return key;
});
return { processedText: text, placeholderMap: codeMap };
}
function extractFormulas(text: string): { processedText: string, placeholderMap: Map<string, string> } {
// Protect the $ signs inside code blocks from being recognized as formulas.
const { processedText: noCodeText, placeholderMap: codeMap } = extractCodeBlocks(text);
const formulaMap = new Map<string, string>();
let id = 0;
const timestamp = Date.now();
// Display math and Inline math
let processedText = noCodeText.replace(/(?<!\\)\$\$((?:(?!\n{2,})[\s\S])+?)\$\$/g, (_, formula) => {
const key = `<!--FORMULA_BLOCK_${timestamp}_${id++}-->`;
const rendered = `<span class="math-tex">\\[${formula}\\]</span>`;
formulaMap.set(key, rendered);
return key;
}).replace(/(?<!\\)\$(.+?)\$/g, (_, formula) => {
const key = `<!--FORMULA_INLINE_${timestamp}_${id++}-->`;
const rendered = `<span class="math-tex">\\(${formula}\\)</span>`;
formulaMap.set(key, rendered);
return key;
});
processedText = restoreFromMap(processedText, codeMap);
return { processedText, placeholderMap: formulaMap };
}
function restoreFromMap(text: string, map: Map<string, string>): string {
if (map.size === 0) return text;
const pattern = [...map.keys()]
.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|');
return text.replace(new RegExp(pattern, 'g'), match => map.get(match) ?? match);
}
const renderer = new CustomMarkdownRenderer({ async: false });
export default {
renderToHtml
};

View File

@@ -123,6 +123,16 @@ describe("#getType", () => {
[{textImportedAsText: false}, "text/x-markdown"], "file"
],
[
"w/ codeImportedAsCode: true and 'text/markdown' mime type (override) it should return 'code'",
[{codeImportedAsCode: true}, "text/markdown"], "code"
],
[
"w/ codeImportedAsCode: true and 'application/javascript' mime type (override) it should return 'code'",
[{codeImportedAsCode: true}, "application/javascript"], "code"
],
[
"w/ textImportedAsText: false and 'text/html' mime type it should return 'file'",
[{textImportedAsText: false}, "text/html"], "file"

View File

@@ -97,7 +97,7 @@ function getType(options: TaskData<"importNotes">, mime: string): NoteType {
case options?.textImportedAsText && ["text/html", "text/markdown", "text/x-markdown", "text/mdx"].includes(mimeLc):
return "text";
case options?.codeImportedAsCode && CODE_MIME_TYPES.has(mimeLc):
case options?.codeImportedAsCode && (CODE_MIME_TYPES.has(mimeLc) || CODE_MIME_TYPES_OVERRIDE.has(mimeLc)):
return "code";
case mime.startsWith("image/"):

View File

@@ -175,8 +175,12 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
}
function detectFileTypeAndMime(taskContext: TaskContext<"importNotes">, filePath: string) {
const mime = mimeService.getMime(filePath) || "application/octet-stream";
const type = mimeService.getType(taskContext.data || {}, mime);
const rawMime = mimeService.getMime(filePath) || "application/octet-stream";
const type = mimeService.getType(taskContext.data || {}, rawMime);
// Normalize aliased code MIMEs (e.g. `text/markdown` → `text/x-markdown`,
// `application/javascript` → `application/javascript;env=frontend`) so the
// stored MIME matches what the rest of the app expects.
const mime = (type === "code" && mimeService.normalizeMimeType(rawMime)) || rawMime;
return { mime, type };
}

View File

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

View File

@@ -3962,6 +3962,83 @@
"attachments": []
}
]
},
{
"isClone": false,
"noteId": "SL5f1Auq7sVN",
"notePath": [
"pOsGYCXsbNQG",
"gh7bpGYxajRS",
"Vc8PjrjAGuOp",
"SL5f1Auq7sVN"
],
"title": "Note types with split view",
"notePosition": 230,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "s1aBHPd79XYj",
"isInheritable": false,
"position": 30
},
{
"type": "relation",
"name": "internalLink",
"value": "6RM1Q7ppFVoj",
"isInheritable": false,
"position": 40
},
{
"type": "relation",
"name": "internalLink",
"value": "CoFPLs3dRlXc",
"isInheritable": false,
"position": 50
},
{
"type": "relation",
"name": "internalLink",
"value": "8YBEPzcpUgxw",
"isInheritable": false,
"position": 60
},
{
"type": "relation",
"name": "internalLink",
"value": "IjZS7iK5EXtb",
"isInheritable": false,
"position": 70
},
{
"type": "relation",
"name": "internalLink",
"value": "XpOYSgsLkTJy",
"isInheritable": false,
"position": 80
},
{
"type": "label",
"name": "iconClass",
"value": "bx bx-card",
"isInheritable": false,
"position": 90
},
{
"type": "label",
"name": "shareAlias",
"value": "note-types-with-split-view",
"isInheritable": false,
"position": 100
}
],
"format": "markdown",
"dataFileName": "Note types with split view.md",
"attachments": []
}
]
},
@@ -10094,6 +10171,13 @@
"value": "bx bx-selection",
"isInheritable": false,
"position": 20
},
{
"type": "relation",
"name": "internalLink",
"value": "SL5f1Auq7sVN",
"isInheritable": false,
"position": 40
}
],
"format": "markdown",
@@ -10739,6 +10823,124 @@
"dataFileName": "Spreadsheets_image.png"
}
]
},
{
"isClone": false,
"noteId": "6RM1Q7ppFVoj",
"notePath": [
"pOsGYCXsbNQG",
"KSZ04uQ2D1St",
"6RM1Q7ppFVoj"
],
"title": "Markdown",
"notePosition": 230,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "label",
"name": "iconClass",
"value": "bx bxl-markdown",
"isInheritable": false,
"position": 30
},
{
"type": "label",
"name": "shareAlias",
"value": "markdown",
"isInheritable": false,
"position": 40
},
{
"type": "relation",
"name": "internalLink",
"value": "Oau6X9rCuegd",
"isInheritable": false,
"position": 50
},
{
"type": "relation",
"name": "internalLink",
"value": "iPIMuisry3hd",
"isInheritable": false,
"position": 60
},
{
"type": "relation",
"name": "internalLink",
"value": "6f9hih2hXXZk",
"isInheritable": false,
"position": 70
},
{
"type": "relation",
"name": "internalLink",
"value": "oPVyFC7WL2Lp",
"isInheritable": false,
"position": 80
},
{
"type": "relation",
"name": "internalLink",
"value": "wy8So3yZZlH9",
"isInheritable": false,
"position": 150
},
{
"type": "relation",
"name": "internalLink",
"value": "SL5f1Auq7sVN",
"isInheritable": false,
"position": 160
},
{
"type": "relation",
"name": "internalLink",
"value": "NwBbFdNZ9h7O",
"isInheritable": false,
"position": 170
},
{
"type": "relation",
"name": "internalLink",
"value": "YfYAtQBcfo5V",
"isInheritable": false,
"position": 180
},
{
"type": "relation",
"name": "internalLink",
"value": "s1aBHPd79XYj",
"isInheritable": false,
"position": 190
},
{
"type": "relation",
"name": "internalLink",
"value": "nBAXQFj20hS1",
"isInheritable": false,
"position": 200
},
{
"type": "relation",
"name": "internalLink",
"value": "hrZ1D00cLbal",
"isInheritable": false,
"position": 210
},
{
"type": "relation",
"name": "internalLink",
"value": "m1lbrzyKDaRB",
"isInheritable": false,
"position": 220
}
],
"format": "markdown",
"dataFileName": "Markdown.md",
"attachments": []
}
]
},

View File

@@ -0,0 +1,21 @@
# Note types with split view
Split view is a feature of <a class="reference-link" href="../../Note%20Types/Mermaid%20Diagrams.md">Mermaid Diagrams</a> and <a class="reference-link" href="../../Note%20Types/Markdown.md">Markdown</a> notes which displays both the source code on one side and the preview of the content on the other.
<a class="reference-link" href="../../Note%20Types/Mermaid%20Diagrams.md">Mermaid Diagrams</a> also allow changing between a horizontal or a vertical split, to accommodate for the various sizes of diagrams.
## Display modes and interaction
The split comes with three different display modes:
* _Split view_, in which both the source code is available on one side and can be edited, and the preview is available on the other side.
* In this mode, the size of either the source pane or the preview pane can be adjusted by dragging the small border between them.
* _Source view_ which shows the source code on the entire screen for a more focused editing experience.
* _Preview_ which displays only the rendering of the diagram or text in full screen, especially useful for read-only notes.
These buttons can be found near the <a class="reference-link" href="Note%20buttons.md">Note buttons</a> section on the <a class="reference-link" href="New%20Layout.md">New Layout</a>, or in the <a class="reference-link" href="Floating%20buttons.md">Floating buttons</a> on the old layout.
The display node is stored at note level.
## Relation to read-only notes
If a note is marked as [read-only](../Notes/Read-Only%20Notes.md), the source view will not be editable. While in preview mode, marking a note as read-only has no effect since the preview itself is not editable.

View File

@@ -33,7 +33,7 @@ The following note types are supported by Trilium:
| <a class="reference-link" href="Note%20Types/Relation%20Map.md">Relation Map</a> | Allows easy creation of notes and relations between them. Can be used for mainly relational data such as a family tree. |
| <a class="reference-link" href="Note%20Types/Note%20Map.md">Note Map</a> | Displays the relationships between the notes, whether via relations or their hierarchical structure. |
| <a class="reference-link" href="Note%20Types/Render%20Note.md">Render Note</a> | Used in <a class="reference-link" href="Scripting.md">Scripting</a>, it displays the HTML content of another note. This allows displaying any kind of content, provided there is a script behind it to generate it. |
| <a class="reference-link" href="Collections.md">Collections</a> | Displays the children of the note either as a grid, a list, or for a more specialized case: a calendar.  <br> <br>Generally useful for easy reading of short notes. |
| <a class="reference-link" href="Collections.md">Collections</a> | Displays the children of the note either as a grid, a list, or for a more specialized case: a calendar.   <br> <br>Generally useful for easy reading of short notes. |
| <a class="reference-link" href="Note%20Types/Mermaid%20Diagrams.md">Mermaid Diagrams</a> | Displays diagrams such as bar charts, flow charts, state diagrams, etc. Requires a bit of technical knowledge since the diagrams are written in a specialized format. |
| <a class="reference-link" href="Note%20Types/Canvas.md">Canvas</a> | Allows easy drawing of sketches, diagrams, handwritten content. Uses the same technology behind [excalidraw.com](https://excalidraw.com). |
| <a class="reference-link" href="Note%20Types/Web%20View.md">Web View</a> | Displays the content of an external web page, similar to a browser. |

View File

@@ -0,0 +1,93 @@
# Markdown
Trilium has always supported Markdown through its [import feature](../Basic%20Concepts%20and%20Features/Import%20%26%20Export/Markdown.md), however the file was either transformed to a <a class="reference-link" href="Text.md">Text</a> note (converted to Trilium's internal HTML format) or saved as a <a class="reference-link" href="Code.md">Code</a> note with only syntax highlight.
This note type is a split view, meaning that both the source code and a preview of the document are displayed side-by-side. See <a class="reference-link" href="../Basic%20Concepts%20and%20Features/UI%20Elements/Note%20types%20with%20split%20view.md">Note types with split view</a> for more information.
## Rationale
The goal of this note type is to fill a gap: rendering Markdown but not altering its structure or its whitespace which would inevitably change otherwise through import/export.
Even if Markdown is now specially treated by having a preview mechanism, Trilium remains at its core a WYSWYG editor so Markdown will not replace text notes.
> [!NOTE]
> Feature requests regarding the Markdown implementation will be considered, but if they are outside the realm of Trilium they will not be implemented. One of the core aspects of the Markdown integration is that it reuses components that are already available through other features of the application.
## Features
### Source view pane
* Syntax highlighting for the Markdown syntax.
* Nested syntax highlighting for code inside code blocks.
* When editing larger documents, the preview scrolls along with the source editor.
### Preview pane
The following features are supported by Trilium's Markdown format and will show up in the preview pane:
* All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)
* Code blocks with syntax highlight (e.g. ` ```js `) and automatic syntax highlight
* <a class="reference-link" href="Text/Block%20quotes%20%26%20admonitions.md">Block quotes &amp; admonitions</a>
* <a class="reference-link" href="Text/Math%20Equations.md">Math Equations</a>
* <a class="reference-link" href="Mermaid%20Diagrams.md">Mermaid Diagrams</a> using ` ```mermaid `
* <a class="reference-link" href="Text/Include%20Note.md">Include Note</a> (no builtin Markdown syntax, but HTML syntax works just fine):
```
<section class="include-note" data-note-id="vJDjQm0VK8Na" data-box-size="expandable">
&nbsp;
</section>n
```
* <a class="reference-link" href="Text/Links/Internal%20(reference)%20links.md">Internal (reference) links</a> via its HTML syntax, or through a _Wikilinks_\-like format (only <a class="reference-link" href="../Advanced%20Usage/Note%20ID.md">Note ID</a>):
```
[[Hg8TS5ZOxti6]]
```
## Creating Markdown notes
There are two ways to create a Markdown note:
1. Create a new note (e.g. in the <a class="reference-link" href="../Basic%20Concepts%20and%20Features/UI%20Elements/Note%20Tree.md">Note Tree</a>) and select the type _Markdown_, just like all the other note types.
2. Create a note of type <a class="reference-link" href="Code.md">Code</a> and select as the language either _Markdown_ or _GitHub-Flavored Markdown_. This maintains compatibility with your existing notes prior to the introduction of this feature.
> [!NOTE]
> There is no distinction between the new Markdown note type and code notes of type Markdown; internally both are represented as <a class="reference-link" href="Code.md">Code</a> notes with the proper MIME type (e.g. `text/x-markdown`).
## Import/export
* By default, when importing a single Markdown file it automatically gets converted to a <a class="reference-link" href="Text.md">Text</a> note. To avoid that and have it imported as a Markdown note instead:
* Right click the <a class="reference-link" href="../Basic%20Concepts%20and%20Features/UI%20Elements/Note%20Tree.md">Note Tree</a> and select _Import into note_.
* Select the file normally.
* Uncheck _Import HTML, Markdown and TXT as text notes if it's unclear from the metadata_.
* When exporting Markdown files, the extension is preserved and the content remains the same as in the source view.
* Once exported as a Trilium ZIP, the ZIP will preserve the Markdown type without converting to text notes thanks to the meta-information in it.
## Conversion between text notes and Markdown notes
Currently there is no built-in functionality to convert a <a class="reference-link" href="Text.md">Text</a> note into a Markdown note or vice-versa. We do have plans to address this in the future.
This can be achieved manually, for a single note:
1. Export the file as Markdown, with single format.
2. Import the file again, but unchecking _Import HTML, Markdown and TXT as text notes if it's unclear from the metadata_.
For multiple notes, the process is slightly more involved:
1. Export the file as Markdown, ZIP.
2. Extract the archive.
3. Remove the `!!!meta.json` file.
4. Compress the extracted files back into an archive.
5. Import the newly create archive, but unchecking _Import HTML, Markdown and TXT as text notes if it's unclear from the metadata_.
## Sync-scrolling & block highlight
When scrolling through the editing pane, the preview pane will attempt to synchronize its position to make it easier to see the preview.
In addition, the block in the preview matching the position of the cursor in the source view will appear slightly highlighted.
The sync is currently one-way only, scrolling the preview will not synchronize the position of the editor.
This feature cannot be disabled as of now; if the scrolling feels distracting, consider temporarily switching to the editor mode and then switching to preview mode when ready.
> [!NOTE]
> This feature of synchronizing the scroll is based on blocks but it's provided on a best-effort basis since our underlying Markdown library doesn't support this feature natively, so we had to implement our own algorithm. Feel free to [report issues](../Troubleshooting/Reporting%20issues.md), but always provide a sample Markdown file to be able to reproduce it.

View File

@@ -4,10 +4,12 @@
<figure class="image image-style-align-center"><img style="aspect-ratio:886/663;" src="2_Mermaid Diagrams_image.png" width="886" height="663"></figure>
## Types of diagrams
Trilium supports Mermaid, which adds support for various diagrams such as flowchart, sequence diagram, class diagram, state diagram, pie charts, etc., all using a text description of the chart instead of manually drawing the diagram.
This note type is a split view, meaning that both the source code and a preview of the document are displayed side-by-side. See <a class="reference-link" href="../Basic%20Concepts%20and%20Features/UI%20Elements/Note%20types%20with%20split%20view.md">Note types with split view</a> for more information.
## Sample diagrams
Starting with v0.103.0, Mermaid diagrams no longer start with a sample flowchart, but instead a pane at the bottom will show all the supported diagrams with sample code for each:
* Simply click on any of the samples to apply it.

View File

@@ -69,4 +69,4 @@ It's possible to refresh the note via:
## Examples
* <a class="reference-link" href="../Advanced%20Usage/Advanced%20Showcases/Weight%20Tracker.md">Weight Tracker</a> which is present in the <a class="reference-link" href="../Advanced%20Usage/Database/Demo%20Notes.md">Demo Notes</a>.
* <a class="reference-link" href="../Advanced%20Usage/Advanced%20Showcases/Weight%20Tracker.md">[missing note]</a> which is present in the <a class="reference-link" href="../Advanced%20Usage/Database/Demo%20Notes.md">[missing note]</a>.

View File

@@ -60,7 +60,6 @@ If you would like us to work on these features, consider [supporting us](https:/
## Known limitations
* It is possible to share a spreadsheet, case in which a best-effort HTML rendering of the spreadsheet is done.
* For more advanced use cases, this will most likely not work as intended. Feel free to [report issues](../Troubleshooting/Reporting%20issues.md), but keep in mind that we might not be able to have a complete feature parity with all the features of Univer.
* There is currently no export functionality, as stated previously.
* There is no dedicated mobile support. Mobile support is currently experimental in Univer and when it becomes stable, we could potentially integrate it into Trilium as well.

View File

@@ -8,7 +8,7 @@
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
"emitDeclarationOnly": true,
"forceConsistentCasingInFileNames": true,
"lib": ["DOM", "ES2020"],
"lib": ["DOM", "ES2023"],
"types": [
"vite/client",
"jquery"

View File

@@ -12,6 +12,7 @@
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/language-data": "6.5.1",
"@codemirror/lang-php": "6.0.2",
"@codemirror/lang-vue": "0.1.3",
"@codemirror/lang-xml": "6.1.0",

View File

@@ -104,16 +104,14 @@ export default class CodeMirror extends EditorView {
];
}
extensions.push(EditorView.updateListener.of((v) => this.#onDocumentUpdated(v)));
if (!config.readOnly) {
// Logic specific to editable notes
if (config.placeholder) {
extensions.push(placeholder(config.placeholder));
}
if (config.onContentChanged) {
extensions.push(EditorView.updateListener.of((v) => this.#onDocumentUpdated(v)));
}
extensions.push(historyCompartment.of(history()));
} else {
// Logic specific to read-only notes
@@ -142,6 +140,21 @@ export default class CodeMirror extends EditorView {
if (v.docChanged) {
this.config.onContentChanged?.();
}
for (const listener of this.#updateListeners) listener(v);
}
#updateListeners: Array<(v: ViewUpdate) => void> = [];
/**
* Subscribe to view updates (doc changes, selection changes, viewport changes, etc.).
* Returns an unsubscribe function. The listener will not fire after the view is destroyed.
*/
addUpdateListener(listener: (v: ViewUpdate) => void): () => void {
this.#updateListeners.push(listener);
return () => {
const i = this.#updateListeners.indexOf(listener);
if (i >= 0) this.#updateListeners.splice(i, 1);
};
}
getText() {

View File

@@ -101,8 +101,10 @@ const byMimeType: Record<SupportedMimeTypes, (() => Promise<StreamParser<unknown
"text/x-gdscript": async () => (await import('./languages/gdscript.js')).gdscript,
"text/x-gfm": async () => {
const { markdown, markdownLanguage } = (await import('@codemirror/lang-markdown'));
const { languages } = (await import('@codemirror/language-data'));
return markdown({
base: markdownLanguage
base: markdownLanguage,
codeLanguages: languages
});
},
"text/x-go": async () => (await import('@codemirror/legacy-modes/mode/go')).go,
@@ -124,7 +126,11 @@ const byMimeType: Record<SupportedMimeTypes, (() => Promise<StreamParser<unknown
"text/x-livescript": async () => (await import('@codemirror/legacy-modes/mode/livescript')).liveScript,
"text/x-lua": async () => (await import('@codemirror/legacy-modes/mode/lua')).lua,
"text/x-mariadb": async () => (await import('@codemirror/legacy-modes/mode/sql')).sqlite,
"text/x-markdown": async () => ((await import('@codemirror/lang-markdown')).markdown()),
"text/x-markdown": async () => {
const { markdown } = (await import('@codemirror/lang-markdown'));
const { languages } = (await import('@codemirror/language-data'));
return markdown({ codeLanguages: languages });
},
"text/x-mathematica": async () => (await import('@codemirror/legacy-modes/mode/mathematica')).mathematica,
"text/x-modelica": async () => (await import('@codemirror/legacy-modes/mode/modelica')).modelica,
"text/x-mscgen": async () => (await import('@codemirror/legacy-modes/mode/mscgen')).mscgen,

View File

@@ -18,3 +18,4 @@ export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js";
export * from "./lib/spreadsheet/render_to_html.js";
export * from "./lib/llm_api.js";
export * from "./lib/marked_extensions.js";
export * from "./lib/markdown_renderer.js";

View File

@@ -70,6 +70,7 @@ type Labels = {
webViewSrc: string;
"disabled:webViewSrc": string;
readOnly: boolean;
displayMode: string;
tabWidth: number;
indentWithTabs: boolean;
wrapLines: boolean;

View File

@@ -0,0 +1,307 @@
import { Marked, Renderer, type Tokens } from "marked";
import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO, normalizeMimeTypeForCKEditor } from "./mime_type.js";
import {
createTransclusionExtension,
createWikiLinkExtension,
transclusionExtension,
type TransclusionOptions,
wikiLinkExtension,
type WikiLinkOptions
} from "./marked_extensions.js";
/**
* Mapping from markdown admonition keywords (case-insensitive) to the ids
* used in the rendered `<aside class="admonition …">` markup. Same set as
* GitHub's supported admonition callouts.
*/
export const ADMONITION_TYPE_MAPPINGS: Record<string, string> = {
note: "NOTE",
tip: "TIP",
important: "IMPORTANT",
caution: "CAUTION",
warning: "WARNING"
};
/** Options for {@link renderToHtml}. */
export interface RenderToHtmlOptions {
/**
* HTML sanitizer. Required — each environment plugs in its own:
* - server: `sanitize-html` configured with per-option allowed tags
* - client: `DOMPurify.sanitize`
*/
sanitize: (dirtyHtml: string) => string;
/**
* How `[[noteId]]` wiki-links should be rendered. Defaults to the
* server-side format (`href="/noteId"`), which is what imports want.
* Browser callers that navigate via the hash router should pass
* `{ formatHref: (id) => `#root/${id}` }`.
*/
wikiLink?: WikiLinkOptions;
/** Same as {@link wikiLink}, for `![[noteId]]` transclusions. */
transclusion?: TransclusionOptions;
/**
* If `true` (default), strip the first `<h1>` that matches {@link title}
* and demote any remaining `<h1>` to `<h2>` — notes render the title as a
* separate H1 above the content, so double-H1 would otherwise result.
* Set to `false` when there's no surrounding title (e.g. a live editor
* preview) so authored H1s are shown as-is.
*/
demoteH1?: boolean;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const NAMED_ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: "\u00a0"
};
function unescapeHtml(str: string): string {
return str.replace(/&(#\d+|#x[0-9a-fA-F]+|\w+);/g, (match, entity: string) => {
if (entity.startsWith("#x") || entity.startsWith("#X")) {
return String.fromCodePoint(parseInt(entity.slice(2), 16));
}
if (entity.startsWith("#")) {
return String.fromCodePoint(parseInt(entity.slice(1), 10));
}
return NAMED_ENTITIES[entity] ?? match;
});
}
function getNormalizedMimeFromMarkdownLanguage(language: string | undefined): string {
if (language) {
const mimeDefinition = getMimeTypeFromMarkdownName(language);
if (mimeDefinition) {
return normalizeMimeTypeForCKEditor(mimeDefinition.mime);
}
}
return MIME_TYPE_AUTO;
}
function handleH1(content: string, title: string): string {
let isFirstH1Handled = false;
return content.replace(/<h1[^>]*>([^<]*)<\/h1>/gi, (match, text: string) => {
text = unescapeHtml(text);
const convertedContent = `<h2>${text}</h2>`;
if (!isFirstH1Handled) {
isFirstH1Handled = true;
return title.trim() === text.trim() ? "" : convertedContent;
}
return convertedContent;
});
}
function extractCodeBlocks(text: string): { processedText: string; placeholderMap: Map<string, string> } {
const codeMap = new Map<string, string>();
let id = 0;
const timestamp = Date.now();
text = text
.replace(/```[\s\S]*?```/g, (m) => {
const key = `<!--CODE_BLOCK_${timestamp}_${id++}-->`;
codeMap.set(key, m);
return key;
})
.replace(/`[^`\n]+`/g, (m) => {
const key = `<!--INLINE_CODE_${timestamp}_${id++}-->`;
codeMap.set(key, m);
return key;
});
return { processedText: text, placeholderMap: codeMap };
}
function extractFormulas(text: string): { processedText: string; placeholderMap: Map<string, string> } {
const { processedText: noCodeText, placeholderMap: codeMap } = extractCodeBlocks(text);
const formulaMap = new Map<string, string>();
let id = 0;
const timestamp = Date.now();
let processedText = noCodeText
.replace(/(?<!\\)\$\$((?:(?!\n{2,})[\s\S])+?)\$\$/g, (_, formula: string) => {
const key = `<!--FORMULA_BLOCK_${timestamp}_${id++}-->`;
formulaMap.set(key, `<span class="math-tex">\\[${formula}\\]</span>`);
return key;
})
.replace(/(?<!\\)\$(.+?)\$/g, (_, formula: string) => {
const key = `<!--FORMULA_INLINE_${timestamp}_${id++}-->`;
formulaMap.set(key, `<span class="math-tex">\\(${formula}\\)</span>`);
return key;
});
processedText = restoreFromMap(processedText, codeMap);
return { processedText, placeholderMap: formulaMap };
}
function restoreFromMap(text: string, map: Map<string, string>): string {
if (map.size === 0) return text;
const pattern = [ ...map.keys() ]
.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|");
return text.replace(new RegExp(pattern, "g"), (match) => map.get(match) ?? match);
}
/**
* Keep renderer code up to date with https://github.com/markedjs/marked/blob/master/src/Renderer.ts.
*/
class CustomMarkdownRenderer extends Renderer {
override heading(data: Tokens.Heading): string {
if (data.depth === 1) {
return `<h1>${data.text}</h1>`;
}
return super.heading(data).trimEnd();
}
override paragraph(data: Tokens.Paragraph): string {
return super.paragraph(data).trimEnd();
}
override code({ text, lang }: Tokens.Code): string {
if (!text) return "";
text = escapeHtml(text).replace(/&quot;/g, '"');
// `mermaid` isn't in the MIME dictionary, but CKEditor/Trilium's
// mermaid rewrite specifically looks for `language-mermaid`, so
// preserve the fence language verbatim instead of falling back to auto.
const ckEditorLanguage = lang === "mermaid" ? "mermaid" : getNormalizedMimeFromMarkdownLanguage(lang);
return `<pre><code class="language-${ckEditorLanguage}">${text}</code></pre>`;
}
override list(token: Tokens.List): string {
let result = super.list(token)
.replace("\n", "")
.trimEnd();
if (token.items.some((item) => item.task)) {
result = result.replace(/^<ul>/, '<ul class="todo-list">');
}
return result;
}
override checkbox({ checked }: Tokens.Checkbox): string {
return `<input type="checkbox"${
checked ? 'checked="checked" ' : ""
}disabled="disabled">`;
}
override listitem(item: Tokens.ListItem): string {
if (item.task) {
let itemBody = "";
const checkbox = this.checkbox({ checked: !!item.checked, raw: "- [ ]", type: "checkbox" });
if (item.loose) {
if (item.tokens[0]?.type === "paragraph") {
item.tokens[0].text = checkbox + item.tokens[0].text;
if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === "text") {
item.tokens[0].tokens[0].text = checkbox + escapeHtml(item.tokens[0].tokens[0].text);
item.tokens[0].tokens[0].escaped = true;
}
} else {
item.tokens.unshift({
type: "text",
raw: checkbox,
text: checkbox,
escaped: true
});
}
} else {
itemBody += checkbox;
}
itemBody += `<span class="todo-list__label__description">${this.parser.parse(item.tokens.filter((t) => t.type !== "checkbox"))}</span>`;
return `<li><label class="todo-list__label">${itemBody}</label></li>`;
}
return super.listitem(item).trimEnd();
}
override image(token: Tokens.Image): string {
return super.image(token).replace(` alt=""`, "");
}
override blockquote({ tokens }: Tokens.Blockquote): string {
const body = this.parser.parse(tokens);
const admonitionMatch = /^<p>\[\!([A-Z]+)\]/.exec(body);
if (Array.isArray(admonitionMatch) && admonitionMatch.length === 2) {
const type = admonitionMatch[1].toLowerCase();
if (ADMONITION_TYPE_MAPPINGS[type]) {
const bodyWithoutHeader = body
.replace(/^<p>\[\!([A-Z]+)\]\s*/, "<p>")
.replace(/^<p><\/p>/, "");
return `<aside class="admonition ${type}">${bodyWithoutHeader.trim()}</aside>`;
}
}
return `<blockquote>${body}</blockquote>`;
}
override codespan({ text }: Tokens.Codespan): string {
return `<code spellcheck="false">${escapeHtml(text)}</code>`;
}
}
/**
* Render markdown to CKEditor-compatible HTML. Produces the same output the
* server-side `/api/other/render-markdown` endpoint emits, but sanitization is
* delegated to the caller so this works in both Node and the browser.
*/
export function renderToHtml(content: string, title: string, options: RenderToHtmlOptions): string {
// Double-escape slashes in math expressions — otherwise the parser consumes them.
content = content.replaceAll("\\$", "\\\\$");
const { processedText, placeholderMap: formulaMap } = extractFormulas(content);
const marked = new Marked({ async: false });
marked.use({
// Order is important, especially for wikilinks.
extensions: [
options.transclusion ? createTransclusionExtension(options.transclusion) : transclusionExtension,
options.wikiLink ? createWikiLinkExtension(options.wikiLink) : wikiLinkExtension
]
});
const renderer = new CustomMarkdownRenderer({ async: false });
let html = marked.parse(processedText, { async: false, renderer }) as string;
html = restoreFromMap(html, formulaMap);
// h1 handling needs to come before sanitization.
if (options.demoteH1 !== false) {
html = handleH1(html, title);
}
html = options.sanitize(html);
// Add a trailing semicolon to CSS styles.
html = html.replaceAll(/(<(img|figure|col).*?style=".*?)"/g, '$1;"');
// Remove slash for self-closing tags to match CKEditor's approach.
html = html.replace(/<(\w+)([^>]*)\s+\/>/g, "<$1$2>");
// Normalize non-breaking spaces to entity.
html = html.replaceAll("\u00a0", "&nbsp;");
return html;
}

223
pnpm-lock.yaml generated
View File

@@ -1226,6 +1226,9 @@ importers:
'@codemirror/lang-xml':
specifier: 6.1.0
version: 6.1.0
'@codemirror/language-data':
specifier: 6.5.1
version: 6.5.1
'@codemirror/legacy-modes':
specifier: 6.5.2
version: 6.5.2
@@ -2008,18 +2011,36 @@ packages:
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-angular@0.1.4':
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
'@codemirror/lang-cpp@6.0.3':
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-go@6.0.1':
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-java@6.0.2':
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
'@codemirror/lang-javascript@6.2.5':
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-less@6.0.2':
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
'@codemirror/lang-liquid@6.3.2':
resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
'@codemirror/lang-markdown@6.3.2':
resolution: {integrity: sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA==}
@@ -2029,12 +2050,33 @@ packages:
'@codemirror/lang-php@6.0.2':
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
'@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/lang-rust@6.0.2':
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
'@codemirror/lang-sass@6.0.2':
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
'@codemirror/lang-vue@0.1.3':
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
'@codemirror/lang-wast@6.0.2':
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
'@codemirror/lang-xml@6.1.0':
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
'@codemirror/lang-yaml@6.1.3':
resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
'@codemirror/language-data@6.5.1':
resolution: {integrity: sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w==}
'@codemirror/language@6.12.3':
resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==}
@@ -3627,15 +3669,24 @@ packages:
'@lezer/common@1.5.2':
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
'@lezer/cpp@1.1.5':
resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==}
'@lezer/css@1.1.11':
resolution: {integrity: sha512-FuAnusbLBl1SEAtfN8NdShxYJiESKw9LAFysfea1T96jD3ydBn12oYjaSG1a04BQRIUd93/0D8e5CV1cUMkmQg==}
'@lezer/go@1.0.1':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.12':
resolution: {integrity: sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==}
'@lezer/java@1.1.3':
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
'@lezer/javascript@1.5.1':
resolution: {integrity: sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==}
@@ -3651,9 +3702,21 @@ packages:
'@lezer/php@1.0.2':
resolution: {integrity: sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==}
'@lezer/python@1.1.18':
resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
'@lezer/rust@1.0.2':
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
'@lezer/sass@1.1.0':
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
'@lezer/xml@1.0.6':
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@listr2/prompt-adapter-inquirer@2.0.22':
resolution: {integrity: sha512-hV36ZoY+xKL6pYOt1nPNnkciFkn89KZwqLhAFzJvYysAvL5uBQdiADZx/8bIDXIukzzwG0QlPYolgMzQUtKgpQ==}
engines: {node: '>=18.0.0'}
@@ -15453,6 +15516,20 @@ snapshots:
'@codemirror/view': 6.41.0
'@lezer/common': 1.5.2
'@codemirror/lang-angular@0.1.4':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.3
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@codemirror/lang-cpp@6.0.3':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/cpp': 1.1.5
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.18.6
@@ -15461,6 +15538,14 @@ snapshots:
'@lezer/common': 1.5.2
'@lezer/css': 1.1.11
'@codemirror/lang-go@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/go': 1.0.1
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.18.6
@@ -15473,6 +15558,11 @@ snapshots:
'@lezer/css': 1.1.11
'@lezer/html': 1.3.12
'@codemirror/lang-java@6.0.2':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/java': 1.1.3
'@codemirror/lang-javascript@6.2.5':
dependencies:
'@codemirror/autocomplete': 6.18.6
@@ -15488,6 +15578,25 @@ snapshots:
'@codemirror/language': 6.12.3
'@lezer/json': 1.0.3
'@codemirror/lang-less@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.3
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@codemirror/lang-liquid@6.3.2':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.41.0
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@codemirror/lang-markdown@6.3.2':
dependencies:
'@codemirror/autocomplete': 6.18.6
@@ -15516,6 +15625,36 @@ snapshots:
'@lezer/common': 1.5.2
'@lezer/php': 1.0.2
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/python': 1.1.18
'@codemirror/lang-rust@6.0.2':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/rust': 1.0.2
'@codemirror/lang-sass@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/sass': 1.1.0
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@codemirror/lang-vue@0.1.3':
dependencies:
'@codemirror/lang-html': 6.4.11
@@ -15525,6 +15664,13 @@ snapshots:
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@codemirror/lang-wast@6.0.2':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@codemirror/lang-xml@6.1.0':
dependencies:
'@codemirror/autocomplete': 6.18.6
@@ -15534,6 +15680,41 @@ snapshots:
'@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
'@codemirror/lang-yaml@6.1.3':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/yaml': 1.0.4
'@codemirror/language-data@6.5.1':
dependencies:
'@codemirror/lang-angular': 0.1.4
'@codemirror/lang-cpp': 6.0.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-go': 6.0.1
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-java': 6.0.2
'@codemirror/lang-javascript': 6.2.5
'@codemirror/lang-json': 6.0.2
'@codemirror/lang-less': 6.0.2
'@codemirror/lang-liquid': 6.3.2
'@codemirror/lang-markdown': 6.5.0
'@codemirror/lang-php': 6.0.2
'@codemirror/lang-python': 6.2.1
'@codemirror/lang-rust': 6.0.2
'@codemirror/lang-sass': 6.0.2
'@codemirror/lang-sql': 6.10.0
'@codemirror/lang-vue': 0.1.3
'@codemirror/lang-wast': 6.0.2
'@codemirror/lang-xml': 6.1.0
'@codemirror/lang-yaml': 6.1.3
'@codemirror/language': 6.12.3
'@codemirror/legacy-modes': 6.5.2
'@codemirror/language@6.12.3':
dependencies:
'@codemirror/state': 6.6.0
@@ -17280,12 +17461,24 @@ snapshots:
'@lezer/common@1.5.2': {}
'@lezer/cpp@1.1.5':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/css@1.1.11':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/go@1.0.1':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.2
@@ -17296,6 +17489,12 @@ snapshots:
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/java@1.1.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/javascript@1.5.1':
dependencies:
'@lezer/common': 1.5.2
@@ -17323,12 +17522,36 @@ snapshots:
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/python@1.1.18':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/rust@1.0.2':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/sass@1.1.0':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/xml@1.0.6':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@lezer/yaml@1.0.4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.2
'@listr2/prompt-adapter-inquirer@2.0.22(@inquirer/prompts@6.0.1)':
dependencies:
'@inquirer/prompts': 6.0.1