diff --git a/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css b/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css
index 80b92f7..684931b 100644
--- a/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css
+++ b/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css
@@ -86,7 +86,7 @@
.canvasColumn {
flex: 1;
- min-width: 0;
+ min-width: 780px;
min-height: 0;
position: relative;
display: flex;
diff --git a/frontend/src/features/memoryModelEditor/MemoryModelEditor.test.tsx b/frontend/src/features/memoryModelEditor/MemoryModelEditor.test.tsx
new file mode 100644
index 0000000..b17d232
--- /dev/null
+++ b/frontend/src/features/memoryModelEditor/MemoryModelEditor.test.tsx
@@ -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: () =>
Canvas
,
+}));
+
+jest.mock("../palette/Palette", () => ({
+ __esModule: true,
+ default: () => Palette
,
+}));
+
+jest.mock("./components/ConfirmationModal", () => ({
+ __esModule: true,
+ default: () => null,
+}));
+
+jest.mock("../informationTabs/InformationTabs", () => ({
+ __esModule: true,
+ default: () => Information
,
+}));
+
+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;
+
+ 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();
+
+ 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();
+
+ 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();
+ });
+});
diff --git a/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx b/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx
index 85f84ed..d3a8ba6 100644
--- a/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx
+++ b/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx
@@ -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;
@@ -57,6 +59,9 @@ export default function MemoryModelEditor({
const [tempPaletteWidth, setTempPaletteWidth] = useState(
DEFAULT_PALETTE_WIDTH
);
+ const [maxInfoPanelWidth, setMaxInfoPanelWidth] = useState(
+ window.innerWidth * MAX_INFO_PANEL_VIEWPORT_RATIO
+ );
const [currentQuestionData, setCurrentQuestionData] = useState(null);
const _initialUI = loadInitialUIData();
@@ -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);
};
@@ -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);
}
}
@@ -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 (