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
+239 -2
View File
@@ -1,7 +1,30 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
import {
CodeBlockSidebar,
InsertCodeBlockButton,
} from "./components/CodeBlockSidebar";
import { renderCodeBlockEmbeddable } from "./components/CodeBlockEmbeddable";
import { EditorCanvas } from "./components/EditorCanvas";
import { PublicViewer } from "./components/PublicViewer";
import {
createCodeBlockElement,
DEFAULT_CODE_BLOCK_HEIGHT,
DEFAULT_CODE_BLOCK_WIDTH,
EMPTY_CODE_BLOCK_SELECTION_STATE,
updateCodeBlockElements,
type CodeBlockDraftState,
type CodeBlockLanguage,
type CodeBlockSelectionState,
} from "./codeblock";
import { usePublicDrawing } from "./hooks/usePublicDrawing";
import { useDrawingSession } from "./hooks/useDrawingSession";
import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
@@ -23,7 +46,13 @@ function routeFromPath(pathname = window.location.pathname): AppRoute {
function PrivateApp() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [codeBlockSelection, setCodeBlockSelection] =
useState<CodeBlockSelectionState>(EMPTY_CODE_BLOCK_SELECTION_STATE);
const [codeBlockDraft, setCodeBlockDraft] =
useState<CodeBlockDraftState | null>(null);
const appShellRef = useRef<HTMLDivElement | null>(null);
const excalidrawApiRef = useRef<ExcalidrawImperativeAPI | null>(null);
const codeBlockDraftRef = useRef<CodeBlockDraftState | null>(null);
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
const {
drawings,
@@ -48,6 +77,38 @@ function PrivateApp() {
disablePublication,
} = useDrawingSession();
const flushCodeBlockDraft = useEffectEvent(() => {
const api = excalidrawApiRef.current;
const draft = codeBlockDraftRef.current;
if (!api || !draft) {
return;
}
const currentElements = api.getSceneElementsIncludingDeleted();
const nextElements = updateCodeBlockElements(
currentElements,
draft.elementId,
{
code: draft.code,
language: draft.language,
showBackground: draft.showBackground,
},
);
if (nextElements === currentElements) {
return;
}
api.updateScene({
elements: nextElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
useEffect(() => {
codeBlockDraftRef.current = codeBlockDraft;
}, [codeBlockDraft]);
useEffect(() => {
if (!scene || loading) {
return;
@@ -73,6 +134,62 @@ function PrivateApp() {
};
}, [isSidebarOpen]);
useEffect(() => {
return () => {
flushCodeBlockDraft();
};
}, []);
useEffect(() => {
const selectedCodeBlock = codeBlockSelection.selectedCodeBlock;
setCodeBlockDraft((current) => {
const nextDraft = selectedCodeBlock
? {
elementId: selectedCodeBlock.id,
code: selectedCodeBlock.customData.code,
language: selectedCodeBlock.customData.language,
showBackground: selectedCodeBlock.customData.showBackground,
}
: null;
if (
current?.elementId === nextDraft?.elementId &&
current?.code === nextDraft?.code &&
current?.language === nextDraft?.language &&
current?.showBackground === nextDraft?.showBackground
) {
return current;
}
return nextDraft;
});
return () => {
flushCodeBlockDraft();
};
}, [codeBlockSelection.selectedCodeBlockId]);
useEffect(() => {
if (!codeBlockDraft) {
return;
}
const timeoutId = window.setTimeout(() => {
flushCodeBlockDraft();
}, 250);
return () => {
clearTimeout(timeoutId);
};
}, [codeBlockDraft]);
useEffect(() => {
if (loading) {
setCodeBlockSelection(EMPTY_CODE_BLOCK_SELECTION_STATE);
setCodeBlockDraft(null);
}
}, [loading]);
const openSidebar = useCallback(() => {
setIsSidebarOpen(true);
}, []);
@@ -94,6 +211,102 @@ function PrivateApp() {
void createDrawing();
}, [createDrawing]);
const handleInsertCodeBlock = useCallback(() => {
const api = excalidrawApiRef.current;
if (!api) {
return;
}
const appState = api.getAppState();
const viewportWidth =
typeof appState.width === "number" ? appState.width : window.innerWidth;
const viewportHeight =
typeof appState.height === "number"
? appState.height
: window.innerHeight;
const zoom = appState.zoom.value;
const x =
-appState.scrollX +
viewportWidth / (2 * zoom) -
DEFAULT_CODE_BLOCK_WIDTH / 2;
const y =
-appState.scrollY +
viewportHeight / (2 * zoom) -
DEFAULT_CODE_BLOCK_HEIGHT / 2;
const codeBlock = createCodeBlockElement({ x, y });
api.updateScene({
elements: [...api.getSceneElementsIncludingDeleted(), codeBlock],
appState: {
selectedElementIds: {
[codeBlock.id]: true,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
}, []);
const handleCodeBlockSelectionChange = useCallback(
(selection: CodeBlockSelectionState) => {
setCodeBlockSelection((current) => {
if (
current.isPanelOpen === selection.isPanelOpen &&
current.selectedCodeBlockId === selection.selectedCodeBlockId &&
current.selectedCodeBlock?.customData.code ===
selection.selectedCodeBlock?.customData.code &&
current.selectedCodeBlock?.customData.language ===
selection.selectedCodeBlock?.customData.language &&
current.selectedCodeBlock?.customData.showBackground ===
selection.selectedCodeBlock?.customData.showBackground
) {
return current;
}
return selection;
});
},
[],
);
const handleCodeBlockCodeChange = useCallback((code: string) => {
setCodeBlockDraft((current) =>
current
? {
...current,
code,
}
: current,
);
}, []);
const handleCodeBlockLanguageChange = useCallback(
(language: CodeBlockLanguage) => {
setCodeBlockDraft((current) =>
current
? {
...current,
language,
}
: current,
);
},
[],
);
const handleCodeBlockShowBackgroundChange = useCallback(
(showBackground: boolean) => {
setCodeBlockDraft((current) =>
current
? {
...current,
showBackground,
}
: current,
);
},
[],
);
return (
<div className="app-shell" ref={appShellRef}>
{toastMessage && (
@@ -101,6 +314,10 @@ function PrivateApp() {
{toastMessage}
</div>
)}
<InsertCodeBlockButton
onClick={handleInsertCodeBlock}
disabled={loading || scene === null}
/>
<main className="editor-shell">
<div className="app-actions">
<DrawingsToggle onClick={openSidebar} />
@@ -113,7 +330,20 @@ function PrivateApp() {
error={error}
editorReloadNonce={editorReloadNonce}
onSceneChange={handleSceneChange}
onSelectionStateChange={handleCodeBlockSelectionChange}
onEditorActivity={scheduleThemeTokenSync}
onExcalidrawAPI={(api) => {
excalidrawApiRef.current = api;
}}
renderEmbeddable={renderCodeBlockEmbeddable}
/>
<CodeBlockSidebar
open={codeBlockSelection.isPanelOpen}
draft={codeBlockDraft}
onCodeChange={handleCodeBlockCodeChange}
onLanguageChange={handleCodeBlockLanguageChange}
onShowBackgroundChange={handleCodeBlockShowBackgroundChange}
onCommit={flushCodeBlockDraft}
/>
</main>
<DrawingSidebar
@@ -141,7 +371,14 @@ function PrivateApp() {
function PublicApp({ slug }: { slug: string }) {
const { drawing, loading, error } = usePublicDrawing(slug);
return <PublicViewer drawing={drawing} loading={loading} error={error} />;
return (
<PublicViewer
drawing={drawing}
loading={loading}
error={error}
renderEmbeddable={renderCodeBlockEmbeddable}
/>
);
}
export function App() {
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, test } from "bun:test";
import {
DEFAULT_CODE_BLOCK_SHOW_BACKGROUND,
EMPTY_CODE_BLOCK_SELECTION_STATE,
createCodeBlockCustomData,
getCodeBlockSelectionState,
getCodeBlockCustomData,
isCodeBlockEmbeddable,
sanitizeCodeBlockLanguage,
updateCodeBlockElements,
} from "./codeblock";
const baseEmbeddable = {
id: "codeblock-1",
type: "embeddable" as const,
x: 10,
y: 20,
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: "solid" as const,
strokeWidth: 1,
strokeStyle: "solid" as const,
roundness: null,
roughness: 0,
opacity: 100,
width: 420,
height: 260,
angle: 0,
seed: 1,
version: 1,
versionNonce: 2,
index: null,
isDeleted: false,
groupIds: [],
frameId: null,
boundElements: null,
updated: 1,
link: null,
locked: false,
};
describe("codeblock helpers", () => {
test("type guard accepts only repo-owned codeblock embeddables", () => {
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
customData: createCodeBlockCustomData(),
}),
).toBe(true);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
customData: {
excaliType: "other",
version: 2,
code: "",
language: "tsx",
showBackground: true,
},
}),
).toBe(false);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
link: "https://example.com",
customData: createCodeBlockCustomData(),
}),
).toBe(false);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
type: "iframe",
customData: createCodeBlockCustomData(),
}),
).toBe(false);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
customData: {
excaliType: "codeblock",
version: 1,
code: "",
language: "tsx",
showBackground: true,
},
}),
).toBe(false);
});
test("create helper defaults background to enabled", () => {
expect(createCodeBlockCustomData().showBackground).toBe(
DEFAULT_CODE_BLOCK_SHOW_BACKGROUND,
);
});
test("update helper preserves unrelated fields and non-codeblock embeddables", () => {
const codeBlock = {
...baseEmbeddable,
customData: createCodeBlockCustomData({ code: "const a = 1;" }),
};
const foreignEmbeddable = {
...baseEmbeddable,
id: "embed-2",
customData: { source: "foreign" },
};
const elements = [codeBlock, foreignEmbeddable];
const nextElements = updateCodeBlockElements(elements, "codeblock-1", {
code: "const a = 2;",
language: "ts",
showBackground: false,
});
expect(nextElements).not.toBe(elements);
expect(nextElements[0]).not.toBe(codeBlock);
expect(nextElements[0]).toMatchObject({
id: "codeblock-1",
x: 10,
y: 20,
width: 420,
customData: {
excaliType: "codeblock",
version: 2,
code: "const a = 2;",
language: "typescript",
showBackground: false,
},
});
expect(nextElements[1]).toBe(foreignEmbeddable);
expect(
updateCodeBlockElements(elements, "embed-2", { code: "ignored" }),
).toBe(elements);
});
test("selection helper opens only for a single selected codeblock", () => {
const codeBlock = {
...baseEmbeddable,
customData: createCodeBlockCustomData(),
};
const other = {
...baseEmbeddable,
id: "shape-2",
type: "rectangle" as const,
};
expect(
getCodeBlockSelectionState([codeBlock, other], {
selectedElementIds: { "codeblock-1": true },
}),
).toMatchObject({
isPanelOpen: true,
selectedCodeBlockId: "codeblock-1",
selectedCodeBlock: {
customData: {
showBackground: true,
},
},
});
expect(
getCodeBlockSelectionState([codeBlock, other], {
selectedElementIds: { "codeblock-1": true, "shape-2": true },
}),
).toEqual(EMPTY_CODE_BLOCK_SELECTION_STATE);
expect(
getCodeBlockSelectionState([codeBlock, other], {
selectedElementIds: { "shape-2": true },
}),
).toEqual(EMPTY_CODE_BLOCK_SELECTION_STATE);
});
test("language sanitization accepts shiki aliases and normalizes them", () => {
expect(sanitizeCodeBlockLanguage("ts")).toBe("typescript");
expect(sanitizeCodeBlockLanguage("js")).toBe("javascript");
expect(sanitizeCodeBlockLanguage("md")).toBe("markdown");
expect(sanitizeCodeBlockLanguage("bash")).toBe("shellscript");
expect(sanitizeCodeBlockLanguage("plaintext")).toBe("plaintext");
});
test("custom data reads normalize persisted shiki aliases", () => {
const codeBlock = {
...baseEmbeddable,
customData: {
excaliType: "codeblock" as const,
version: 2 as const,
code: "echo hi",
language: "bash" as const,
showBackground: true,
},
};
expect(getCodeBlockCustomData(codeBlock)).toMatchObject({
code: "echo hi",
language: "shellscript",
});
});
});
+296
View File
@@ -0,0 +1,296 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import type {
ExcalidrawEmbeddableElement,
ExcalidrawElement,
NonDeleted,
} from "@excalidraw/excalidraw/element/types";
import {
bundledLanguages,
bundledLanguagesInfo,
type BundledLanguage,
} from "shiki";
export type CodeBlockLanguage = "plaintext" | BundledLanguage;
export type CodeBlockCustomData = {
excaliType: "codeblock";
version: 2;
code: string;
language: CodeBlockLanguage;
showBackground: boolean;
};
export type CodeBlockElement = NonDeleted<ExcalidrawEmbeddableElement> & {
customData: CodeBlockCustomData;
};
export type CodeBlockSelectionState = {
isPanelOpen: boolean;
selectedCodeBlock: CodeBlockElement | null;
selectedCodeBlockId: string | null;
};
export type CodeBlockDraftState = {
elementId: string;
code: string;
language: CodeBlockLanguage;
showBackground: boolean;
};
const SHIKI_LANGUAGE_SET = new Set<string>(Object.keys(bundledLanguages));
const SHIKI_LANGUAGE_ALIAS_TO_ID = new Map<string, BundledLanguage>(
bundledLanguagesInfo.flatMap(({ id, aliases }) =>
(aliases ?? []).map((alias) => [alias, id as BundledLanguage] as const),
),
);
export const CODE_BLOCK_LANGUAGES = Object.freeze([
"plaintext",
...bundledLanguagesInfo
.map(({ id }) => id as BundledLanguage)
.sort((left, right) => left.localeCompare(right)),
]) as readonly CodeBlockLanguage[];
export const DEFAULT_CODE_BLOCK_LANGUAGE: CodeBlockLanguage = "tsx";
export const DEFAULT_CODE_BLOCK_CODE = [
"export function Example() {",
' return <button type="button">Click me</button>;',
"}",
].join("\n");
export const DEFAULT_CODE_BLOCK_WIDTH = 420;
export const DEFAULT_CODE_BLOCK_HEIGHT = 260;
export const CODE_BLOCK_SHIKI_THEME = "poimandres";
export const DEFAULT_CODE_BLOCK_SHOW_BACKGROUND = true;
export const EMPTY_CODE_BLOCK_SELECTION_STATE: CodeBlockSelectionState = {
isPanelOpen: false,
selectedCodeBlock: null,
selectedCodeBlockId: null,
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function randomInt(): number {
return (
crypto.getRandomValues(new Uint32Array(1))[0] ??
Math.floor(Math.random() * 2 ** 31)
);
}
function getCanonicalCodeBlockLanguage(
language: unknown,
): CodeBlockLanguage | null {
if (language === "plaintext") {
return language;
}
if (typeof language !== "string" || !SHIKI_LANGUAGE_SET.has(language)) {
return null;
}
return (
SHIKI_LANGUAGE_ALIAS_TO_ID.get(language) ?? (language as BundledLanguage)
);
}
export function sanitizeCodeBlockLanguage(
language: unknown,
): CodeBlockLanguage {
return getCanonicalCodeBlockLanguage(language) ?? DEFAULT_CODE_BLOCK_LANGUAGE;
}
export function createCodeBlockCustomData(
overrides?: Partial<
Pick<CodeBlockCustomData, "code" | "language" | "showBackground">
>,
): CodeBlockCustomData {
return {
excaliType: "codeblock",
version: 2,
code: overrides?.code ?? DEFAULT_CODE_BLOCK_CODE,
language: sanitizeCodeBlockLanguage(overrides?.language),
showBackground:
overrides?.showBackground ?? DEFAULT_CODE_BLOCK_SHOW_BACKGROUND,
};
}
export function isCodeBlockCustomData(
value: unknown,
): value is CodeBlockCustomData {
return (
isRecord(value) &&
value.excaliType === "codeblock" &&
value.version === 2 &&
typeof value.code === "string" &&
getCanonicalCodeBlockLanguage(value.language) !== null &&
typeof value.showBackground === "boolean"
);
}
export function isCodeBlockEmbeddable(
element: unknown,
): element is CodeBlockElement {
return (
isRecord(element) &&
element.type === "embeddable" &&
element.link === null &&
isCodeBlockCustomData(element.customData)
);
}
export function getCodeBlockCustomData(
element: unknown,
): CodeBlockCustomData | null {
if (!isCodeBlockEmbeddable(element)) {
return null;
}
const language = sanitizeCodeBlockLanguage(element.customData.language);
if (language === element.customData.language) {
return element.customData;
}
return {
...element.customData,
language,
};
}
export function createCodeBlockElement({
x,
y,
width = DEFAULT_CODE_BLOCK_WIDTH,
height = DEFAULT_CODE_BLOCK_HEIGHT,
}: {
x: number;
y: number;
width?: number;
height?: number;
}): CodeBlockElement {
const now = Date.now();
return {
id: crypto.randomUUID(),
type: "embeddable",
x,
y,
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: "solid",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
roughness: 0,
opacity: 100,
width,
height,
angle: 0,
seed: randomInt(),
version: 1,
versionNonce: randomInt(),
index: null,
isDeleted: false,
groupIds: [],
frameId: null,
boundElements: null,
updated: now,
link: null,
locked: false,
customData: createCodeBlockCustomData(),
};
}
export function updateCodeBlockElement<TElement extends ExcalidrawElement>(
element: TElement,
patch: Partial<
Pick<CodeBlockCustomData, "code" | "language" | "showBackground">
>,
): TElement {
if (!isCodeBlockEmbeddable(element)) {
return element;
}
const nextCustomData = {
...element.customData,
...(patch.code !== undefined ? { code: patch.code } : {}),
...(patch.language !== undefined
? { language: sanitizeCodeBlockLanguage(patch.language) }
: {}),
...(patch.showBackground !== undefined
? { showBackground: patch.showBackground }
: {}),
} satisfies CodeBlockCustomData;
if (
nextCustomData.code === element.customData.code &&
nextCustomData.language === element.customData.language &&
nextCustomData.showBackground === element.customData.showBackground
) {
return element;
}
return {
...element,
customData: nextCustomData,
};
}
export function updateCodeBlockElements<TElement extends ExcalidrawElement>(
elements: readonly TElement[],
elementId: string,
patch: Partial<
Pick<CodeBlockCustomData, "code" | "language" | "showBackground">
>,
): readonly TElement[] {
let changed = false;
const nextElements = elements.map((element) => {
if (element.id !== elementId) {
return element;
}
const nextElement = updateCodeBlockElement(element, patch);
changed ||= nextElement !== element;
return nextElement;
});
return changed ? nextElements : elements;
}
export function getCodeBlockSelectionState(
elements: readonly ExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
): CodeBlockSelectionState {
const selectedIds = Object.entries(appState.selectedElementIds ?? {})
.filter(([, selected]) => selected)
.map(([elementId]) => elementId);
if (selectedIds.length !== 1) {
return EMPTY_CODE_BLOCK_SELECTION_STATE;
}
const selectedCodeBlock = elements.find(
(element) =>
element.id === selectedIds[0] && isCodeBlockEmbeddable(element),
);
if (!selectedCodeBlock || !isCodeBlockEmbeddable(selectedCodeBlock)) {
return EMPTY_CODE_BLOCK_SELECTION_STATE;
}
const customData = getCodeBlockCustomData(selectedCodeBlock);
if (!customData) {
return EMPTY_CODE_BLOCK_SELECTION_STATE;
}
return {
isPanelOpen: true,
selectedCodeBlock: {
...selectedCodeBlock,
customData,
},
selectedCodeBlockId: selectedCodeBlock.id,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { getSingletonHighlighter, type Highlighter } from "shiki";
import { CODE_BLOCK_SHIKI_THEME, type CodeBlockLanguage } from "./codeblock";
let highlighterPromise: Promise<Highlighter> | null = null;
export function getCodeBlockHighlighter() {
if (!highlighterPromise) {
highlighterPromise = getSingletonHighlighter({
themes: [CODE_BLOCK_SHIKI_THEME],
});
}
return highlighterPromise;
}
export function getHighlightableCodeBlockLanguage(
language: CodeBlockLanguage,
): Exclude<CodeBlockLanguage, "plaintext"> | null {
return language === "plaintext" ? null : language;
}
export async function renderHighlightedCodeBlockHtml(
code: string,
language: CodeBlockLanguage,
): Promise<string | null> {
const shikiLanguage = getHighlightableCodeBlockLanguage(language);
if (!shikiLanguage) {
return null;
}
const highlighter = await getCodeBlockHighlighter();
await highlighter.loadLanguage(shikiLanguage);
return highlighter.codeToHtml(code, {
lang: shikiLanguage,
theme: CODE_BLOCK_SHIKI_THEME,
});
}
@@ -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>
);
+208 -1
View File
@@ -27,7 +27,9 @@ body {
}
button,
input {
input,
select,
textarea {
font: inherit;
}
@@ -211,6 +213,7 @@ input {
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 48px);
z-index: 20;
display: flex;
align-items: flex-end;
}
.app-actions-toggle {
@@ -227,6 +230,13 @@ input {
color: var(--app-text-color);
}
.codeblock-toolbar-button {
position: fixed;
top: max(16px, calc(env(safe-area-inset-top) + 16px));
left: calc(50% + 314px);
z-index: 20;
}
.app-actions-toggle svg {
width: 1rem;
height: 1rem;
@@ -323,6 +333,190 @@ input {
height: 100%;
}
.codeblock-sidebar {
position: fixed;
top: 88px;
right: max(16px, calc(env(safe-area-inset-right) + 16px));
bottom: 16px;
z-index: 25;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
width: min(360px, calc(100vw - 32px));
padding: 24px 12px 12px;
border: 1px solid var(--app-sidebar-border);
border-radius: 16px;
background: color-mix(in srgb, var(--app-sidebar-bg) 92%, white);
box-shadow: var(--app-sidebar-shadow);
backdrop-filter: blur(14px);
}
.codeblock-language-select,
.codeblock-editor-surface {
border: 1px solid var(--app-input-border);
color: var(--app-input-color);
}
.codeblock-background-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--app-text-color);
font-size: var(--app-sidebar-font-size);
}
.codeblock-background-toggle input {
margin: 0;
}
.codeblock-language-select {
border-radius: 10px;
padding: 8px 10px;
background: var(--app-input-bg);
}
.codeblock-editor-surface {
position: relative;
min-height: 0;
overflow: hidden;
border-radius: 10px;
background: transparent;
}
.codeblock-editor-highlight,
.codeblock-editor-highlight-html,
.codeblock-editor-highlight-html :where(pre.shiki),
.codeblock-editor-highlight-fallback,
.codeblock-editor-textarea {
width: 100%;
height: 100%;
}
.codeblock-editor-highlight,
.codeblock-editor-textarea,
.codeblock-editor-highlight-html :where(pre.shiki),
.codeblock-editor-highlight-fallback {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
monospace;
line-height: 1.5;
}
.codeblock-editor-highlight {
position: absolute;
inset: 0;
overflow: auto;
pointer-events: none;
}
.codeblock-editor-highlight-html :where(pre.shiki),
.codeblock-editor-highlight-fallback {
margin: 0;
padding: 12px;
overflow: hidden;
}
.codeblock-editor-highlight-html :where(code),
.codeblock-editor-highlight-fallback code {
display: block;
min-height: 100%;
white-space: pre;
}
.codeblock-editor-highlight-fallback,
.codeblock-embeddable-fallback {
color: #a6accd;
}
.codeblock-editor-textarea {
position: relative;
min-height: 0;
width: 100%;
height: 100%;
resize: none;
padding: 12px;
border: 0;
background: transparent;
color: transparent;
caret-color: #e4f0fb;
}
.codeblock-editor-textarea::selection {
background: rgba(113, 124, 180, 0.45);
}
.codeblock-editor-textarea:focus {
outline: none;
}
.codeblock-embeddable,
.codeblock-embeddable-html,
.codeblock-embeddable-html :where(pre.shiki),
.codeblock-embeddable-fallback {
width: 100%;
height: 100%;
}
.codeblock-embeddable {
overflow: auto;
border-radius: inherit;
background: transparent;
}
.codeblock-embeddable-html :where(pre.shiki),
.codeblock-embeddable-fallback {
margin: 0;
padding: 16px;
overflow: auto;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
monospace;
line-height: 1.5;
}
.codeblock-embeddable-html :where(pre.shiki) {
background: transparent !important;
}
.codeblock-surface--background {
background: #1b1e28;
}
.codeblock-surface--background .codeblock-embeddable-html :where(pre.shiki),
.codeblock-surface--background
.codeblock-editor-highlight-html
:where(pre.shiki) {
background: #1b1e28 !important;
}
.codeblock-surface--background .codeblock-embeddable-fallback,
.codeblock-surface--background .codeblock-editor-highlight-fallback {
background: #1b1e28;
}
.codeblock-surface--transparent {
background: transparent;
}
.codeblock-surface--transparent .codeblock-embeddable-html :where(pre.shiki),
.codeblock-surface--transparent
.codeblock-editor-highlight-html
:where(pre.shiki) {
background: transparent !important;
}
.codeblock-surface--transparent .codeblock-embeddable-fallback,
.codeblock-surface--transparent .codeblock-editor-highlight-fallback {
background: transparent;
}
.codeblock-embeddable-html :where(code),
.codeblock-embeddable-fallback code {
display: block;
min-height: 100%;
white-space: pre;
}
.editor-loading {
display: grid;
place-items: center;
@@ -359,4 +553,17 @@ input {
right: max(16px, calc(env(safe-area-inset-right) + 16px));
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 56px);
}
.codeblock-toolbar-button {
left: calc(50% + 254px);
}
.codeblock-sidebar {
top: auto;
left: 16px;
right: 16px;
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 72px);
width: auto;
height: min(44vh, 360px);
}
}