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
78 changes: 49 additions & 29 deletions frontend/src/features/canvas/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import React, {
useRef,
useCallback,
useLayoutEffect,
useMemo,
} from "react";
import Draggable from "react-draggable";
import { CanvasElement, BoxType, ID } from "../shared/types";
Expand All @@ -14,6 +13,11 @@ import BoxEditor from "../editors/boxEditor/BoxEditor";
import CallStack from "./components/CallStack";
import { useCanvasRefs } from "./hooks/useCanvas";
import { validateElements } from "./utils/validation";
import {
QUESTION_MAIN_FRAME_NAME,
isLockedMainFrame,
reorderFunctionFramesWithLockedMain,
} from "../memoryModelEditor/utils/questionFrames";
import styles from "./Canvas.module.css";

const EDITOR_MAP: Record<BoxType["name"], React.FC<any>> = {
Expand Down Expand Up @@ -46,6 +50,9 @@ interface FloatingEditorProps {
elements: CanvasElement[];
editorScale: number;
questionFunctionNames?: string[];
isLockedMainFrame?: boolean;
reservedFunctionNames?: string[];
isQuestionMode?: boolean;
}

function FloatingEditor({
Expand All @@ -68,6 +75,9 @@ function FloatingEditor({
elements,
editorScale,
questionFunctionNames,
isLockedMainFrame: lockMainFrame = false,
reservedFunctionNames,
isQuestionMode = false,
}: FloatingEditorProps) {
const nodeRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -115,6 +125,9 @@ function FloatingEditor({
canManageFunctions={canManageFunctions ?? sandbox}
elements={elements}
questionFunctionNames={questionFunctionNames}
isLockedMainFrame={lockMainFrame}
reservedFunctionNames={reservedFunctionNames}
isQuestionMode={isQuestionMode}
/>
</div>
</div>
Expand All @@ -140,6 +153,7 @@ interface CanvasProps {
onScaleChange?: (scale: number) => void;
editorScale?: number;
questionFunctionNames?: string[];
isQuestionMode?: boolean;
}

function Canvas({
Expand All @@ -158,6 +172,7 @@ function Canvas({
scale: externalScale,
editorScale = 1,
questionFunctionNames,
isQuestionMode = false,
}: CanvasProps) {
const [openEditors, setOpenEditors] = useState<CanvasElement[]>([]);
const [selectedElement, setSelectedElement] = useState<CanvasElement | null>(
Expand All @@ -169,6 +184,10 @@ function Canvas({

// Use external scale if provided, otherwise use internal
const scale = externalScale !== undefined ? externalScale : internalScale;
const isProtectedMainFrame = useCallback(
(element: CanvasElement) => isQuestionMode && isLockedMainFrame(element),
[isQuestionMode]
);

const { svgRef } = useCanvasRefs();
const wrapperRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -278,13 +297,6 @@ function Canvas({
}, [elements]);

// Validate elements whenever they change
// Create a stable signature of elements for comparison
const elementsSignature = useMemo(() => {
return elements.map(el =>
`${el.boxId}-${el.id}-${el.invalidated || false}-${typeof el.kind.value === 'object' ? JSON.stringify(el.kind.value) : el.kind.value}`
).join('|');
}, [elements]);

useEffect(() => {
const validatedElements = validateElements(elements);

Expand All @@ -307,7 +319,7 @@ function Canvas({
if (hasChanges) {
setElements(validatedElements);
}
}, [elementsSignature]); // Only depend on the signature, not elements directly
}, [elements, setElements]);

const createPositionUpdater = useCallback(
(boxId: number) => (x: number, y: number) => {
Expand Down Expand Up @@ -353,7 +365,7 @@ function Canvas({
return [...prev, newElement];
});
},
[elements, ids, sandbox, svgRef, setElements]
[ids, sandbox, svgRef, setElements]
);

const saveElement = useCallback(
Expand All @@ -366,27 +378,40 @@ function Canvas({
setElements((prev) =>
prev.map((el) => {
if (el.boxId !== boxId) return el;
const updated = { ...el, id: updatedId, kind: updatedKind };
const nextKind =
isProtectedMainFrame(el) && updatedKind.name === "function"
? {
...updatedKind,
functionName: QUESTION_MAIN_FRAME_NAME,
}
: updatedKind;
const updated = { ...el, id: updatedId, kind: nextKind };
return invalidated !== undefined
? { ...updated, invalidated }
? {
...updated,
invalidated: isProtectedMainFrame(el) ? false : invalidated,
}
: updated;
})
);
},
[setElements]
[isProtectedMainFrame, setElements]
);

const removeElement = useCallback(
(boxId: number) => {
const removed = elements.find((el) => el.boxId === boxId);
if (removed && isProtectedMainFrame(removed)) {
return;
}
if (removed && typeof removed.id === "number") {
removeId(removed.id);
}
setElements((prev) => prev.filter((el) => el.boxId !== boxId));
setOpenEditors((prev) => prev.filter((el) => el.boxId !== boxId));
setSelectedElement((prev) => (prev?.boxId === boxId ? null : prev));
},
[elements, setElements, removeId]
[elements, isProtectedMainFrame, setElements, removeId]
);

const openElementEditor = useCallback((element: CanvasElement) => {
Expand Down Expand Up @@ -426,20 +451,9 @@ function Canvas({
(fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return;

setElements((prev) => {
const functionIndices = prev
.map((el, i) => ({ el, i }))
.filter(({ el }) => el.kind.name === "function");

const sourceIndex = functionIndices[fromIndex].i;
const targetIndex = functionIndices[toIndex].i;

const reordered = [...prev];
const [movedElement] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, movedElement);

return reordered;
});
setElements((prev) =>
reorderFunctionFramesWithLockedMain(prev, fromIndex, toIndex)
);
},
[setElements]
);
Expand Down Expand Up @@ -527,6 +541,11 @@ function Canvas({
elements={elements}
editorScale={editorScale}
questionFunctionNames={questionFunctionNames}
isLockedMainFrame={isProtectedMainFrame(element)}
reservedFunctionNames={
isQuestionMode ? [QUESTION_MAIN_FRAME_NAME] : undefined
}
isQuestionMode={isQuestionMode}
/>
);
})}
Expand Down Expand Up @@ -609,7 +628,8 @@ const areEqual = (prev: Readonly<CanvasProps>, next: Readonly<CanvasProps>) => {
prev.sandbox === next.sandbox &&
prev.scale === next.scale &&
prev.editorScale === next.editorScale &&
prev.questionFunctionNames === next.questionFunctionNames
prev.questionFunctionNames === next.questionFunctionNames &&
prev.isQuestionMode === next.isQuestionMode
);
};

Expand Down
25 changes: 13 additions & 12 deletions frontend/src/features/canvas/components/CallStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
BUTTON_HEIGHT,
ADDITIONAL_HEIGHT_OFFSET,
} from "../constants";
import { isLockedMainFrame } from "../../memoryModelEditor/utils/questionFrames";

interface CallStackProps {
frames: CanvasElement[];
Expand Down Expand Up @@ -115,15 +116,6 @@ const CallStack: React.FC<CallStackProps> = ({
columnHeight - HEADER_HEIGHT - TOP_PADDING - BOTTOM_PADDING
);

// Log for debugging - remove after verification
console.log('CallStack centering:', {
viewportHeight,
yPosition,
bottomSpacing,
columnHeight,
'Should be equal': yPosition === bottomSpacing
});

const layout = useMemo((): LayoutItem[] => {
let yOffset = 0;
const baseY = yPosition + HEADER_HEIGHT + TOP_PADDING + visibleHeight;
Expand Down Expand Up @@ -176,6 +168,10 @@ const CallStack: React.FC<CallStackProps> = ({
const dragState = useRef<DragState | null>(null);
const [insertIndex, setInsertIndex] = useState<number | null>(null);
const [dropMarkerY, setDropMarkerY] = useState<number | null>(null);
const lockedFrameIndex = useMemo(
() => layout.findIndex(({ f }) => isLockedMainFrame(f)),
[layout]
);

const computeDropPosition = useCallback(
(ghostCenterY: number, draggedIndex: number) => {
Expand Down Expand Up @@ -206,6 +202,10 @@ const CallStack: React.FC<CallStackProps> = ({

const handlePointerDown = useCallback(
(index: number) => (event: React.PointerEvent<SVGGElement>) => {
if (isLockedMainFrame(orderedFrames[index])) {
return;
}

event.preventDefault();
event.stopPropagation();

Expand All @@ -220,7 +220,7 @@ const CallStack: React.FC<CallStackProps> = ({
active: false,
};
},
[]
[orderedFrames]
);

const handlePointerMove: React.PointerEventHandler = useCallback(
Expand Down Expand Up @@ -395,7 +395,7 @@ const CallStack: React.FC<CallStackProps> = ({
transform={`translate(0, ${
yLocal + scrollPosition + VERTICAL_OFFSET
})`}
style={{ cursor: "grab" }}
style={{ cursor: isLockedMainFrame(frame) ? "default" : "grab" }}
onPointerDown={handlePointerDown(index)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
Expand Down Expand Up @@ -429,7 +429,8 @@ const CallStack: React.FC<CallStackProps> = ({
</g>
))}

{dropMarkerY !== null && (
{dropMarkerY !== null &&
!(lockedFrameIndex === 0 && insertIndex === layout.length - 1) && (
<rect
className={styles.dropMarker}
x={-columnWidth / 2 + 10}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/features/editors/boxEditor/BoxEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const BoxEditorModule = ({
canManageFunctions = sandbox,
elements = [],
questionFunctionNames,
isLockedMainFrame = false,
reservedFunctionNames,
}: BoxEditorType) => {
// Shared hover state for remove button
const { hoverRemove, setHoverRemove } = useGlobalStates();
Expand Down Expand Up @@ -129,6 +131,8 @@ const BoxEditorModule = ({
canManageFunctions={canManageFunctions}
elements={elements}
onClose={onClose}
isLockedMainFrame={isLockedMainFrame}
reservedFunctionNames={reservedFunctionNames}
/>

{/* Middle section: editable content */}
Expand Down Expand Up @@ -171,6 +175,8 @@ const BoxEditorModule = ({
onToggleInvalidate={setInvalidated}
ownClassVariables={ownClassVariables}
items={collectionItems}
disableRemove={isLockedMainFrame}
disableInvalidate={isLockedMainFrame}
/>
</div>
</div>
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/features/editors/boxEditor/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ interface Props {
removeClasses?: (className: string) => void;
elements?: any[];
onClose: () => void;
isLockedMainFrame?: boolean;
reservedFunctionNames?: string[];
}

const KIND_LABELS: Record<string, string> = {
Expand Down Expand Up @@ -67,6 +69,8 @@ const Header = ({
removeClasses = () => {},
elements = [],
onClose,
isLockedMainFrame = false,
reservedFunctionNames = [],
}: Props) => {
const kind = element.kind.name;
const titleLabel = KIND_LABELS[kind] ?? kind;
Expand All @@ -80,6 +84,9 @@ const Header = ({
: KIND_LABELS[kind] ?? kind;

const handleFunctionAdd = (name: string) => {
if (reservedFunctionNames.includes(name)) {
return;
}
if (!functionNames.includes(name)) {
setFunctionNames((prev) => [...prev, name]);
}
Expand Down Expand Up @@ -131,9 +138,10 @@ const Header = ({
onAdd={handleFunctionAdd}
onRemove={handleFunctionRemove}
buttonClassName={styles.moduleIdBox}
editable={true}
editable={!isLockedMainFrame}
sandbox={sandbox}
canManageFunctions={canManageFunctions}
reservedNames={reservedFunctionNames}
/>
) : (
<span className={styles.typeChip}>{typeLabel}</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ButtonDisplays from "./ButtonDisplays";

describe("ButtonDisplays", () => {
it("disables remove and invalidate actions for the protected main frame", async () => {
const onSave = jest.fn();
const onRemove = jest.fn();
const onToggleInvalidate = jest.fn();

render(
<ButtonDisplays
element={{
id: "_",
kind: {
name: "function",
type: "function",
},
}}
onSave={onSave}
onToggleInvalidate={onToggleInvalidate}
invalidated={false}
onRemove={onRemove}
dataType="function"
value=""
hoverRemove={false}
setHoverRemove={jest.fn()}
functionName="__main__"
functionParams={[]}
items={[]}
disableRemove={true}
disableInvalidate={true}
/>
);

const invalidateButton = screen.getByRole("button", {
name: "Invalidate",
});
const removeButton = screen.getByRole("button", {
name: "Remove Box",
});

expect(invalidateButton).toBeDisabled();
expect(removeButton).toBeDisabled();

userEvent.click(invalidateButton);
userEvent.click(removeButton);

expect(onToggleInvalidate).not.toHaveBeenCalled();
expect(onSave).not.toHaveBeenCalled();
expect(onRemove).not.toHaveBeenCalled();
});
});
Loading