feat: code blocks with shiki highlighting

This commit is contained in:
2026-06-02 15:20:42 +00:00
parent e5eaa4f8d7
commit 7d36b6c9ea
12 changed files with 1394 additions and 4 deletions
@@ -0,0 +1,79 @@
import { memo, useEffect, useState } from "react";
import type { ExcalidrawProps } from "@excalidraw/excalidraw/types";
import type {
ExcalidrawEmbeddableElement,
NonDeleted,
} from "@excalidraw/excalidraw/element/types";
import { getCodeBlockCustomData, isCodeBlockEmbeddable } from "../codeblock";
import { renderHighlightedCodeBlockHtml } from "../codeblockHighlight";
type CodeBlockEmbeddableProps = {
element: NonDeleted<ExcalidrawEmbeddableElement>;
};
const CodeBlockEmbeddable = memo(function CodeBlockEmbeddable({
element,
}: CodeBlockEmbeddableProps) {
const customData = getCodeBlockCustomData(element);
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
if (!customData) {
setHtml(null);
return;
}
let cancelled = false;
void renderHighlightedCodeBlockHtml(customData.code, customData.language)
.then((nextHtml) => {
if (!cancelled) {
setHtml(nextHtml);
}
})
.catch(() => {
if (!cancelled) {
setHtml(null);
}
});
return () => {
cancelled = true;
};
}, [customData]);
if (!customData) {
return null;
}
return (
<div
className={[
"codeblock-embeddable",
customData.showBackground
? "codeblock-surface--background"
: "codeblock-surface--transparent",
].join(" ")}
>
{html ? (
<div
className="codeblock-embeddable-html"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre className="codeblock-embeddable-fallback">
<code>{customData.code}</code>
</pre>
)}
</div>
);
});
export const renderCodeBlockEmbeddable: NonNullable<
ExcalidrawProps["renderEmbeddable"]
> = (element) => {
if (!isCodeBlockEmbeddable(element)) {
return null;
}
return <CodeBlockEmbeddable element={element} />;
};
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from "react";
import {
CODE_BLOCK_LANGUAGES,
type CodeBlockDraftState,
type CodeBlockLanguage,
} from "../codeblock";
import { renderHighlightedCodeBlockHtml } from "../codeblockHighlight";
type CodeBlockSidebarProps = {
open: boolean;
draft: CodeBlockDraftState | null;
onCodeChange: (code: string) => void;
onLanguageChange: (language: CodeBlockLanguage) => void;
onShowBackgroundChange: (showBackground: boolean) => void;
onCommit: () => void;
};
function CodeBlockEditor({
draft,
onCodeChange,
onCommit,
}: {
draft: CodeBlockDraftState;
onCodeChange: (code: string) => void;
onCommit: () => void;
}) {
const [html, setHtml] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const highlightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
void renderHighlightedCodeBlockHtml(draft.code, draft.language)
.then((nextHtml) => {
if (!cancelled) {
setHtml(nextHtml);
}
})
.catch(() => {
if (!cancelled) {
setHtml(null);
}
});
return () => {
cancelled = true;
};
}, [draft.code, draft.language]);
const syncScroll = () => {
const textarea = textareaRef.current;
const highlight = highlightRef.current;
if (!textarea || !highlight) {
return;
}
highlight.scrollTop = textarea.scrollTop;
highlight.scrollLeft = textarea.scrollLeft;
};
return (
<div
className={[
"codeblock-editor-surface",
draft.showBackground
? "codeblock-surface--background"
: "codeblock-surface--transparent",
].join(" ")}
>
<div
ref={highlightRef}
className="codeblock-editor-highlight"
aria-hidden="true"
>
{html ? (
<div
className="codeblock-editor-highlight-html"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre className="codeblock-editor-highlight-fallback">
<code>{draft.code || " "}</code>
</pre>
)}
</div>
<textarea
ref={textareaRef}
className="codeblock-editor-textarea"
value={draft.code}
onChange={(event) => onCodeChange(event.target.value)}
onBlur={onCommit}
onScroll={syncScroll}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
aria-label="Code block contents"
/>
</div>
);
}
function CodeBlockIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M8 8 4.5 12 8 16M16 8l3.5 4-3.5 4M13 6l-2 12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function InsertCodeBlockButton({
onClick,
disabled,
}: {
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
className="secondary-button app-actions-toggle codeblock-toolbar-button"
onClick={onClick}
disabled={disabled}
aria-label="Insert code block"
>
<CodeBlockIcon />
<span className="sr-only">Insert code block</span>
</button>
);
}
export function CodeBlockSidebar({
open,
draft,
onCodeChange,
onLanguageChange,
onShowBackgroundChange,
onCommit,
}: CodeBlockSidebarProps) {
if (!open || !draft) {
return null;
}
return (
<aside className="codeblock-sidebar">
<select
className="codeblock-language-select"
value={draft.language}
onChange={(event) =>
onLanguageChange(event.target.value as CodeBlockLanguage)
}
onBlur={onCommit}
aria-label="Code block language"
>
{CODE_BLOCK_LANGUAGES.map((language) => (
<option key={language} value={language}>
{language}
</option>
))}
</select>
<CodeBlockEditor
key={draft.elementId}
draft={draft}
onCodeChange={onCodeChange}
onCommit={onCommit}
/>
<label className="codeblock-background-toggle">
<input
type="checkbox"
checked={draft.showBackground}
onChange={(event) => onShowBackgroundChange(event.target.checked)}
onBlur={onCommit}
/>
<span>Background</span>
</label>
</aside>
);
}
+17
View File
@@ -3,10 +3,16 @@ import { Excalidraw } from "@excalidraw/excalidraw";
import type {
AppState,
BinaryFiles,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type ScenePayload } from "../../core/shared";
import {
getCodeBlockSelectionState,
type CodeBlockSelectionState,
} from "../codeblock";
type EditorCanvasProps = {
activeId: string | null;
@@ -15,7 +21,10 @@ type EditorCanvasProps = {
error: string | null;
editorReloadNonce: number;
onSceneChange: (scene: ScenePayload) => void;
onSelectionStateChange: (selection: CodeBlockSelectionState) => void;
onEditorActivity: () => void;
onExcalidrawAPI: (api: ExcalidrawImperativeAPI) => void;
renderEmbeddable?: ExcalidrawProps["renderEmbeddable"];
};
function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState {
@@ -42,7 +51,10 @@ export const EditorCanvas = memo(function EditorCanvas({
error,
editorReloadNonce,
onSceneChange,
onSelectionStateChange,
onEditorActivity,
onExcalidrawAPI,
renderEmbeddable,
}: EditorCanvasProps) {
if (loading || !scene) {
return <div className="editor-loading">{error ?? "Loading..."}</div>;
@@ -53,9 +65,14 @@ export const EditorCanvas = memo(function EditorCanvas({
<Excalidraw
key={`${activeId}:${editorReloadNonce}`}
initialData={sceneToInitialData(scene)}
excalidrawAPI={onExcalidrawAPI}
renderEmbeddable={renderEmbeddable}
onChange={(elements, appState, files) => {
onEditorActivity();
onSceneChange(sceneFromEditor(elements, appState, files));
onSelectionStateChange(
getCodeBlockSelectionState(elements, appState),
);
}}
/>
</div>
+7 -1
View File
@@ -1,6 +1,9 @@
import { memo } from "react";
import { Excalidraw } from "@excalidraw/excalidraw";
import type { ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types";
import type {
ExcalidrawInitialDataState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type PublicDrawing } from "../../core/shared";
@@ -8,6 +11,7 @@ type PublicViewerProps = {
drawing: PublicDrawing | null;
loading: boolean;
error: string | null;
renderEmbeddable?: ExcalidrawProps["renderEmbeddable"];
};
function drawingToInitialData(
@@ -35,6 +39,7 @@ export const PublicViewer = memo(function PublicViewer({
drawing,
loading,
error,
renderEmbeddable,
}: PublicViewerProps) {
if (loading || !drawing) {
return <div className="editor-loading">{error ?? "Loading..."}</div>;
@@ -47,6 +52,7 @@ export const PublicViewer = memo(function PublicViewer({
viewModeEnabled={true}
zenModeEnabled={true}
UIOptions={viewerUiOptions}
renderEmbeddable={renderEmbeddable}
/>
</div>
);