fix: fixed scrolling behavior inside editor when plan editor window is opened

ref: N25B-412
This commit is contained in:
JGerla
2026-01-05 15:53:20 +01:00
parent 149b82cb66
commit 111400bd82
4 changed files with 227 additions and 205 deletions

View File

@@ -48,7 +48,8 @@ const selector = (state: FlowState) => ({
undo: state.undo, undo: state.undo,
redo: state.redo, redo: state.redo,
beginBatchAction: state.beginBatchAction, beginBatchAction: state.beginBatchAction,
endBatchAction: state.endBatchAction endBatchAction: state.endBatchAction,
scrollable: state.scrollable
}); });
// --| define ReactFlow editor |-- // --| define ReactFlow editor |--
@@ -72,7 +73,8 @@ const VisProgUI = () => {
undo, undo,
redo, redo,
beginBatchAction, beginBatchAction,
endBatchAction endBatchAction,
scrollable
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
// adds ctrl+z and ctrl+y support to respectively undo and redo actions // adds ctrl+z and ctrl+y support to respectively undo and redo actions
@@ -101,6 +103,7 @@ const VisProgUI = () => {
onConnect={onConnect} onConnect={onConnect}
onNodeDragStart={beginBatchAction} onNodeDragStart={beginBatchAction}
onNodeDragStop={endBatchAction} onNodeDragStop={endBatchAction}
preventScrolling={scrollable}
snapToGrid snapToGrid
fitView fitView
proOptions={{hideAttribution: true}} proOptions={{hideAttribution: true}}

View File

@@ -72,6 +72,15 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
nodes: initialNodes, nodes: initialNodes,
edges: initialEdges, edges: initialEdges,
edgeReconnectSuccessful: true, edgeReconnectSuccessful: true,
scrollable: true,
/**
* handles changing the scrollable state of the editor,
* this is used to control if scrolling is captured by the editor
* or if it's available to other components within the reactFlowProvider
* @param {boolean} val - the desired state
*/
setScrollable: (val) => set({scrollable: val}),
/** /**
* Handles changes to nodes triggered by ReactFlow. * Handles changes to nodes triggered by ReactFlow.

View File

@@ -23,6 +23,10 @@ export type FlowState = {
nodes: Node[]; nodes: Node[];
edges: Edge[]; edges: Edge[];
edgeReconnectSuccessful: boolean; edgeReconnectSuccessful: boolean;
scrollable: boolean;
/** Handler for managing scrollable state */
setScrollable: (value: boolean) => void;
/** Handler for changes to nodes triggered by ReactFlow */ /** Handler for changes to nodes triggered by ReactFlow */
onNodesChange: OnNodesChange; onNodesChange: OnNodesChange;

View File

@@ -1,4 +1,5 @@
import { useRef, useState } from "react"; import {useRef, useState} from "react";
import useFlowStore from "../VisProgStores.tsx";
import styles from './PlanEditor.module.css'; import styles from './PlanEditor.module.css';
import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan"; import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan";
import { defaultPlan } from "../components/Plan.default"; import { defaultPlan } from "../components/Plan.default";
@@ -16,220 +17,225 @@ export default function PlanEditorDialog({
onSave, onSave,
description, description,
}: PlanEditorDialogProps) { }: PlanEditorDialogProps) {
// UseStates and references // UseStates and references
const dialogRef = useRef<HTMLDialogElement | null>(null); const dialogRef = useRef<HTMLDialogElement | null>(null);
const [draftPlan, setDraftPlan] = useState<Plan | null>(null); const [draftPlan, setDraftPlan] = useState<Plan | null>(null);
const [newActionType, setNewActionType] = useState<ActionTypes>("speech"); const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
const [newActionValue, setNewActionValue] = useState(""); const [newActionValue, setNewActionValue] = useState("");
const { setScrollable } = useFlowStore();
//Button Actions //Button Actions
const openCreate = () => { const openCreate = () => {
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()}); setScrollable(false);
dialogRef.current?.showModal(); setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()});
}; dialogRef.current?.showModal();
};
const openCreateWithDescription = () => { const openCreateWithDescription = () => {
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!}); setScrollable(false);
setNewActionType("llm") setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!});
setNewActionValue(description!) setNewActionType("llm")
dialogRef.current?.showModal(); setNewActionValue(description!)
dialogRef.current?.showModal();
}
const openEdit = () => {
setScrollable(false);
if (!plan) return;
setDraftPlan(structuredClone(plan));
dialogRef.current?.showModal();
};
const close = () => {
setScrollable(true);
dialogRef.current?.close();
setDraftPlan(null);
};
const buildAction = (): Action => {
const id = crypto.randomUUID();
switch (newActionType) {
case "speech":
return { id, text: newActionValue, type: "speech" };
case "gesture":
return { id, gesture: newActionValue, type: "gesture" };
case "llm":
return { id, goal: newActionValue, type: "llm" };
} }
};
const openEdit = () => { return (<>
if (!plan) return; {/* Create and edit buttons */}
setDraftPlan(structuredClone(plan)); {!plan && (
dialogRef.current?.showModal(); <button className={styles.nodeButton} onClick={description ? openCreateWithDescription : openCreate}>
}; Create Plan
</button>
)}
{plan && (
<button className={styles.nodeButton} onClick={openEdit}>
Edit Plan
</button>
)}
const close = () => { {/* Start of dialog (plan editor) */}
dialogRef.current?.close(); <dialog
setDraftPlan(null); ref={dialogRef}
}; className={`${styles.planDialog}`}
//onWheel={(e) => e.stopPropagation()}
data-testid={"PlanEditorDialogTestID"}
>
<form method="dialog" className="flex-col gap-md">
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
{/* Plan name text field */}
{draftPlan && (
<TextField
value={draftPlan.name}
setValue={(name) =>
setDraftPlan({ ...draftPlan, name })}
placeholder="Plan name"
data-testid="name_text_field"/>
)}
const buildAction = (): Action => { {/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */}
const id = crypto.randomUUID(); {draftPlan && (<div className={styles.planEditor}>
switch (newActionType) { <div className={styles.planEditorLeft}>
case "speech": {/* Left Side (Action Adder) */}
return { id, text: newActionValue, type: "speech" }; <h4>Add Action</h4>
case "gesture": {(!plan && description && draftPlan.steps.length === 0) && (<div className={styles.stepSuggestion}>
return { id, gesture: newActionValue, type: "gesture" }; <label> Filled in as a suggestion! </label>
case "llm": <label> Feel free to change! </label>
return { id, goal: newActionValue, type: "llm" }; </div>)}
} <label>
}; Action Type <wbr />
{/* Type selection */}
<select
value={newActionType}
onChange={(e) => {
setNewActionType(e.target.value as ActionTypes);
// Reset value when action type changes
setNewActionValue("");
}}>
<option value="speech">Speech Action</option>
<option value="gesture">Gesture Action</option>
<option value="llm">LLM Action</option>
</select>
</label>
return (<> {/* Action value editor*/}
{/* Create and edit buttons */} {newActionType === "gesture" ? (
{!plan && ( // Gesture get their own editor component
<button className={styles.nodeButton} onClick={description ? openCreateWithDescription : openCreate}> <GestureValueEditor
Create Plan value={newActionValue}
</button> setValue={setNewActionValue}
)} placeholder="Gesture name"
{plan && ( />
<button className={styles.nodeButton} onClick={openEdit}> ) : (
Edit Plan <TextField
</button>
)}
{/* Start of dialog (plan editor) */}
<dialog
ref={dialogRef}
className={`${styles.planDialog}`}
onWheel={(e) => e.stopPropagation()}
data-testid={"PlanEditorDialogTestID"}
>
<form method="dialog" className="flex-col gap-md">
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
{/* Plan name text field */}
{draftPlan && (
<TextField
value={draftPlan.name}
setValue={(name) =>
setDraftPlan({ ...draftPlan, name })}
placeholder="Plan name"
data-testid="name_text_field"/>
)}
{/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */}
{draftPlan && (<div className={styles.planEditor}>
<div className={styles.planEditorLeft}>
{/* Left Side (Action Adder) */}
<h4>Add Action</h4>
{(!plan && description && draftPlan.steps.length === 0) && (<div className={styles.stepSuggestion}>
<label> Filled in as a suggestion! </label>
<label> Feel free to change! </label>
</div>)}
<label>
Action Type <wbr />
{/* Type selection */}
<select
value={newActionType}
onChange={(e) => {
setNewActionType(e.target.value as ActionTypes);
// Reset value when action type changes
setNewActionValue("");
}}>
<option value="speech">Speech Action</option>
<option value="gesture">Gesture Action</option>
<option value="llm">LLM Action</option>
</select>
</label>
{/* Action value editor*/}
{newActionType === "gesture" ? (
// Gesture get their own editor component
<GestureValueEditor
value={newActionValue} value={newActionValue}
setValue={setNewActionValue} setValue={setNewActionValue}
placeholder="Gesture name" placeholder={
/> newActionType === "speech" ? "Speech text"
) : ( : "LLM goal"
<TextField }
value={newActionValue} />
setValue={setNewActionValue} )}
placeholder={
newActionType === "speech" ? "Speech text"
: "LLM goal"
}
/>
)}
{/* Adding steps */}
<button
type="button"
disabled={!newActionValue}
onClick={() => {
if (!draftPlan) return;
// Add action to steps
const action = buildAction();
setDraftPlan({
...draftPlan,
steps: [...draftPlan.steps, action],});
// Reset current action building
setNewActionValue("");
setNewActionType("speech");
}}>
Add Step
</button>
</div>
{/* Right Side (Steps shown) */}
<div className={styles.planEditorRight}>
<h4>Steps</h4>
{/* Show if there are no steps yet */} {/* Adding steps */}
{draftPlan.steps.length === 0 && ( <button
<div className={styles.emptySteps}> type="button"
No steps yet disabled={!newActionValue}
</div> onClick={() => {
)} if (!draftPlan) return;
// Add action to steps
const action = buildAction();
{/* Map over all steps */} setDraftPlan({
{draftPlan.steps.map((step, index) => ( ...draftPlan,
<div steps: [...draftPlan.steps, action],});
role="button"
tabIndex={0} // Reset current action building
key={step.id} setNewActionValue("");
className={styles.planStep} setNewActionType("speech");
// Extra logic for screen readers to access using keyboard }}>
onKeyDown={(e) => { Add Step
if (e.key === "Enter" || e.key === " ") { </button>
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}}
onClick={() => {
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}>
<span className={styles.stepIndex}>{index + 1}.</span>
<span className={styles.stepType}>{step.type}:</span>
<span className={styles.stepName}>{
step.type == "goal" ? ""/* TODO: Add support for goals */
: GetActionValue(step)}
</span>
</div>
))}
</div>
</div> </div>
)}
{/* Buttons */} {/* Right Side (Steps shown) */}
<div className="flex-row gap-md"> <div className={styles.planEditorRight}>
{/* Close button */} <h4>Steps</h4>
<button type="button" onClick={close}>
Cancel
</button>
{/* Confirm/ Create button */} {/* Show if there are no steps yet */}
<button {draftPlan.steps.length === 0 && (
type="button" <div className={styles.emptySteps}>
disabled={!draftPlan} No steps yet
onClick={() => { </div>
if (!draftPlan) return; )}
onSave(draftPlan);
close();
}}>
{draftPlan?.id === plan?.id ? "Confirm" : "Create"}
</button>
{/* Reset button */}
<button {/* Map over all steps */}
type="button" {draftPlan.steps.map((step, index) => (
disabled={!draftPlan} <div
onClick={() => { role="button"
onSave(undefined); tabIndex={0}
close(); key={step.id}
}}> className={styles.planStep}
Reset // Extra logic for screen readers to access using keyboard
</button> onKeyDown={(e) => {
</div> if (e.key === "Enter" || e.key === " ") {
</form> setDraftPlan({
</dialog> ...draftPlan,
</> steps: draftPlan.steps.filter((s) => s.id !== step.id),});
); }}}
onClick={() => {
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}>
<span className={styles.stepIndex}>{index + 1}.</span>
<span className={styles.stepType}>{step.type}:</span>
<span className={styles.stepName}>{
step.type == "goal" ? ""/* TODO: Add support for goals */
: GetActionValue(step)}
</span>
</div>
))}
</div>
</div>
)}
{/* Buttons */}
<div className="flex-row gap-md">
{/* Close button */}
<button type="button" onClick={close}>
Cancel
</button>
{/* Confirm/ Create button */}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
if (!draftPlan) return;
onSave(draftPlan);
close();
}}>
{draftPlan?.id === plan?.id ? "Confirm" : "Create"}
</button>
{/* Reset button */}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
onSave(undefined);
close();
}}>
Reset
</button>
</div>
</form>
</dialog>
</>
);
} }