Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/markup/markdown/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.C
languageStr := giteautil.IfZero(string(languageBytes), "text")

preClasses := "code-block"
if languageStr == "mermaid" || languageStr == "math" {
if languageStr == "mermaid" || languageStr == "math" || languageStr == "excalidraw" {
preClasses += " is-loading"
}

Expand Down
8 changes: 5 additions & 3 deletions modules/setting/markup.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import (

// ExternalMarkupRenderers represents the external markup renderers
var (
ExternalMarkupRenderers []*MarkupRenderer
ExternalSanitizerRules []MarkupSanitizerRule
MermaidMaxSourceCharacters int
ExternalMarkupRenderers []*MarkupRenderer
ExternalSanitizerRules []MarkupSanitizerRule
ExcalidrawMaxSourceCharacters int
MermaidMaxSourceCharacters int
)

const (
Expand Down Expand Up @@ -127,6 +128,7 @@ func loadMarkupFrom(rootCfg ConfigProvider) {
}
}

ExcalidrawMaxSourceCharacters = rootCfg.Section("markup").Key("EXCALIDRAW_MAX_SOURCE_CHARACTERS").MustInt(100000)
MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(50000)
ExternalMarkupRenderers = make([]*MarkupRenderer, 0, 10)
ExternalSanitizerRules = make([]MarkupSanitizerRule, 0, 10)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@citation-js/plugin-bibtex": "0.7.18",
"@citation-js/plugin-csl": "0.7.18",
"@citation-js/plugin-software-formats": "0.6.1",
"@excalidraw/utils": "0.1.3-test32",
Copy link
Member

@silverwind silverwind Sep 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This package does not look very popular at all (only a few hundret weekly downloads). Are we sure this is the right interface to convert source to svg? I have a feeling this is not meant for public consumption.

Also it looks like this package brings some PNG-related modules into the dependency tree which would be unused in our case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is from the official Excalidraw mono repo: https://github.com/excalidraw/excalidraw/tree/master/packages/utils

"@github/markdown-toolbar-element": "2.2.3",
"@github/paste-markdown": "1.5.3",
"@github/relative-time-element": "4.4.8",
Expand Down
90 changes: 90 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion web_src/js/features/file-view.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {FileRenderPlugin} from '../render/plugin.ts';
import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
import {newRenderPluginExcalidrawViewer} from '../render/plugins/excalidraw-viewer.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
Expand All @@ -10,7 +11,7 @@ const plugins: FileRenderPlugin[] = [];

function initPluginsOnce(): void {
if (plugins.length) return;
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer(), newRenderPluginExcalidrawViewer());
}

function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
Expand Down
2 changes: 2 additions & 0 deletions web_src/js/markup/content.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {initMarkupCodeMermaid} from './mermaid.ts';
import {initMarkupCodeExcalidraw} from './excalidraw.ts';
import {initMarkupCodeMath} from './math.ts';
import {initMarkupCodeCopy} from './codecopy.ts';
import {initMarkupRenderAsciicast} from './asciicast.ts';
Expand All @@ -12,6 +13,7 @@ export function initMarkupContent(): void {
initMarkupTasklist(el);
initMarkupCodeMermaid(el);
initMarkupCodeMath(el);
initMarkupCodeExcalidraw(el);
initMarkupRenderAsciicast(el);
});
}
79 changes: 79 additions & 0 deletions web_src/js/markup/excalidraw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {isDarkTheme} from '../utils.ts';
import {makeCodeCopyButton} from './codecopy.ts';
import {displayError} from './common.ts';
import {queryElems} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';

const {excalidrawMaxSourceCharacters} = window.config;

const iframeCss = `body { margin: 0; } svg { max-width: 100%; height: auto; }`;

export async function initMarkupCodeExcalidraw(elMarkup: HTMLElement): Promise<void> {
queryElems(elMarkup, 'code.language-excalidraw', async (el) => {
const {exportToSvg} = await import(/* webpackChunkName: "excalidraw/utils" */ '@excalidraw/utils');

const pre = el.closest('pre');
if (pre.hasAttribute('data-render-done')) return;

const source = el.textContent;
if (excalidrawMaxSourceCharacters >= 0 && source.length > excalidrawMaxSourceCharacters) {
displayError(pre, new Error(`Excalidraw source of ${source.length} characters exceeds the maximum allowed length of ${excalidrawMaxSourceCharacters}.`));
return;
}

let excalidrawJson;
try {
excalidrawJson = JSON.parse(source);
} catch (err) {
displayError(pre, new Error(`Invalid Excalidraw JSON: ${err}`));
return;
}

try {
const svg = await exportToSvg({
elements: excalidrawJson.elements,
appState: {
...excalidrawJson.appState,
exportWithDarkMode: isDarkTheme(),
},
files: excalidrawJson.files,
skipInliningFonts: true,
});
const iframe = document.createElement('iframe');
iframe.classList.add('markup-content-iframe', 'tw-invisible');
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg.outerHTML)}</body></html>`;

const excalidrawBlock = document.createElement('div');
excalidrawBlock.classList.add('excalidraw-block', 'is-loading', 'tw-hidden');
excalidrawBlock.append(iframe);

const btn = makeCodeCopyButton();
btn.setAttribute('data-clipboard-text', source);
excalidrawBlock.append(btn);

const updateIframeHeight = () => {
const body = iframe.contentWindow?.document?.body;
if (body) {
iframe.style.height = `${body.clientHeight}px`;
}
};
iframe.addEventListener('load', () => {
pre.replaceWith(excalidrawBlock);
excalidrawBlock.classList.remove('tw-hidden');
updateIframeHeight();
setTimeout(() => { // avoid flash of iframe background
excalidrawBlock.classList.remove('is-loading');
iframe.classList.remove('tw-invisible');
}, 0);

(new IntersectionObserver(() => {
updateIframeHeight();
}, {root: document.documentElement})).observe(iframe);
});

document.body.append(excalidrawBlock);
} catch (err) {
displayError(pre, err);
}
});
}
Loading
Loading