Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 125 additions & 34 deletions frontend/src/components/Common/ArtifactCompareModal.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,69 @@
import { ExternalLinkIcon } from "@chakra-ui/icons"
/**
* Modal for viewing an artifact with version comparison support.
Comment on lines +1 to 3
*
* Shows the artifact content alongside a version history panel. Users can
* select two commits to compare side-by-side.
*/
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Flex,
Box,
Text,
Heading,
Spinner,
Avatar,
Badge,
Code,
Box,
Button,
VStack,
Checkbox,
Code,
Divider,
useColorModeValue,
Flex,
Heading,
Icon,
IconButton,
Tooltip,
Link,
Textarea,
Avatar,
Tabs,
TabList,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Spinner,
Switch,
Tab,
TabPanels,
TabList,
TabPanel,
Icon,
Checkbox,
Switch,
TabPanels,
Tabs,
Text,
Textarea,
Tooltip,
VStack,
useColorModeValue,
} from "@chakra-ui/react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Link as RouterLink } from "@tanstack/react-router"
import { Suspense, lazy, useEffect, useState } from "react"
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"
import {
FaCheck,
FaChevronLeft,
FaChevronRight,
FaCodeBranch,
FaGithub,
FaLink,
FaUndo,
FaReply,
FaUndo,
} from "react-icons/fa"
import { ExternalLinkIcon } from "@chakra-ui/icons"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState, useEffect, lazy, Suspense } from "react"
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"

import FigureView from "../Figures/FigureView"
import PdfCanvas from "./PdfCanvas"
import FileContent from "../Files/FileContent"
import {
type ContentsItem,
type Figure,
type GitRef,
type Publication,
type Notebook,
type ContentsItem,
ProjectsService,
type Publication,
} from "../../client"
import useAuth from "../../hooks/useAuth"
import FigureView from "../Figures/FigureView"
import FileContent from "../Files/FileContent"
import PdfCanvas from "./PdfCanvas"
const IpynbRenderer = lazy(() =>
import("react-ipynb-renderer").then(async (m) => {
await import("react-ipynb-renderer/dist/styles/monokai.css")
Expand Down Expand Up @@ -132,7 +133,7 @@ function ArtifactContent({
}
return (
<Box height="100%" width="100%">
<FigureView figure={fig} />
<FigureView figure={fig} fillHeight />
</Box>
)
}
Expand Down Expand Up @@ -288,6 +289,81 @@ function useArtifactAtRef(
})
}

/** Info panel for a figure, mirroring the publications page layout. */
function FigureInfo({
figure,
ownerName,
projectName,
}: {
figure: Figure
ownerName: string
projectName: string
}) {
const secBgColor = useColorModeValue("ui.secondary", "ui.darkSlate")
// Typed as plain string so the router's typed `to` prop accepts them.
const filesTo: string = `/${ownerName}/${projectName}/files`
const pipelineTo: string = `/${ownerName}/${projectName}/pipeline`
return (
<Box bg={secBgColor} borderRadius="lg" p={3} mb={3} h="fit-content">
<Heading size="sm" mb={2}>
Info
</Heading>
{figure.title && (
<Text fontSize="sm" mb={1}>
<Text as="span" fontWeight="semibold">
Title:
</Text>{" "}
<Text as="span" color="gray.500">
{figure.title}
</Text>
</Text>
)}
{figure.description && (
<Text fontSize="sm" mb={1}>
<Text as="span" fontWeight="semibold">
Description:
</Text>{" "}
<Text as="span" color="gray.500">
{figure.description}
</Text>
</Text>
)}
<Text fontSize="sm" mb={1}>
<Text as="span" fontWeight="semibold">
Path:
</Text>{" "}
<Link
as={RouterLink}
to={filesTo}
search={{ path: figure.path } as any}
>
{figure.path}
</Link>
</Text>
<Text fontSize="sm" mb={1}>
<Text as="span" fontWeight="semibold">
Pipeline stage:
</Text>{" "}
{figure.stage ? (
<Link
as={RouterLink}
to={pipelineTo}
search={{ stage: figure.stage } as any}
>
<Code fontSize="xs" cursor="pointer">
{figure.stage}
</Code>
</Link>
) : (
<Text as="span" color="red.500">
Not in pipeline
</Text>
)}
</Text>
</Box>
)
}

function FigureComments({
ownerName,
projectName,
Expand Down Expand Up @@ -756,6 +832,14 @@ export function ArtifactCompareModal({

const isComparing = Boolean(ref2)

// Figure metadata (title/description/stage) for the info panel, sourced from
// the figure at the displayed ref, falling back to the one we opened with.
const figureInfo =
kind === "figure"
? (displayData1 as Figure | undefined) ??
(initialArtifact as Figure | undefined)
: undefined

const getShareUrl = () => {
const url = new URL(window.location.href)
if (ref1) url.searchParams.set("base_ref", ref1)
Expand Down Expand Up @@ -1149,6 +1233,13 @@ export function ArtifactCompareModal({
pl={3}
overflowY="auto"
>
{figureInfo && (
<FigureInfo
figure={figureInfo}
ownerName={ownerName}
projectName={projectName}
/>
)}
<FigureComments
ownerName={ownerName}
projectName={projectName}
Expand Down
66 changes: 62 additions & 4 deletions frontend/src/components/Common/Mermaid.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
import { useEffect, useRef } from "react"
import { zoom, zoomIdentity, ZoomBehavior, D3ZoomEvent } from "d3-zoom"
import { Box, Flex, IconButton } from "@chakra-ui/react"
import { select } from "d3-selection"
import { Box, IconButton, Flex } from "@chakra-ui/react"
import { FaHome, FaExpandAlt } from "react-icons/fa"
import {
type D3ZoomEvent,
type ZoomBehavior,
zoom,
zoomIdentity,
} from "d3-zoom"
import { useEffect, useRef, useState } from "react"
import { FaExpandAlt, FaHome } from "react-icons/fa"

interface MermaidProps {
children: string
isDiagramExpanded: boolean
setIsDiagramExpanded: Function
/** Pan/zoom the diagram to center the node for this pipeline stage. */
zoomToStage?: string
}

const Mermaid = ({
children,
isDiagramExpanded,
setIsDiagramExpanded,
zoomToStage,
}: MermaidProps) => {
const zoomBehaviorRef = useRef<ZoomBehavior<Element, unknown> | null>(null)
// Bumped each time the diagram finishes rendering so the zoom-to-stage
// effect can re-run against the freshly drawn SVG.
const [renderTick, setRenderTick] = useState(0)

const handleResetZoom = () => {
const svgSelection = select<Element, unknown>(".mermaid svg")
Expand Down Expand Up @@ -54,6 +65,7 @@ const Mermaid = ({

svgSelection.call(zoomBehavior)
zoomBehaviorRef.current = zoomBehavior
setRenderTick((t) => t + 1)
} catch (error) {
console.error("Error rendering Mermaid diagram:", error)
}
Expand All @@ -64,6 +76,52 @@ const Mermaid = ({
}
}, [children])

// Pan/zoom to the requested stage's node once the diagram is rendered.
useEffect(() => {
if (!zoomToStage || !zoomBehaviorRef.current) return
const svgEl = select<SVGSVGElement, unknown>(".mermaid svg").node()
const gEl = svgEl?.querySelector("g")
if (!svgEl || !gEl) return
const nodes = Array.from(svgEl.querySelectorAll<SVGGElement>(".node"))
const label = (n: SVGGElement) => (n.textContent ?? "").trim()
const match =
nodes.find((n) => label(n) === zoomToStage) ??
nodes.find((n) => label(n).split("@")[0] === zoomToStage)
if (!match) return
const gCTM = gEl.getCTM()
const nCTM = match.getCTM()
if (!gCTM || !nCTM) return
// Node center in the coordinate space the zoom transform writes into
// (g's parent / SVG user space). g's own transform cancels out here.
const m = gCTM.inverse().multiply(nCTM)
const bbox = match.getBBox()
let pt = svgEl.createSVGPoint()
pt.x = bbox.x + bbox.width / 2
pt.y = bbox.y + bbox.height / 2
pt = pt.matrixTransform(m)
const vb = svgEl.viewBox.baseVal
const hasVb = vb != null && vb.width > 0
const vbW = hasVb ? vb.width : svgEl.clientWidth
const vbH = hasVb ? vb.height : svgEl.clientHeight
const cx = (hasVb ? vb.x : 0) + vbW / 2
const cy = (hasVb ? vb.y : 0) + vbH / 2
// Scale so the node fills ~45% of the view, clamped to a sane range.
const k = Math.max(
1,
Math.min(
2.5,
(vbW * 0.45) / (bbox.width || 1),
(vbH * 0.45) / (bbox.height || 1),
),
)
const tx = cx - k * pt.x
const ty = cy - k * pt.y
select<Element, unknown>(svgEl).call(
zoomBehaviorRef.current.transform,
zoomIdentity.translate(tx, ty).scale(k),
)
}, [zoomToStage, renderTick])

return (
<Box
borderRadius="lg"
Expand Down
Loading