Skip to content

Commit 8c271c5

Browse files
committed
Support for Rendering Excalidraw Diagrams
While issue #28903 explored integrating Draw.io, @lunny suggested in a #28903 (comment) that Excalidraw would be a better alternative. The primary reasons are its MIT license and its ease of integration into Gitea. The recommended approach is to implement this as a plugin after the work in #34917 is completed. Key Changes: * Markdown Rendering: - The Markdown renderer now recognizes excalidraw as a language identifier in code blocks, similar to mermaid. * Configuration: - A new setting `EXCALIDRAW_MAX_SOURCE_CHARACTERS` has been added to control the maximum size of Excalidraw source data. * Dependencies: - The `@excalidraw/utils` package has been added to package.json to handle Excalidraw data processing. * Frontend Features: - A new file view plugin (`excalidraw-view.ts`) is added to render .excalidraw files directly. - The markup content handling (`content.ts`) now initializes Excalidraw rendering for embedded diagrams. - A new module (`excalidraw.ts`) is created to handle the logic of rendering Excalidraw JSON from code blocks into SVG images, including dark mode support. - The main file view feature (`file-view.ts`) now includes the Excalidraw viewer plugin.
1 parent f09bea7 commit 8c271c5

File tree

7 files changed

+123
-5
lines changed

7 files changed

+123
-5
lines changed

modules/markup/markdown/markdown.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.C
8282
languageStr := giteautil.IfZero(string(languageBytes), "text")
8383

8484
preClasses := "code-block"
85-
if languageStr == "mermaid" || languageStr == "math" {
85+
if languageStr == "mermaid" || languageStr == "math" || languageStr == "excalidraw" {
8686
preClasses += " is-loading"
8787
}
8888

modules/setting/markup.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import (
1313

1414
// ExternalMarkupRenderers represents the external markup renderers
1515
var (
16-
ExternalMarkupRenderers []*MarkupRenderer
17-
ExternalSanitizerRules []MarkupSanitizerRule
18-
MermaidMaxSourceCharacters int
16+
ExternalMarkupRenderers []*MarkupRenderer
17+
ExternalSanitizerRules []MarkupSanitizerRule
18+
ExcalidrawMaxSourceCharacters int
19+
MermaidMaxSourceCharacters int
1920
)
2021

2122
const (
@@ -127,6 +128,7 @@ func loadMarkupFrom(rootCfg ConfigProvider) {
127128
}
128129
}
129130

131+
ExcalidrawMaxSourceCharacters = rootCfg.Section("markup").Key("EXCALIDRAW_MAX_SOURCE_CHARACTERS").MustInt(100000)
130132
MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(50000)
131133
ExternalMarkupRenderers = make([]*MarkupRenderer, 0, 10)
132134
ExternalSanitizerRules = make([]MarkupSanitizerRule, 0, 10)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"@citation-js/plugin-bibtex": "0.7.18",
1111
"@citation-js/plugin-csl": "0.7.18",
1212
"@citation-js/plugin-software-formats": "0.6.1",
13+
"@excalidraw/utils": "0.1.3-test32",
1314
"@github/markdown-toolbar-element": "2.2.3",
1415
"@github/paste-markdown": "1.5.3",
1516
"@github/relative-time-element": "4.4.8",

web_src/js/features/file-view.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {FileRenderPlugin} from '../render/plugin.ts';
22
import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
33
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
4+
import {newRenderPluginExcalidrawViewer} from '../render/plugins/excalidraw-view.ts';
45
import {registerGlobalInitFunc} from '../modules/observer.ts';
56
import {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts';
67
import {html} from '../utils/html.ts';
@@ -10,7 +11,7 @@ const plugins: FileRenderPlugin[] = [];
1011

1112
function initPluginsOnce(): void {
1213
if (plugins.length) return;
13-
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
14+
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer(), newRenderPluginExcalidrawViewer());
1415
}
1516

1617
function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {

web_src/js/markup/content.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {initMarkupCodeMermaid} from './mermaid.ts';
2+
import {initMarkupCodeExcalidraw} from './excalidraw.ts';
23
import {initMarkupCodeMath} from './math.ts';
34
import {initMarkupCodeCopy} from './codecopy.ts';
45
import {initMarkupRenderAsciicast} from './asciicast.ts';
@@ -12,6 +13,7 @@ export function initMarkupContent(): void {
1213
initMarkupTasklist(el);
1314
initMarkupCodeMermaid(el);
1415
initMarkupCodeMath(el);
16+
initMarkupCodeExcalidraw(el);
1517
initMarkupRenderAsciicast(el);
1618
});
1719
}

web_src/js/markup/excalidraw.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {isDarkTheme} from '../utils.ts';
2+
import {makeCodeCopyButton} from './codecopy.ts';
3+
import {displayError} from './common.ts';
4+
import {queryElems} from '../utils/dom.ts';
5+
import {html, htmlRaw} from '../utils/html.ts';
6+
7+
const {excalidrawMaxSourceCharacters} = window.config;
8+
9+
const iframeCss = `body { margin: 0; } svg { max-width: 100%; height: auto; }`;
10+
11+
export async function initMarkupCodeExcalidraw(elMarkup: HTMLElement): Promise<void> {
12+
queryElems(elMarkup, 'code.language-excalidraw', async (el) => {
13+
const {exportToSvg} = await import(/* webpackChunkName: "excalidraw/utils" */ '@excalidraw/utils');
14+
15+
const pre = el.closest('pre');
16+
if (pre.hasAttribute('data-render-done')) return;
17+
18+
const source = el.textContent;
19+
if (excalidrawMaxSourceCharacters >= 0 && source.length > excalidrawMaxSourceCharacters) {
20+
displayError(pre, new Error(`Excalidraw source of ${source.length} characters exceeds the maximum allowed length of ${excalidrawMaxSourceCharacters}.`));
21+
return;
22+
}
23+
24+
let excalidrawJson;
25+
try {
26+
excalidrawJson = JSON.parse(source);
27+
} catch (err) {
28+
displayError(pre, new Error(`Invalid Excalidraw JSON: ${err}`));
29+
return;
30+
}
31+
32+
try {
33+
const svg = await exportToSvg({
34+
elements: excalidrawJson.elements,
35+
appState: {
36+
...excalidrawJson.appState,
37+
exportWithDarkMode: isDarkTheme(),
38+
},
39+
files: excalidrawJson.files,
40+
skipInliningFonts: true,
41+
});
42+
const iframe = document.createElement('iframe');
43+
iframe.classList.add('markup-content-iframe', 'tw-invisible');
44+
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg.outerHTML)}</body></html>`;
45+
46+
const excalidrawBlock = document.createElement('div');
47+
excalidrawBlock.classList.add('excalidraw-block', 'is-loading', 'tw-hidden');
48+
excalidrawBlock.append(iframe);
49+
50+
const btn = makeCodeCopyButton();
51+
btn.setAttribute('data-clipboard-text', source);
52+
excalidrawBlock.append(btn);
53+
54+
const updateIframeHeight = () => {
55+
const body = iframe.contentWindow?.document?.body;
56+
if (body) {
57+
iframe.style.height = `${body.clientHeight}px`;
58+
}
59+
};
60+
iframe.addEventListener('load', () => {
61+
pre.replaceWith(excalidrawBlock);
62+
excalidrawBlock.classList.remove('tw-hidden');
63+
updateIframeHeight();
64+
setTimeout(() => { // avoid flash of iframe background
65+
excalidrawBlock.classList.remove('is-loading');
66+
iframe.classList.remove('tw-invisible');
67+
}, 0);
68+
69+
(new IntersectionObserver(() => {
70+
updateIframeHeight();
71+
}, {root: document.documentElement})).observe(iframe);
72+
});
73+
74+
document.body.append(excalidrawBlock);
75+
} catch (err) {
76+
displayError(pre, err);
77+
}
78+
});
79+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type {FileRenderPlugin} from '../plugin.ts';
2+
import {isDarkTheme} from '../../utils.ts';
3+
import {request} from '../../modules/fetch.ts';
4+
5+
export function newRenderPluginExcalidrawViewer(): FileRenderPlugin {
6+
return {
7+
name: 'excalidraw-viewer',
8+
9+
canHandle(filename: string, _mimeType: string): boolean {
10+
return filename.toLowerCase().endsWith('.excalidraw');
11+
},
12+
13+
async render(container: HTMLElement, fileUrl: string): Promise<void> {
14+
const {exportToSvg} = await import(/* webpackChunkName: "excalidraw/utils" */ '@excalidraw/utils');
15+
const data = await request(fileUrl);
16+
const excalidrawJson = await data.json();
17+
const svg = await exportToSvg({
18+
elements: excalidrawJson.elements,
19+
appState: {
20+
...excalidrawJson.appState,
21+
exportWithDarkMode: isDarkTheme(),
22+
},
23+
files: excalidrawJson.files,
24+
skipInliningFonts: true,
25+
});
26+
container.style.display = 'flex';
27+
container.style.justifyContent = 'center';
28+
svg.style.maxWidth = '80%';
29+
svg.style.height = 'auto';
30+
container.append(svg);
31+
},
32+
};
33+
}

0 commit comments

Comments
 (0)