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
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@

.canvasColumn {
flex: 1;
min-width: 0;
min-width: 780px;
min-height: 0;
position: relative;
display: flex;
Expand Down
143 changes: 143 additions & 0 deletions frontend/src/features/memoryModelEditor/MemoryModelEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import MemoryModelEditor from "./MemoryModelEditor";

jest.mock("../canvas/Canvas", () => ({
__esModule: true,
default: () => <div data-testid="canvas">Canvas</div>,
}));

jest.mock("../palette/Palette", () => ({
__esModule: true,
default: () => <div data-testid="palette">Palette</div>,
}));

jest.mock("./components/ConfirmationModal", () => ({
__esModule: true,
default: () => null,
}));

jest.mock("../informationTabs/InformationTabs", () => ({
__esModule: true,
default: () => <div data-testid="information-tabs">Information</div>,
}));

jest.mock("./components/PanelToggleButtons", () => ({
__esModule: true,
default: () => null,
}));

jest.mock("./hooks/useResponsivePanels", () => ({
useResponsivePanels: () => undefined,
}));

jest.mock("./hooks/useCanvasSubmission", () => ({
useCanvasSubmission: () => ({
handleCanvasSubmit: jest.fn(),
handleCanvasSubmitAtLine: jest.fn(),
}),
}));

jest.mock("./hooks/useLocalStorage", () => ({
useCanvasLocalStorage: () => undefined,
useUILocalStorage: () => undefined,
}));

jest.mock("./hooks/useUndoHistory", () => ({
useUndoHistory: () => ({
canUndo: false,
canRedo: false,
undo: jest.fn(),
redo: jest.fn(),
recordState: jest.fn(),
clearHistory: jest.fn(),
}),
}));

class ResizeObserverMock {
observe() {}
disconnect() {}
unobserve() {}
}

describe("MemoryModelEditor info panel resizing", () => {
let containerWidth = 1200;
let innerWidthDescriptor: PropertyDescriptor | undefined;
let rectSpy: jest.SpyInstance<DOMRect, [], HTMLElement>;

beforeAll(() => {
(global as typeof globalThis).ResizeObserver =
ResizeObserverMock as unknown as typeof ResizeObserver;
});

beforeEach(() => {
localStorage.clear();
containerWidth = 1200;
innerWidthDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth");
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 1600,
});
rectSpy = jest
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockImplementation(function mockRect(this: HTMLElement) {
const isMainContainer =
typeof this.className === "string" &&
this.className.split(" ").includes("mainContainer");
const width = isMainContainer ? containerWidth : 0;

return {
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: 0,
width,
height: 0,
toJSON: () => ({}),
} as DOMRect;
});
});

afterEach(() => {
rectSpy.mockRestore();
if (innerWidthDescriptor) {
Object.defineProperty(window, "innerWidth", innerWidthDescriptor);
}
});

it("keeps the information panel at its minimum width when a drag ends too small to settle", () => {
containerWidth = 1048;
const { container } = render(<MemoryModelEditor />);

const infoPanel = container.querySelector(".infoPanel") as HTMLElement;
const resizeDividers = container.querySelectorAll(".resizeDivider");
const infoResizeDivider = resizeDividers[resizeDividers.length - 1] as HTMLElement;

fireEvent.mouseDown(infoResizeDivider);
fireEvent.mouseMove(document, { clientX: 898 });
fireEvent.mouseUp(document, { clientX: 898 });

expect(infoPanel.style.width).toBe("260px");
});

it("caps the information panel so the canvas keeps its minimum column width", () => {
containerWidth = 1200;
const { container } = render(<MemoryModelEditor />);

const infoPanel = container.querySelector(".infoPanel") as HTMLElement;
const canvasColumn = container.querySelector(".canvasColumn") as HTMLElement;
const resizeDividers = container.querySelectorAll(".resizeDivider");
const infoResizeDivider = resizeDividers[resizeDividers.length - 1] as HTMLElement;

fireEvent.mouseDown(infoResizeDivider);
fireEvent.mouseMove(document, { clientX: 0 });
fireEvent.mouseUp(document, { clientX: 0 });

expect(infoPanel.style.width).toBe("412px");
expect(containerWidth - parseInt(infoPanel.style.width, 10) - 8).toBe(780);
expect(canvasColumn).toBeInTheDocument();
});
});
77 changes: 72 additions & 5 deletions frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ import { spreadOverlappingElements } from "../canvas/utils/boundary.helpers";

// Layout constants
const MAX_INFO_PANEL_VIEWPORT_RATIO = 0.6667;
const MAX_INFO_PANEL_CSS_WIDTH = `${MAX_INFO_PANEL_VIEWPORT_RATIO * 100}vw`;
const MIN_INFO_PANEL_WIDTH = 260;
const MIN_CANVAS_COLUMN_WIDTH = 780;
const INFO_RESIZE_DIVIDER_WIDTH = 8;
const MIN_PALETTE_WIDTH = 200;
const MAX_PALETTE_WIDTH = 400;
const DEFAULT_PALETTE_WIDTH = 280;
Expand All @@ -57,6 +59,9 @@ export default function MemoryModelEditor({
const [tempPaletteWidth, setTempPaletteWidth] = useState<number>(
DEFAULT_PALETTE_WIDTH
);
const [maxInfoPanelWidth, setMaxInfoPanelWidth] = useState<number>(
window.innerWidth * MAX_INFO_PANEL_VIEWPORT_RATIO
);

const [currentQuestionData, setCurrentQuestionData] = useState<any>(null);
const _initialUI = loadInitialUIData();
Expand Down Expand Up @@ -455,12 +460,16 @@ export default function MemoryModelEditor({

const maxWidthBasedOnViewport =
window.innerWidth * MAX_INFO_PANEL_VIEWPORT_RATIO;
const maxWidthPreservingCanvas = Math.max(
MIN_INFO_PANEL_WIDTH,
containerRect.width - MIN_CANVAS_COLUMN_WIDTH - INFO_RESIZE_DIVIDER_WIDTH
);
const maxAllowedWidth = Math.min(
containerRect.width - 100,
maxWidthPreservingCanvas,
maxWidthBasedOnViewport
);

const clamped = Math.max(50, Math.min(newWidth, maxAllowedWidth));
const clamped = Math.max(0, Math.min(newWidth, maxAllowedWidth));
infoPanelSetWidth(clamped);
};

Expand All @@ -469,10 +478,26 @@ export default function MemoryModelEditor({
const containerRect =
mainContainerRefCurrent.current.getBoundingClientRect();
const finalWidth = containerRect.right - event.clientX;
const maxWidthBasedOnViewport =
window.innerWidth * MAX_INFO_PANEL_VIEWPORT_RATIO;
const maxWidthPreservingCanvas = Math.max(
MIN_INFO_PANEL_WIDTH,
containerRect.width - MIN_CANVAS_COLUMN_WIDTH - INFO_RESIZE_DIVIDER_WIDTH
);
const maxAllowedWidth = Math.min(
maxWidthPreservingCanvas,
maxWidthBasedOnViewport
);

if (finalWidth < SNAP_CLOSE_THRESHOLD) {
infoPanelSetOpen(false);
infoPanelSetWidth(500);
} else {
const settledWidth = Math.max(
MIN_INFO_PANEL_WIDTH,
Math.min(finalWidth, maxAllowedWidth)
);
infoPanelSetWidth(settledWidth);
}
}

Expand All @@ -492,6 +517,42 @@ export default function MemoryModelEditor({
};
}, [state.isResizingInfoPanel, infoPanelSetWidth, infoPanelSetResizing, infoPanelSetOpen, mainContainerRefCurrent]);

useEffect(() => {
const container = refs.mainContainerRef.current;
if (!container) return;

const updateMaxInfoPanelWidth = () => {
const containerWidth = container.getBoundingClientRect().width;
const maxWidthBasedOnViewport =
window.innerWidth * MAX_INFO_PANEL_VIEWPORT_RATIO;
const maxWidthPreservingCanvas = Math.max(
MIN_INFO_PANEL_WIDTH,
containerWidth - MIN_CANVAS_COLUMN_WIDTH - INFO_RESIZE_DIVIDER_WIDTH
);
const nextMax = Math.min(
maxWidthBasedOnViewport,
maxWidthPreservingCanvas
);

setMaxInfoPanelWidth(nextMax);
state.setInfoPanelWidth((prev) => Math.min(prev, nextMax));
};

updateMaxInfoPanelWidth();

const resizeObserver = new ResizeObserver(() => {
updateMaxInfoPanelWidth();
});

resizeObserver.observe(container);
window.addEventListener("resize", updateMaxInfoPanelWidth);

return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updateMaxInfoPanelWidth);
};
}, [refs.mainContainerRef, state.setInfoPanelWidth]);

return (
<div className={styles.editorContainer}>
<PanelToggleButtons
Expand Down Expand Up @@ -644,8 +705,14 @@ export default function MemoryModelEditor({
state.isResizingInfoPanel ? styles.noTransition : ""
}`}
style={{
width: `${state.infoPanelWidth}px`,
maxWidth: MAX_INFO_PANEL_CSS_WIDTH,
width: `${Math.min(state.infoPanelWidth, maxInfoPanelWidth)}px`,
minWidth: state.isResizingInfoPanel
? "0px"
: `${Math.min(MIN_INFO_PANEL_WIDTH, maxInfoPanelWidth)}px`,
maxWidth: `${Math.min(
maxInfoPanelWidth,
window.innerWidth * MAX_INFO_PANEL_VIEWPORT_RATIO
)}px`,
}}
>
<InformationTabs
Expand Down