Skip to content
Open
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
7 changes: 7 additions & 0 deletions backend/src/features/validationServices/errorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export const ERROR_MESSAGES = {
// Pattern: "head.next and second should point to the same object"
reference_should_match: (pathA: string, pathB: string) =>
`${pathA} and ${pathB} should point to the same object`,

// Pattern: "third.next should point to the same None object as head.next"
reference_should_match_with_expected: (
targetPath: string,
sourcePath: string,
expectedLabel: string
) => `${targetPath} should point to the same ${expectedLabel} as ${sourcePath}`,

// Pattern: "head.next and second should not point to the same object"
reference_should_differ: (pathA: string, pathB: string) =>
Expand Down
19 changes: 16 additions & 3 deletions backend/src/features/validationServices/validateAnswer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ function extractPathElementIds(
const idMap = (m: MemoryBox[]) =>
new Map(m.filter((e) => e.id !== null).map((e) => [e.id as number, e]));

function describeExpectedReference(answerID: number, answerMap: Map<number, MemoryBox>): string {
const answerBox = answerMap.get(answerID);
if (!answerBox) return "object";
if (isNoneType(answerBox.type) || isNoneValue(answerBox.value)) return "None object";
if (isClassInstanceType(answerBox.type)) {
return answerBox.name ? `${answerBox.name} object` : "object";
}
return `${formatTypeForUser(answerBox.type)} object`;
}

// Ensure a bijection between answer and input IDs
function ensureBijection(
answerID: number,
Expand All @@ -139,9 +149,12 @@ function ensureBijection(
if (prev.target !== inputID) {
if (!isVar) {
const previousPath = cleanPathForUser(prev.path);
const answerBox = answerMap.get(answerID);

const message = ERROR_MESSAGES.reference_should_match(previousPath, currentPath);
const expectedLabel = describeExpectedReference(answerID, answerMap);
const message = ERROR_MESSAGES.reference_should_match_with_expected(
currentPath,
previousPath,
expectedLabel
);

errors.push(
makeFeedbackError(ErrorType.REFERENCE_MISMATCH, message, {
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/features/canvas/Canvas.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
display: flex;
justify-content: center;
align-items: flex-start;
padding: 12px 0px;
height: 100vh;
padding: 12px 0 0;
height: 100%;
background-color: var(--bg-tertiary);
box-sizing: border-box;
transition: background-color 200ms ease;
position: relative;
}

/* Main SVG canvas */
Expand All @@ -16,10 +17,7 @@
height: 100%;
display: block;
background: var(--canvas-bg);
border: 1px solid var(--border-primary);
border-radius: 10px;
box-shadow: var(--shadow-sm);
transition: background-color 200ms ease, border-color 200ms ease;
transition: background-color 200ms ease;
}

/* Override memory-viz box background fills for light mode */
Expand Down
101 changes: 61 additions & 40 deletions frontend/src/features/canvas/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ interface CanvasProps {
pythonTutorReferenceArrows?: boolean;
pythonTutorStandalonePrimitives?: boolean;
isQuestionMode?: boolean;
preserveCollapsedWorkspace?: boolean;
workspaceHeightOffset?: number;
}

function Canvas({
Expand All @@ -211,6 +213,8 @@ function Canvas({
pythonTutorReferenceArrows = false,
pythonTutorStandalonePrimitives = false,
isQuestionMode = false,
preserveCollapsedWorkspace = false,
workspaceHeightOffset = 0,
}: CanvasProps) {
const [openEditors, setOpenEditors] = useState<CanvasElement[]>([]);
const [selectedElement, setSelectedElement] = useState<CanvasElement | null>(
Expand Down Expand Up @@ -291,53 +295,74 @@ function Canvas({
[svgRef]
);

const functionFrames = elements.filter((el) => el.kind.name === "function");
const elementsById = useMemo(() => createElementsByIdMap(elements), [elements]);
const visibleObjects = useMemo(
() =>
elements.filter((el) => {
if (el.kind.name === "function") return false;
return !(
useInlinePythonTutorPrimitives && el.kind.name === "primitive"
);
}),
[elements, useInlinePythonTutorPrimitives]
);

// Canvas sizing and viewBox management (pre-paint to avoid flicker)
useLayoutEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const height = Math.max(1, rect.height);
const wrapper = wrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const width = Math.max(1, rect.width);
setCanvasHeight(height);
baseDims.current = { width, height };
applyViewBox(width, height, scale);
}, [svgRef, scale, applyViewBox]);

// Handle canvas width changes while preserving height
const viewportHeight = Math.max(1, rect.height);
const renderedHeight = preserveCollapsedWorkspace
? Math.max(viewportHeight + workspaceHeightOffset, viewportHeight)
: viewportHeight;
setCanvasHeight(viewportHeight);
baseDims.current = { width, height: renderedHeight };
applyViewBox(width, renderedHeight, scale);
}, [scale, applyViewBox, preserveCollapsedWorkspace, workspaceHeightOffset]);

// Handle canvas size changes so vertical split panels can safely resize
// the workspace without leaving the SVG on a stale height.
useEffect(() => {
if (!canvasHeight) return;

const svg = svgRef.current;
if (!svg) return;
const wrapper = wrapperRef.current;
if (!wrapper) return;

let animationFrame = 0;
let previousWidth = -1;
let previousHeight = -1;

const updateWidth = () => {
const width = Math.max(
1,
svg.clientWidth || svg.getBoundingClientRect().width
);
const updateSize = () => {
const rect = wrapper.getBoundingClientRect();
const width = Math.max(1, rect.width);
const viewportHeight = Math.max(1, rect.height);
const renderedHeight = preserveCollapsedWorkspace
? Math.max(viewportHeight + workspaceHeightOffset, viewportHeight)
: viewportHeight;

if (width !== previousWidth) {
if (width !== previousWidth || renderedHeight !== previousHeight) {
previousWidth = width;
baseDims.current = { width, height: canvasHeight };
applyViewBox(width, canvasHeight, scale);
previousHeight = renderedHeight;
setCanvasHeight(viewportHeight);
baseDims.current = { width, height: renderedHeight };
applyViewBox(width, renderedHeight, scale);
}
};

const resizeObserver = new ResizeObserver(() => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(updateWidth);
animationFrame = requestAnimationFrame(updateSize);
});

resizeObserver.observe(svg);
resizeObserver.observe(wrapper);
updateSize();

return () => {
cancelAnimationFrame(animationFrame);
resizeObserver.disconnect();
};
}, [canvasHeight, svgRef, scale, applyViewBox]);
}, [scale, applyViewBox, preserveCollapsedWorkspace, workspaceHeightOffset]);

// Re-apply viewBox when scale changes
useEffect(() => {
Expand Down Expand Up @@ -585,19 +610,6 @@ function Canvas({
return () => window.removeEventListener("keydown", handleKeyDown);
}, [openEditors.length]);

const functionFrames = elements.filter((el) => el.kind.name === "function");
const elementsById = useMemo(() => createElementsByIdMap(elements), [elements]);
const visibleObjects = useMemo(
() =>
elements.filter((el) => {
if (el.kind.name === "function") return false;
return !(
useInlinePythonTutorPrimitives && el.kind.name === "primitive"
);
}),
[elements, useInlinePythonTutorPrimitives]
);

useEffect(() => {
if (!useInlinePythonTutorPrimitives) {
return;
Expand Down Expand Up @@ -665,7 +677,10 @@ function Canvas({
<div
ref={wrapperRef}
className={styles.canvasWrapper}
style={{ overflowX: "hidden" }}
style={{
overflowX: "hidden",
overflowY: preserveCollapsedWorkspace ? "auto" : "hidden",
}}
>
<svg
data-testid="canvas"
Expand All @@ -675,7 +690,13 @@ function Canvas({
className={styles.canvas}
style={{
width: "100%",
height: canvasHeight ?? undefined,
height: preserveCollapsedWorkspace
? `${Math.max(
canvasHeight ?? 0,
(canvasHeight ?? 0) + workspaceHeightOffset
)}px`
: "100%",
minHeight: canvasHeight ?? undefined,
display: "block",
padding: 0,
border: 0,
Expand Down
31 changes: 30 additions & 1 deletion frontend/src/features/canvas/components/CallStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface CallStackProps {
onSelect: (element: CanvasElement) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
onWidthChange?: (width: number) => void;
onBoundsChange?: (bounds: { top: number; bottom: number }) => void;
x?: number;
y?: number;
width?: number;
Expand Down Expand Up @@ -73,6 +74,7 @@ const CallStack: React.FC<CallStackProps> = ({
onSelect,
onReorder,
onWidthChange,
onBoundsChange,
x = 20,
y = 73,
width = 205,
Expand All @@ -83,7 +85,7 @@ const CallStack: React.FC<CallStackProps> = ({
elementsById,
}) => {
const clipPathId = useId();

const [viewportHeight] = useState<number>(() => window.innerHeight);

const yPosition = y;
Expand Down Expand Up @@ -211,6 +213,33 @@ const CallStack: React.FC<CallStackProps> = ({
[layout, scrollPosition]
);

useEffect(() => {
const clipTop = yPosition + HEADER_HEIGHT;
const clipBottom =
yPosition + HEADER_HEIGHT + TOP_PADDING + visibleHeight + BOTTOM_PADDING;

const visibleFrameBottoms = layout
.map(({ yLocal, h }) => {
const frameTop = yLocal + scrollPosition + VERTICAL_OFFSET - h / 2;
const frameBottom = yLocal + scrollPosition + VERTICAL_OFFSET + h / 2;

if (frameBottom < clipTop || frameTop > clipBottom) {
return null;
}

return Math.min(frameBottom, clipBottom);
})
.filter((value): value is number => value !== null);

onBoundsChange?.({
top: yPosition,
bottom:
visibleFrameBottoms.length > 0
? Math.max(...visibleFrameBottoms)
: yPosition,
});
}, [layout, onBoundsChange, scrollPosition, visibleHeight, yPosition]);

const handlePointerDown = useCallback(
(index: number) => (event: React.PointerEvent<SVGGElement>) => {
if (isLockedMainFrame(orderedFrames[index])) {
Expand Down
Loading