diff --git a/.changeset/estepper-layout-memory.md b/.changeset/estepper-layout-memory.md new file mode 100644 index 0000000..774980e --- /dev/null +++ b/.changeset/estepper-layout-memory.md @@ -0,0 +1,5 @@ +--- +"@sourceacademy/web-e-stepper": patch +--- + +e-stepper: the panes start side by side (Vertical off) whatever the tab's width, and the arrangement and the divider stay where the user put them after a run diff --git a/src/web/e-stepper/src/EStepperHostPlugin.tsx b/src/web/e-stepper/src/EStepperHostPlugin.tsx index 6c485b9..fdcf846 100644 --- a/src/web/e-stepper/src/EStepperHostPlugin.tsx +++ b/src/web/e-stepper/src/EStepperHostPlugin.tsx @@ -15,7 +15,7 @@ import { } from "@sourceacademy/conductor/conduit"; import { createElement, useSyncExternalStore } from "react"; -import EStepperView from "./EStepperView"; +import EStepperView, { type PaneLayout } from "./EStepperView"; /** The side-content tab id used by the host to show/hide the e-stepper tab. */ const TAB_ID = "e-stepper"; @@ -65,6 +65,8 @@ export class EStepperHostPlugin implements IPlugin { const subscribe = (listener: () => void) => this.subscribe(listener); const getState = () => this.getState(); + // Kept here, not in the view: a run may mount the view anew, and the user's divider stays. + const layout: PaneLayout = {}; const cseDiagram: ICseDiagramService | undefined = hostServices?.cseDiagram; function EStepperTab() { const state = useSyncExternalStore(subscribe, getState); @@ -73,6 +75,7 @@ export class EStepperHostPlugin implements IPlugin { profile: state.profile, error: state.error, cseDiagram, + layout, }); } diff --git a/src/web/e-stepper/src/EStepperView.tsx b/src/web/e-stepper/src/EStepperView.tsx index 1855601..dc6f6ab 100644 --- a/src/web/e-stepper/src/EStepperView.tsx +++ b/src/web/e-stepper/src/EStepperView.tsx @@ -30,11 +30,6 @@ import EnvDiagram from "./EnvDiagram"; import ProgramArrows, { ENV_ATTRIBUTE, REF_ATTRIBUTE } from "./ProgramArrows"; import { injectEStepperStyles } from "./styles"; -/** - * Width (px) from which the program and the diagram start out side by side: the arrangement is - * chosen by the tab's width once, when the tab is first measured, and the user's after that. - */ -const WIDE_LAYOUT_MIN_WIDTH = 900; const DEFAULT_PROGRAM_HEIGHT = 240; /** The least height of the diagram below the program, and of the program: about one line of it. */ const MIN_PANE_HEIGHT = 80; @@ -58,8 +53,37 @@ type Props = { error?: string | null; /** The host's CSE machine visualization, if it lends one (see `ICseDiagramService`). */ cseDiagram?: ICseDiagramService; + /** + * The user's choices about the arrangement of the panes. The host keeps this object across runs, + * which may mount the view anew, so that the divider stays where the user put it. + */ + layout?: PaneLayout; }; +/** The arrangement of the panes that the user chose; a missing field is still the default. */ +export type PaneLayout = { vertical?: boolean; programHeight?: number; programShare?: number }; + +/** Like `useState`, but the value is also written to (and starts from) the host's `layout`. */ +function useRemembered( + layout: PaneLayout | undefined, + key: K, + fallback: NonNullable, +) { + const [value, setValue] = useState>( + (layout?.[key] as NonNullable | undefined) ?? fallback, + ); + const set = useCallback( + (update: React.SetStateAction>) => + setValue(old => { + const next = typeof update === "function" ? update(old) : update; + if (layout) layout[key] = next; + return next; + }), + [layout, key], + ); + return [value, set] as const; +} + /** Whether the step is a stop for breakpoint navigation: it evaluates a `breakpoint()` statement. */ const isBreakpoint = (step: EStepperStep): boolean => step.markers?.some(m => m.redexNodeType === "DebuggerStatement") ?? false; @@ -193,12 +217,20 @@ function useSize(): [(element: HTMLElement | null) => void, { width: number; hei */ export { sliderLabels }; -export default function EStepperView({ steps, profile, error, cseDiagram }: Props) { +export default function EStepperView({ steps, profile, error, cseDiagram, layout }: Props) { const [stepValue, setStepValue] = useState(1); const [hovered, setHovered] = useState(null); const [hoveredFrame, setHoveredFrame] = useState(null); - const [programHeight, setProgramHeight] = useState(DEFAULT_PROGRAM_HEIGHT); - const [programShare, setProgramShare] = useState(DEFAULT_PROGRAM_SHARE); + const [programHeight, setProgramHeight] = useRemembered( + layout, + "programHeight", + DEFAULT_PROGRAM_HEIGHT, + ); + const [programShare, setProgramShare] = useRemembered( + layout, + "programShare", + DEFAULT_PROGRAM_SHARE, + ); const [outputOpen, setOutputOpen] = useState(true); // Display options. Program references (arrows from the program into the diagram) are off until the user asks. const [showArrows, setShowArrows] = useState(true); @@ -235,15 +267,10 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop const hasRun = lastStep > 0; const stepIndex = hasRun ? Math.min(stepValue, lastStep) - 1 : 0; const step = hasRun ? steps[stepIndex] : undefined; - // The arrangement of the panes: side by side ("wide") or one above the other ("vertical"). It - // starts from the tab's width when that is first known, and is the user's choice from then on. - const [vertical, setVertical] = useState(null); - useEffect(() => { - if (containerSize.width > 0) { - setVertical(v => v ?? containerSize.width < WIDE_LAYOUT_MIN_WIDTH); - } - }, [containerSize.width]); - const wide = vertical === null ? false : !vertical; + // The arrangement of the panes: side by side ("wide", the default) or one above the other + // ("vertical"), as the user chooses, whatever the tab's width. + const [vertical, setVertical] = useRemembered(layout, "vertical", false); + const wide = !vertical; // The double arrows (and `a` / `e`) jump to the previous / next breakpoint, or to the first / last // step when there is none, as in the stepper. @@ -458,7 +485,7 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop title="Program above the environment, instead of beside it" aria-label="Vertical" aria-pressed={!wide} - onClick={() => setVertical(v => !(v ?? false))} + onClick={() => setVertical(v => !v)} > { expect(view.root.findAllByType(konva("konva-stage"))).toHaveLength(1); }); - test("stacks the panes in a narrow tab, with a divider that resizes the program pane", () => { + test("stacks the panes when Vertical is chosen, with a divider that resizes the program pane", () => { const view = render({ steps: [fixture[1]], profile }); + act(() => + view.root + .findAllByType(Button) + .find(b => b.props["aria-label"] === "Vertical")! + .props.onClick(), + ); const main = view.root.find(n => hasClass(n, "estepper-main")); expect(hasClass(main, "narrow")).toBe(true); const listeners: Record void> = {}; @@ -278,10 +284,31 @@ describe("EStepperView", () => { vi.unstubAllGlobals(); }); + test("a view mounted anew (after a run) starts from the host's remembered arrangement", () => { + const layout = {}; + const first = render({ steps: [fixture[1]], profile, layout }); + act(() => + first.root + .findAllByType(Button) + .find(b => b.props["aria-label"] === "Vertical")! + .props.onClick(), + ); + act(() => first.unmount()); + const second = render({ steps: [fixture[1]], profile, layout }); + const main = second.root.find(n => hasClass(n, "estepper-main")); + expect(hasClass(main, "narrow")).toBe(true); + }); + test("the divider can not be dragged out of view: the diagram below keeps its least height", () => { // The panes' container is 600px high (see `render`'s node mock): 80px stay for the diagram, // and 26px for the divider and its gaps. const view = render({ steps: [fixture[1]], profile }); + act(() => + view.root + .findAllByType(Button) + .find(b => b.props["aria-label"] === "Vertical")! + .props.onClick(), + ); const listeners: Record void> = {}; vi.stubGlobal("window", { addEventListener: (type: string, fn: (e: { clientY: number }) => void) =>