feat: code blocks with shiki highlighting
This commit is contained in:
+239
-2
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+208
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user