feat: code blocks with shiki highlighting
This commit is contained in:
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user