chore(react/type_widget): basic SVG rendering

This commit is contained in:
Elian Doran 2025-09-20 13:27:58 +03:00
parent c49b90d33f
commit 8caaa99415
No known key found for this signature in database
7 changed files with 87 additions and 45 deletions

View File

@ -1,6 +1,34 @@
import { useCallback } from "preact/hooks";
import SvgSplitEditor from "./helpers/SvgSplitEditor"; import SvgSplitEditor from "./helpers/SvgSplitEditor";
import { TypeWidgetProps } from "./type_widget"; import { TypeWidgetProps } from "./type_widget";
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid";
let idCounter = 1;
let registeredErrorReporter = false;
export default function Mermaid(props: TypeWidgetProps) { export default function Mermaid(props: TypeWidgetProps) {
return <SvgSplitEditor {...props} />; const renderSvg = useCallback(async (content: string) => {
const mermaid = (await import("mermaid")).default;
await loadElkIfNeeded(mermaid, content);
if (!registeredErrorReporter) {
// (await import("./linters/mermaid.js")).default();
registeredErrorReporter = true;
}
mermaid.initialize({
startOnLoad: false,
...(getMermaidConfig() as any),
});
idCounter++;
const { svg } = await mermaid.render(`mermaid-graph-${idCounter}`, content);
return postprocessMermaidSvg(svg);
}, []);
return (
<SvgSplitEditor
renderSvg={renderSvg}
{...props}
/>
);
} }

View File

@ -12,6 +12,14 @@ import keyboard_actions from "../../../services/keyboard_actions";
import { refToJQuerySelector } from "../../react/react_utils"; import { refToJQuerySelector } from "../../react/react_utils";
import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants"; import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants";
export interface EditableCodeProps extends TypeWidgetProps {
// if true, the update will be debounced to prevent excessive updates. Especially useful if the editor is linked to a live preview.
debounceUpdate?: boolean;
lineWrapping?: boolean;
updateInterval?: number;
onContentChanged?: (content: string) => void;
}
export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWidgetProps) { export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWidgetProps) {
const [ content, setContent ] = useState(""); const [ content, setContent ] = useState("");
const blob = useNoteBlob(note); const blob = useNoteBlob(note);
@ -35,12 +43,7 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi
) )
} }
export function EditableCode({ note, ntxId, debounceUpdate, parentComponent, updateInterval, ...editorProps }: TypeWidgetProps & { export function EditableCode({ note, ntxId, debounceUpdate, parentComponent, updateInterval, onContentChanged, ...editorProps }: EditableCodeProps) {
// if true, the update will be debounced to prevent excessive updates. Especially useful if the editor is linked to a live preview.
debounceUpdate?: boolean;
lineWrapping?: boolean;
updateInterval?: number;
}) {
const editorRef = useRef<VanillaCodeMirror>(null); const editorRef = useRef<VanillaCodeMirror>(null);
const containerRef = useRef<HTMLPreElement>(null); const containerRef = useRef<HTMLPreElement>(null);
const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled"); const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
@ -78,6 +81,9 @@ export function EditableCode({ note, ntxId, debounceUpdate, parentComponent, upd
spacedUpdate.resetUpdateTimer(); spacedUpdate.resetUpdateTimer();
} }
spacedUpdate.scheduleUpdate(); spacedUpdate.scheduleUpdate();
if (editorRef.current && onContentChanged) {
onContentChanged(editorRef.current.getText());
}
}} }}
{...editorProps} {...editorProps}
/> />

View File

@ -77,4 +77,8 @@
.note-detail-split.split-read-only .note-detail-split-preview-col { .note-detail-split.split-read-only .note-detail-split-preview-col {
width: 100%; width: 100%;
} }
/* #region SVG */
/* #endregion */

View File

@ -2,17 +2,17 @@ import { useEffect, useRef } from "preact/hooks";
import utils, { isMobile } from "../../../services/utils"; import utils, { isMobile } from "../../../services/utils";
import Admonition from "../../react/Admonition"; import Admonition from "../../react/Admonition";
import { useNoteLabelBoolean, useTriliumOption } from "../../react/hooks"; import { useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { TypeWidgetProps } from "../type_widget";
import "./SplitEditor.css"; import "./SplitEditor.css";
import Split from "split.js"; import Split from "split.js";
import { DEFAULT_GUTTER_SIZE } from "../../../services/resizer"; import { DEFAULT_GUTTER_SIZE } from "../../../services/resizer";
import { EditableCode } from "../code/Code"; import { EditableCode, EditableCodeProps } from "../code/Code";
import { ComponentChildren } from "preact"; import { ComponentChildren } from "preact";
import ActionButton, { ActionButtonProps } from "../../react/ActionButton"; import ActionButton, { ActionButtonProps } from "../../react/ActionButton";
export interface SplitEditorProps extends TypeWidgetProps { export interface SplitEditorProps extends EditableCodeProps {
error?: string | null; error?: string | null;
splitOptions?: Split.Options; splitOptions?: Split.Options;
previewContent: ComponentChildren;
previewButtons?: ComponentChildren; previewButtons?: ComponentChildren;
} }
@ -25,7 +25,7 @@ export interface SplitEditorProps extends TypeWidgetProps {
* - Can display errors to the user via {@link setError}. * - 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. * - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
*/ */
export default function SplitEditor({ note, error, splitOptions, previewButtons, ...editorProps }: SplitEditorProps) { export default function SplitEditor({ note, error, splitOptions, previewContent, previewButtons, ...editorProps }: SplitEditorProps) {
const splitEditorOrientation = useSplitOrientation(); const splitEditorOrientation = useSplitOrientation();
const [ readOnly ] = useNoteLabelBoolean(note, "readOnly"); const [ readOnly ] = useNoteLabelBoolean(note, "readOnly");
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -48,7 +48,9 @@ export default function SplitEditor({ note, error, splitOptions, previewButtons,
const preview = ( const preview = (
<div className={`note-detail-split-preview-col ${error ? "on-error" : ""}`}> <div className={`note-detail-split-preview-col ${error ? "on-error" : ""}`}>
<div className="note-detail-split-preview">Preview goes here</div> <div className="note-detail-split-preview">
{previewContent}
</div>
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group"> <div className="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group">
{previewButtons} {previewButtons}
</div> </div>

View File

@ -1,10 +1,43 @@
import { useState } from "preact/hooks";
import { t } from "../../../services/i18n"; import { t } from "../../../services/i18n";
import SplitEditor, { PreviewButton, SplitEditorProps } from "./SplitEditor"; import SplitEditor, { PreviewButton, SplitEditorProps } from "./SplitEditor";
import { RawHtmlBlock } from "../../react/RawHtml";
interface SvgSplitEditorProps extends Omit<SplitEditorProps, "previewContent"> {
/**
* Called upon when the SVG preview needs refreshing, such as when the editor has switched to a new note or the content has switched.
*
* The method must return a valid SVG string that will be automatically displayed in the preview.
*
* @param content the content of the note, in plain text.
*/
renderSvg(content: string): string | Promise<string>;
}
export default function SvgSplitEditor({ renderSvg, ...props }: SvgSplitEditorProps) {
const [ svg, setSvg ] = useState<string>();
const [ error, setError ] = useState<string | null | undefined>();
async function onContentChanged(content: string) {
try {
const svg = await renderSvg(content);
// Rendering was successful.
setError(null);
setSvg(svg);
} catch (e) {
// Rendering failed.
setError((e as Error)?.message);
}
}
export default function SvgSplitEditor(props: SplitEditorProps) {
return ( return (
<SplitEditor <SplitEditor
error="Hi there" error="Hi there"
onContentChanged={onContentChanged}
previewContent={(
<RawHtmlBlock className="render-container" html={svg} />
)}
previewButtons={ previewButtons={
<> <>
<PreviewButton <PreviewButton

View File

@ -83,18 +83,12 @@ export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTy
try { try {
svg = await this.renderSvg(content); svg = await this.renderSvg(content);
// Rendering was succesful.
this.setError(null);
if (svg === this.svg) { if (svg === this.svg) {
return; return;
} }
this.svg = svg; this.svg = svg;
this.$renderContainer.html(svg); this.$renderContainer.html(svg);
} catch (e: unknown) {
// Rendering failed.
this.setError((e as Error)?.message);
} }
await this.#setupPanZoom(!recenter); await this.#setupPanZoom(!recenter);
@ -118,15 +112,6 @@ export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTy
super.cleanup(); super.cleanup();
} }
/**
* Called upon when the SVG preview needs refreshing, such as when the editor has switched to a new note or the content has switched.
*
* The method must return a valid SVG string that will be automatically displayed in the preview.
*
* @param content the content of the note, in plain text.
*/
abstract renderSvg(content: string): Promise<string>;
/** /**
* Called to obtain the name of the note attachment (without .svg extension) that will be used for storing the preview. * Called to obtain the name of the note attachment (without .svg extension) that will be used for storing the preview.
*/ */

View File

@ -2,8 +2,6 @@ import type { EditorConfig } from "@triliumnext/codemirror";
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid.js"; import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid.js";
import AbstractSvgSplitTypeWidget from "./abstract_svg_split_type_widget.js"; import AbstractSvgSplitTypeWidget from "./abstract_svg_split_type_widget.js";
let idCounter = 1;
let registeredErrorReporter = false;
export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget { export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
@ -16,21 +14,7 @@ export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
} }
async renderSvg(content: string) { async renderSvg(content: string) {
const mermaid = (await import("mermaid")).default;
await loadElkIfNeeded(mermaid, content);
if (!registeredErrorReporter) {
// (await import("./linters/mermaid.js")).default();
registeredErrorReporter = true;
}
mermaid.initialize({
startOnLoad: false,
...(getMermaidConfig() as any),
});
idCounter++;
const { svg } = await mermaid.render(`mermaid-graph-${idCounter}`, content);
return postprocessMermaidSvg(svg);
} }
} }