692 lines
20 KiB
TypeScript
692 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Fragment,
|
||
memo,
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { Lab1ConfidenceChat } from "~/components/labs/Lab1ConfidenceChat";
|
||
import { Lab1NetronPanel } from "~/components/labs/Lab1NetronPanel";
|
||
import { Lab3TerminalFrame } from "~/components/labs/Lab3TerminalFrame";
|
||
import { Lab8Chat } from "~/components/labs/Lab8Chat";
|
||
import { InferenceSettingsVisualization } from "~/components/labs/InferenceSettingsVisualization";
|
||
import { Objective5Chat } from "~/components/labs/Objective5Chat";
|
||
import { QuantizationGridExplorer } from "~/components/labs/QuantizationGridExplorer";
|
||
import { QuantizationExplorer } from "~/components/labs/QuantizationExplorer";
|
||
import { TokenizerPlaygroundEmbed } from "~/components/labs/TokenizerPlaygroundEmbed";
|
||
import {
|
||
fetchCoursewareRuntimeConfig,
|
||
isCoursewareServiceId,
|
||
normalizeCoursewareRuntimeConfig,
|
||
resolveCoursewareServiceAddress,
|
||
resolveCoursewareServiceUrl,
|
||
type ResolvedCoursewareRuntimeConfig,
|
||
} from "~/lib/courseware-runtime";
|
||
|
||
type LabContentProps = {
|
||
className: string;
|
||
html: string;
|
||
};
|
||
|
||
type LabContentArticleProps = LabContentProps & {
|
||
onZoomImage: (image: ZoomedImageState) => void;
|
||
};
|
||
|
||
const cliLanguagePattern =
|
||
/\b(language-(bash|sh|shell|zsh|console|terminal)|bash|shell|zsh)\b/i;
|
||
const cliCommandPattern =
|
||
/(^|\n)\s*(\$|sudo\s|git\s|python3?\s|pip\s|npm\s|pnpm\s|yarn\s|llama-|ollama\s|curl\s|wget\s|apt\s|cd\s|ls\s|cat\s|cp\s|mv\s|chmod\s|make\s)/i;
|
||
const promptLanguagePattern =
|
||
/\b(language-(text|plaintext|md|markdown)|text|plaintext|markdown)\b/i;
|
||
const promptSignalPattern =
|
||
/\b(you are|guidelines|follow these|example|when provided|system prompt|tasked with)\b/i;
|
||
|
||
type ParsedSetting = {
|
||
key: string;
|
||
value: string;
|
||
};
|
||
|
||
type ZoomedImageState = {
|
||
src: string;
|
||
alt: string;
|
||
};
|
||
|
||
const quantizationExplorerToken = "<div data-quantization-explorer></div>";
|
||
const quantizationGridExplorerToken =
|
||
"<div data-quantization-grid-explorer></div>";
|
||
const objective5ChatToken = "<div data-objective5-chat></div>";
|
||
const lab8ChatToken = "<div data-lab8-chat></div>";
|
||
const lab3TerminalToken = "<div data-lab3-terminal></div>";
|
||
const lab1ConfidenceToken = "<div data-lab1-confidence></div>";
|
||
const lab1NetronToken = "<div data-lab1-netron-panel></div>";
|
||
const tokenizerPlaygroundToken = "<div data-tokenizer-playground></div>";
|
||
const inferenceSettingsVisualizationToken =
|
||
"<div data-inference-settings-visualization></div>";
|
||
const serviceTokenPattern =
|
||
/\{\{service-(url|address):([a-z0-9-]+)(?::([^}]+))?\}\}/g;
|
||
const serviceLabels: Record<string, string> = {
|
||
chunkviz: "ChunkViz",
|
||
"embedding-atlas": "Embedding Atlas",
|
||
"open-webui": "Open WebUI",
|
||
promptfoo: "Promptfoo",
|
||
ssh: "SSH",
|
||
unsloth: "Unsloth",
|
||
};
|
||
|
||
function looksLikeCliCommand(commandText: string, className: string) {
|
||
if (cliLanguagePattern.test(className)) return true;
|
||
return (
|
||
cliCommandPattern.test(commandText) || /--[a-z0-9-]+/i.test(commandText)
|
||
);
|
||
}
|
||
|
||
function looksLikePromptTextBlock(text: string, className: string) {
|
||
if (looksLikeCliCommand(text, className)) return false;
|
||
|
||
const normalizedText = text.trim();
|
||
if (!normalizedText) return false;
|
||
|
||
const lineCount = normalizedText.split("\n").length;
|
||
if (promptLanguagePattern.test(className) && normalizedText.length > 80)
|
||
return true;
|
||
if (lineCount >= 4 && promptSignalPattern.test(normalizedText)) return true;
|
||
if (lineCount >= 6 && /(^|\n)\s*[*-]\s+/.test(normalizedText)) return true;
|
||
return false;
|
||
}
|
||
|
||
function escapeRegex(value: string) {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
function escapeHtml(value: string) {
|
||
return value
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function parseSettingListItem(item: HTMLLIElement): ParsedSetting | null {
|
||
const keyElement = item.querySelector("code");
|
||
if (!keyElement) return null;
|
||
|
||
const key = (keyElement.textContent ?? "").replace(/\s+/g, " ").trim();
|
||
if (!key || key.length > 40) return null;
|
||
|
||
const text = (item.textContent ?? "").replace(/\s+/g, " ").trim();
|
||
const match = new RegExp(
|
||
`^${escapeRegex(key)}\\s*(?:-|–|—|:|=)\\s*(.+)$`,
|
||
).exec(text);
|
||
if (!match) return null;
|
||
|
||
const value = (match[1] ?? "").replace(/\s+/g, " ").trim();
|
||
if (!value || value.length > 36) return null;
|
||
if (/[.;]/.test(value) && value.length > 16) return null;
|
||
|
||
return { key, value };
|
||
}
|
||
|
||
function enhanceSettingsLists(root: HTMLElement) {
|
||
const lists = root.querySelectorAll<HTMLUListElement>("ul");
|
||
|
||
for (const list of lists) {
|
||
if (list.dataset.settingsEnhanced === "true") continue;
|
||
|
||
const items = Array.from(list.children).filter(
|
||
(node): node is HTMLLIElement => {
|
||
return node.tagName === "LI";
|
||
},
|
||
);
|
||
if (items.length < 2) continue;
|
||
|
||
const parsedItems = items.map((item) => parseSettingListItem(item));
|
||
if (parsedItems.some((parsedItem) => parsedItem === null)) continue;
|
||
|
||
const settings = parsedItems as ParsedSetting[];
|
||
const compactValueCount = settings.filter(
|
||
(setting) => setting.value.length <= 20,
|
||
).length;
|
||
if (compactValueCount < Math.max(2, Math.ceil(settings.length * 0.66)))
|
||
continue;
|
||
|
||
list.dataset.settingsEnhanced = "true";
|
||
list.classList.add("lab-settings-list");
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i];
|
||
const setting = settings[i];
|
||
if (!item || !setting) continue;
|
||
|
||
item.classList.add("lab-settings-item");
|
||
item.innerHTML =
|
||
`<span class="lab-setting-key">${escapeHtml(setting.key)}</span>` +
|
||
`<span class="lab-setting-value">${escapeHtml(setting.value)}</span>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
function ensureCopyButton(pre: HTMLPreElement, label: string) {
|
||
if (pre.dataset.copyEnhanced === "true") return;
|
||
|
||
pre.dataset.copyEnhanced = "true";
|
||
const copyButton = document.createElement("button");
|
||
copyButton.type = "button";
|
||
copyButton.className = "lab-copy-button";
|
||
copyButton.textContent = label;
|
||
copyButton.dataset.defaultLabel = label;
|
||
copyButton.setAttribute("aria-label", "Copy block to clipboard");
|
||
pre.appendChild(copyButton);
|
||
}
|
||
|
||
async function copyTextToClipboard(text: string) {
|
||
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(text);
|
||
return;
|
||
}
|
||
|
||
const activeElement =
|
||
document.activeElement instanceof HTMLElement
|
||
? document.activeElement
|
||
: null;
|
||
const selection = document.getSelection();
|
||
const previousRanges =
|
||
selection && selection.rangeCount > 0
|
||
? Array.from({ length: selection.rangeCount }, (_, index) =>
|
||
selection.getRangeAt(index).cloneRange(),
|
||
)
|
||
: [];
|
||
|
||
const textarea = document.createElement("textarea");
|
||
textarea.value = text;
|
||
textarea.setAttribute("readonly", "");
|
||
textarea.setAttribute("aria-hidden", "true");
|
||
textarea.style.position = "fixed";
|
||
textarea.style.top = "0";
|
||
textarea.style.left = "-9999px";
|
||
textarea.style.opacity = "0";
|
||
textarea.style.pointerEvents = "none";
|
||
|
||
document.body.appendChild(textarea);
|
||
textarea.focus();
|
||
textarea.select();
|
||
textarea.setSelectionRange(0, textarea.value.length);
|
||
|
||
try {
|
||
const didCopy = document.execCommand("copy");
|
||
if (!didCopy) {
|
||
throw new Error("Copy command was rejected");
|
||
}
|
||
} finally {
|
||
document.body.removeChild(textarea);
|
||
if (selection) {
|
||
selection.removeAllRanges();
|
||
for (const range of previousRanges) {
|
||
selection.addRange(range);
|
||
}
|
||
}
|
||
activeElement?.focus();
|
||
}
|
||
}
|
||
|
||
function enhanceHarnessSelectors(root: HTMLElement) {
|
||
const harnessButtons = Array.from(
|
||
root.querySelectorAll<HTMLButtonElement>("button[data-harness-choice]"),
|
||
);
|
||
const harnessBranches = Array.from(
|
||
root.querySelectorAll<HTMLElement>("[data-harness-branch]"),
|
||
);
|
||
|
||
if (harnessButtons.length === 0 || harnessBranches.length === 0) {
|
||
return () => {};
|
||
}
|
||
|
||
const supportedHarnesses = new Set(
|
||
harnessButtons
|
||
.map((button) => button.dataset.harnessChoice?.trim())
|
||
.filter((value): value is string => Boolean(value)),
|
||
);
|
||
|
||
let selectedHarness: string | null = null;
|
||
|
||
const syncHarnessSelection = () => {
|
||
for (const button of harnessButtons) {
|
||
const harnessId = button.dataset.harnessChoice?.trim() ?? "";
|
||
const isSelected =
|
||
selectedHarness !== null && harnessId === selectedHarness;
|
||
button.setAttribute("aria-pressed", isSelected ? "true" : "false");
|
||
button.dataset.selected = isSelected ? "true" : "false";
|
||
}
|
||
|
||
for (const branch of harnessBranches) {
|
||
const harnessId = branch.dataset.harnessBranch?.trim() ?? "";
|
||
const shouldHide =
|
||
selectedHarness === null || harnessId !== selectedHarness;
|
||
branch.hidden = shouldHide;
|
||
branch.setAttribute("aria-hidden", shouldHide ? "true" : "false");
|
||
}
|
||
};
|
||
|
||
syncHarnessSelection();
|
||
|
||
const handleHarnessClick = (event: Event) => {
|
||
const target = event.target as HTMLElement;
|
||
const button = target.closest<HTMLButtonElement>(
|
||
"button[data-harness-choice]",
|
||
);
|
||
if (!button || !root.contains(button)) return;
|
||
|
||
const harnessId = button.dataset.harnessChoice?.trim() ?? "";
|
||
if (!supportedHarnesses.has(harnessId)) return;
|
||
|
||
event.preventDefault();
|
||
selectedHarness = selectedHarness === harnessId ? null : harnessId;
|
||
syncHarnessSelection();
|
||
};
|
||
|
||
root.addEventListener("click", handleHarnessClick);
|
||
return () => {
|
||
root.removeEventListener("click", handleHarnessClick);
|
||
};
|
||
}
|
||
|
||
function resolveServiceTokenValue(
|
||
runtimeConfig: ResolvedCoursewareRuntimeConfig,
|
||
tokenType: string,
|
||
serviceId: string,
|
||
pathSuffix?: string,
|
||
) {
|
||
if (!isCoursewareServiceId(serviceId)) {
|
||
return null;
|
||
}
|
||
|
||
if (tokenType === "url") {
|
||
return resolveCoursewareServiceUrl(runtimeConfig, serviceId, pathSuffix);
|
||
}
|
||
|
||
if (tokenType === "address") {
|
||
return resolveCoursewareServiceAddress(runtimeConfig, serviceId);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function replaceServiceTokens(
|
||
root: HTMLElement,
|
||
runtimeConfig: ResolvedCoursewareRuntimeConfig,
|
||
) {
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||
acceptNode(node) {
|
||
if (!(node instanceof Text)) {
|
||
return NodeFilter.FILTER_REJECT;
|
||
}
|
||
|
||
if (!node.nodeValue?.includes("{{service-")) {
|
||
return NodeFilter.FILTER_REJECT;
|
||
}
|
||
|
||
const parent = node.parentElement;
|
||
if (!parent) {
|
||
return NodeFilter.FILTER_REJECT;
|
||
}
|
||
|
||
if (parent.closest("[data-widget-enhanced='true']")) {
|
||
return NodeFilter.FILTER_REJECT;
|
||
}
|
||
|
||
return NodeFilter.FILTER_ACCEPT;
|
||
},
|
||
});
|
||
|
||
const textNodes: Text[] = [];
|
||
let currentNode = walker.nextNode();
|
||
while (currentNode) {
|
||
if (currentNode instanceof Text) {
|
||
textNodes.push(currentNode);
|
||
}
|
||
currentNode = walker.nextNode();
|
||
}
|
||
|
||
for (const textNode of textNodes) {
|
||
const parent = textNode.parentElement;
|
||
const nodeValue = textNode.nodeValue;
|
||
if (!parent || !nodeValue) {
|
||
continue;
|
||
}
|
||
|
||
const allowLinks = !parent.closest("code, pre, a");
|
||
const nextTextValue = nodeValue.replace(
|
||
serviceTokenPattern,
|
||
(
|
||
fullMatch,
|
||
tokenType: string,
|
||
serviceId: string,
|
||
pathSuffix?: string,
|
||
) => {
|
||
const replacement = resolveServiceTokenValue(
|
||
runtimeConfig,
|
||
tokenType,
|
||
serviceId,
|
||
pathSuffix,
|
||
);
|
||
return replacement ?? fullMatch;
|
||
},
|
||
);
|
||
|
||
if (!allowLinks) {
|
||
if (nextTextValue !== nodeValue) {
|
||
textNode.nodeValue = nextTextValue;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
serviceTokenPattern.lastIndex = 0;
|
||
let lastIndex = 0;
|
||
let didReplace = false;
|
||
const fragment = document.createDocumentFragment();
|
||
let match = serviceTokenPattern.exec(nodeValue);
|
||
|
||
while (match) {
|
||
const [fullMatch, tokenType, serviceId, pathSuffix] = match;
|
||
const replacement = resolveServiceTokenValue(
|
||
runtimeConfig,
|
||
tokenType,
|
||
serviceId,
|
||
pathSuffix,
|
||
);
|
||
|
||
if (replacement === null) {
|
||
match = serviceTokenPattern.exec(nodeValue);
|
||
continue;
|
||
}
|
||
|
||
didReplace = true;
|
||
if (match.index > lastIndex) {
|
||
fragment.append(nodeValue.slice(lastIndex, match.index));
|
||
}
|
||
|
||
if (tokenType === "url") {
|
||
const link = document.createElement("a");
|
||
const serviceLabel = serviceLabels[serviceId] ?? replacement;
|
||
let visibleLabel = serviceLabel;
|
||
|
||
try {
|
||
const resolvedUrl = new URL(replacement);
|
||
if (resolvedUrl.port) {
|
||
visibleLabel = `${serviceLabel} on port ${resolvedUrl.port}`;
|
||
}
|
||
} catch {
|
||
visibleLabel = serviceLabel;
|
||
}
|
||
|
||
link.className = "lab-service-pill";
|
||
link.dataset.serviceId = serviceId;
|
||
link.href = replacement;
|
||
link.rel = "noreferrer";
|
||
link.target = "_blank";
|
||
link.title = replacement;
|
||
link.textContent = visibleLabel;
|
||
fragment.append(link);
|
||
} else {
|
||
fragment.append(replacement);
|
||
}
|
||
|
||
lastIndex = match.index + fullMatch.length;
|
||
match = serviceTokenPattern.exec(nodeValue);
|
||
}
|
||
|
||
if (!didReplace) {
|
||
continue;
|
||
}
|
||
|
||
if (lastIndex < nodeValue.length) {
|
||
fragment.append(nodeValue.slice(lastIndex));
|
||
}
|
||
|
||
textNode.replaceWith(fragment);
|
||
}
|
||
}
|
||
|
||
const LabContentArticle = memo(function LabContentArticle({
|
||
className,
|
||
html,
|
||
onZoomImage,
|
||
}: LabContentArticleProps) {
|
||
const containerRef = useRef<HTMLElement>(null);
|
||
const [runtimeConfig, setRuntimeConfig] = useState(() =>
|
||
normalizeCoursewareRuntimeConfig(),
|
||
);
|
||
const [isRuntimeResolved, setIsRuntimeResolved] = useState(false);
|
||
|
||
const renderedContent = html
|
||
.split(
|
||
new RegExp(
|
||
`(${escapeRegex(quantizationExplorerToken)}|${escapeRegex(quantizationGridExplorerToken)}|${escapeRegex(objective5ChatToken)}|${escapeRegex(lab8ChatToken)}|${escapeRegex(lab3TerminalToken)}|${escapeRegex(lab1ConfidenceToken)}|${escapeRegex(lab1NetronToken)}|${escapeRegex(tokenizerPlaygroundToken)}|${escapeRegex(inferenceSettingsVisualizationToken)})`,
|
||
"g",
|
||
),
|
||
)
|
||
.filter(Boolean)
|
||
.map((part, index) => {
|
||
if (part === quantizationExplorerToken) {
|
||
return <QuantizationExplorer key={`quantization-explorer-${index}`} />;
|
||
}
|
||
|
||
if (part === quantizationGridExplorerToken) {
|
||
return (
|
||
<QuantizationGridExplorer
|
||
key={`quantization-grid-explorer-${index}`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (part === objective5ChatToken) {
|
||
return <Objective5Chat key={`objective5-chat-${index}`} />;
|
||
}
|
||
|
||
if (part === lab8ChatToken) {
|
||
return <Lab8Chat key={`lab8-chat-${index}`} />;
|
||
}
|
||
|
||
if (part === lab3TerminalToken) {
|
||
return <Lab3TerminalFrame key={`lab3-terminal-${index}`} />;
|
||
}
|
||
|
||
if (part === lab1ConfidenceToken) {
|
||
return <Lab1ConfidenceChat key={`lab1-confidence-${index}`} />;
|
||
}
|
||
|
||
if (part === lab1NetronToken) {
|
||
return <Lab1NetronPanel key={`lab1-netron-${index}`} />;
|
||
}
|
||
|
||
if (part === tokenizerPlaygroundToken) {
|
||
return (
|
||
<TokenizerPlaygroundEmbed key={`tokenizer-playground-${index}`} />
|
||
);
|
||
}
|
||
|
||
if (part === inferenceSettingsVisualizationToken) {
|
||
return (
|
||
<InferenceSettingsVisualization
|
||
key={`inference-settings-viz-${index}`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Fragment key={`html-segment-${index}`}>
|
||
<div dangerouslySetInnerHTML={{ __html: part }} />
|
||
</Fragment>
|
||
);
|
||
});
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false;
|
||
|
||
void fetchCoursewareRuntimeConfig()
|
||
.then((nextRuntimeConfig) => {
|
||
if (isCancelled) return;
|
||
setRuntimeConfig(nextRuntimeConfig);
|
||
})
|
||
.catch(() => {
|
||
if (isCancelled) return;
|
||
setRuntimeConfig(normalizeCoursewareRuntimeConfig());
|
||
})
|
||
.finally(() => {
|
||
if (isCancelled) return;
|
||
setIsRuntimeResolved(true);
|
||
});
|
||
|
||
return () => {
|
||
isCancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const root = containerRef.current;
|
||
if (!root || !isRuntimeResolved) return;
|
||
|
||
replaceServiceTokens(root, runtimeConfig);
|
||
|
||
const preBlocks = root.querySelectorAll<HTMLPreElement>("pre");
|
||
for (const pre of preBlocks) {
|
||
const code = pre.querySelector<HTMLElement>("code");
|
||
if (!code) continue;
|
||
|
||
const blockText = code.textContent ?? "";
|
||
if (looksLikeCliCommand(blockText, code.className)) {
|
||
pre.classList.add("lab-cli-shell");
|
||
ensureCopyButton(pre, "Copy");
|
||
continue;
|
||
}
|
||
|
||
if (looksLikePromptTextBlock(blockText, code.className)) {
|
||
pre.classList.add("lab-prompt-card");
|
||
ensureCopyButton(pre, "Copy Text");
|
||
}
|
||
}
|
||
|
||
enhanceSettingsLists(root);
|
||
const cleanupHarnessSelectors = enhanceHarnessSelectors(root);
|
||
|
||
const handleRootClick = (event: Event) => {
|
||
const target = event.target as HTMLElement;
|
||
const button = target.closest<HTMLButtonElement>(
|
||
"button.lab-copy-button",
|
||
);
|
||
if (button) {
|
||
const pre = button.closest("pre");
|
||
const code = pre?.querySelector("code");
|
||
const commandText = code?.textContent?.trimEnd();
|
||
if (!commandText) return;
|
||
const defaultLabel = button.dataset.defaultLabel ?? "Copy";
|
||
|
||
void copyTextToClipboard(commandText)
|
||
.then(() => {
|
||
button.textContent = "Copied";
|
||
button.classList.add("is-copied");
|
||
window.setTimeout(() => {
|
||
button.textContent = defaultLabel;
|
||
button.classList.remove("is-copied");
|
||
}, 1200);
|
||
})
|
||
.catch(() => {
|
||
button.textContent = "Failed";
|
||
window.setTimeout(() => {
|
||
button.textContent = defaultLabel;
|
||
}, 1200);
|
||
});
|
||
return;
|
||
}
|
||
|
||
const image = target.closest<HTMLImageElement>("img");
|
||
if (!image || !root.contains(image)) return;
|
||
|
||
const src = image.getAttribute("src");
|
||
if (!src) return;
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
onZoomImage({
|
||
src,
|
||
alt: image.getAttribute("alt") ?? "",
|
||
});
|
||
};
|
||
|
||
root.addEventListener("click", handleRootClick);
|
||
return () => {
|
||
cleanupHarnessSelectors();
|
||
root.removeEventListener("click", handleRootClick);
|
||
};
|
||
}, [html, isRuntimeResolved, onZoomImage, runtimeConfig]);
|
||
|
||
return (
|
||
<article ref={containerRef} className={className}>
|
||
{renderedContent}
|
||
</article>
|
||
);
|
||
});
|
||
|
||
export function LabContent({ className, html }: LabContentProps) {
|
||
const [zoomedImage, setZoomedImage] = useState<ZoomedImageState | null>(null);
|
||
const handleZoomImage = useCallback((image: ZoomedImageState) => {
|
||
setZoomedImage(image);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!zoomedImage) return;
|
||
|
||
const previousOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
|
||
const activeElement = document.activeElement;
|
||
const previousFocusedElement =
|
||
activeElement instanceof HTMLElement ? activeElement : null;
|
||
|
||
const handleEscape = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
setZoomedImage(null);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleEscape);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleEscape);
|
||
document.body.style.overflow = previousOverflow;
|
||
previousFocusedElement?.focus();
|
||
};
|
||
}, [zoomedImage]);
|
||
|
||
return (
|
||
<>
|
||
<LabContentArticle
|
||
className={className}
|
||
html={html}
|
||
onZoomImage={handleZoomImage}
|
||
/>
|
||
{zoomedImage ? (
|
||
<div
|
||
className="lab-image-modal"
|
||
role="presentation"
|
||
onClick={() => setZoomedImage(null)}
|
||
>
|
||
<div
|
||
className="lab-image-modal__surface"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
className="lab-image-modal__image"
|
||
src={zoomedImage.src}
|
||
alt={zoomedImage.alt}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|