From 1a83196a5c19d6b47d98c24ada63718de81589d9 Mon Sep 17 00:00:00 2001 From: fries anyone? Date: Tue, 28 Jul 2026 07:31:09 +0800 Subject: [PATCH 1/2] stabilize resizable editor panels --- .../MemoryModelEditor.module.css | 2 + .../memoryModelEditor/MemoryModelEditor.tsx | 77 +++++++++++++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css b/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css index 15398ce..d6dde2e 100644 --- a/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css +++ b/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css @@ -87,6 +87,8 @@ .canvasColumn { flex: 1; min-width: 0; + min-width: 780px; + min-height: 0; position: relative; display: flex; flex-direction: column; diff --git a/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx b/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx index ebf7505..2f16010 100644 --- a/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx +++ b/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx @@ -28,7 +28,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; @@ -54,6 +56,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(); @@ -449,12 +454,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); }; @@ -463,10 +472,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); } } @@ -486,6 +511,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 (
Date: Thu, 6 Aug 2026 15:33:22 -0400 Subject: [PATCH 2/2] add tests for panel resizing --- .../MemoryModelEditor.test.tsx | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 frontend/src/features/memoryModelEditor/MemoryModelEditor.test.tsx 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(); + }); +});