From b6cd370b7e33a0e0c3aefbed23076f278ba53d4f Mon Sep 17 00:00:00 2001 From: handreyrc Date: Mon, 10 Aug 2026 17:55:46 -0400 Subject: [PATCH 1/8] Add undo / redo capabilities Signed-off-by: handreyrc --- .../open-workflow-diagram-editor/package.json | 1 + .../src/core/hooks/structuralEqual.ts | 176 +++++++ .../src/diagram-editor/DiagramEditor.tsx | 157 ++++-- .../src/react-flow/diagram/Diagram.tsx | 247 +++++---- .../src/react-flow/hooks/useHistory.ts | 160 ++++++ .../react-flow/hooks/useWorkflowHistory.ts | 205 ++++++++ .../src/store/DiagramEditorContext.tsx | 23 + .../store/DiagramEditorContextProvider.tsx | 184 ++++++- .../src/styles.css | 6 +- .../stories/features/UndoRedo.stories.tsx | 254 +++++++++ .../stories/features/UndoRedoEditor.tsx | 488 ++++++++++++++++++ .../tests/core/hooks/structuralEqual.test.ts | 302 +++++++++++ .../diagram-editor/DiagramEditor.test.tsx | 182 ++++++- .../tests/react-flow/diagram/Diagram.test.tsx | 35 +- .../tests/react-flow/hooks/useHistory.test.ts | 260 ++++++++++ .../hooks/useWorkflowHistory.test.ts | 434 ++++++++++++++++ .../DiagramEditorContextProvider.test.tsx | 16 +- pnpm-lock.yaml | 12 + pnpm-workspace.yaml | 1 + 19 files changed, 2929 insertions(+), 214 deletions(-) create mode 100644 packages/open-workflow-diagram-editor/src/core/hooks/structuralEqual.ts create mode 100644 packages/open-workflow-diagram-editor/src/react-flow/hooks/useHistory.ts create mode 100644 packages/open-workflow-diagram-editor/src/react-flow/hooks/useWorkflowHistory.ts create mode 100644 packages/open-workflow-diagram-editor/stories/features/UndoRedo.stories.tsx create mode 100644 packages/open-workflow-diagram-editor/stories/features/UndoRedoEditor.tsx create mode 100644 packages/open-workflow-diagram-editor/tests/core/hooks/structuralEqual.test.ts create mode 100644 packages/open-workflow-diagram-editor/tests/react-flow/hooks/useHistory.test.ts create mode 100644 packages/open-workflow-diagram-editor/tests/react-flow/hooks/useWorkflowHistory.test.ts diff --git a/packages/open-workflow-diagram-editor/package.json b/packages/open-workflow-diagram-editor/package.json index 4f29c831..f78d9e7b 100644 --- a/packages/open-workflow-diagram-editor/package.json +++ b/packages/open-workflow-diagram-editor/package.json @@ -48,6 +48,7 @@ "class-variance-authority": "catalog:", "clsx": "catalog:", "elkjs": "catalog:", + "fast-equals": "catalog:", "js-yaml": "catalog:", "radix-ui": "catalog:", "sonner": "catalog:", diff --git a/packages/open-workflow-diagram-editor/src/core/hooks/structuralEqual.ts b/packages/open-workflow-diagram-editor/src/core/hooks/structuralEqual.ts new file mode 100644 index 00000000..91d9a4b8 --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/core/hooks/structuralEqual.ts @@ -0,0 +1,176 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createCustomEqual, type State } from "fast-equals"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Object.prototype.toString tag for plain objects and class instances. */ +const OBJ_TAG = "[object Object]"; +const toStringTag = Object.prototype.toString; + +/** + * Returns true when `v` is a plain object or user-defined class instance — + * i.e. any non-null object whose `Object.prototype.toString` tag is + * `[object Object]`. This excludes arrays, Date, RegExp, Map, Set, typed + * arrays and other built-in types that fast-equals handles natively. + */ +function isObjectLike(v: unknown): v is Record { + return v !== null && typeof v === "object" && toStringTag.call(v) === OBJ_TAG; +} + +const { keys } = Object; +const hasOwnProp = Object.prototype.hasOwnProperty; + +// --------------------------------------------------------------------------- +// Custom fast-equals configuration +// --------------------------------------------------------------------------- + +/** + * Captures the underlying `EqualityComparator` built by fast-equals so we can + * delegate non-object-like values (arrays, dates, sets, …) to it without + * re-implementing those handlers ourselves. + */ +let _defaultCompare: (a: unknown, b: unknown, state: State) => boolean; + +/** + * Constructor-agnostic recursive comparator used as `state.equals` by + * fast-equals. + * + * **Object-like values** (any non-null object whose `toString` tag is + * `[object Object]`, i.e. plain objects and user-defined class instances): + * compared by own enumerable string keys and values, ignoring constructors and + * prototype chains. Property insertion order is irrelevant. + * + * **Array-like values** (anything where `Array.isArray` returns true, including + * SDK array subclasses such as `TaskList`): compared element-by-element via + * `innerEquals`, so constructor differences between e.g. `TaskList` and `Array` + * are also ignored. + * + * **All other value types** (dates, maps, sets, typed arrays, primitives, …): + * forwarded to `_defaultCompare`, the default fast-equals type-dispatching + * comparator. + * + * Circular references are handled via `state.cache` (a `WeakMap` that + * fast-equals provides when `circular: true` is set). + */ +function innerEquals( + a: unknown, + b: unknown, + _keyA: unknown, + _keyB: unknown, + _parentA: unknown, + _parentB: unknown, + state: State, +): boolean { + if (isObjectLike(a) && isObjectLike(b)) { + // `state.cache` is always a WeakMap when built with `circular: true`. + const cache = state.cache as WeakMap; + // Circular-reference guard — mirrors fast-equals' createIsCircular. + const cachedA = cache.get(a); + const cachedB = cache.get(b); + if (cachedA !== undefined && cachedB !== undefined) { + return cachedA === b && cachedB === a; + } + cache.set(a, b); + cache.set(b, a); + + const keysA = keys(a); + let result = keysA.length === keys(b).length; + if (result) { + for (let i = 0; i < keysA.length; i++) { + const k = keysA[i]!; + if (!hasOwnProp.call(b, k) || !innerEquals(a[k], b[k], k, k, a, b, state)) { + result = false; + break; + } + } + } + + cache.delete(a); + cache.delete(b); + return result; + } + + // Handle array subclasses (e.g. SDK's TaskList vs a plain Array). + // fast-equals' internal routing comparator checks `a.constructor !== b.constructor` + // before it reaches the Array.isArray fast-path, so cross-constructor arrays + // must be handled here to avoid a false-negative. + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!innerEquals(a[i], b[i], i, i, a, b, state)) return false; + } + return true; + } + + return _defaultCompare(a, b, state); +} + +/** + * A `createCustomEqual` instance with `circular: true` whose + * `createInternalComparator` captures `_defaultCompare` and installs + * `innerEquals` as `state.equals`. The instance is also used as the + * fallback comparator for non-object-like root values. + */ +const _fastEquals = createCustomEqual({ + circular: true, + createInternalComparator: (defaultCompare) => { + _defaultCompare = defaultCompare; + return innerEquals; + }, +}); + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Compares two values for deep structural equality, **ignoring constructor and + * class identity** for object-like values. + * + * **Behaviour** + * - A plain `{ x: 1 }` and `new Foo(1)` (where `Foo` sets `this.x = x`) are + * considered equal. Two instances of *different* classes with identical own + * enumerable properties are also considered equal. + * - Property insertion order is irrelevant. + * - Circular references are handled safely — no stack overflow. + * - Arrays, dates, sets, maps, typed arrays and primitives are compared by + * value using `fast-equals`' built-in handlers. + * + * @param a - First value to compare. + * @param b - Second value to compare. + * @returns `true` if the two values are structurally equal. + */ +export function structuralEqual(a: unknown, b: unknown): boolean { + // For object-like values and array subclasses, the `_fastEquals` entry point + // has a hard `constructor !== b.constructor → false` guard that we must bypass. + // Invoke `innerEquals` directly with a fresh state for both cases. + if ((isObjectLike(a) && isObjectLike(b)) || (Array.isArray(a) && Array.isArray(b))) { + const state: State = { + cache: new WeakMap(), + equals: innerEquals, + meta: undefined, + strict: false, + }; + return innerEquals(a, b, undefined, undefined, undefined, undefined, state); + } + // Primitives, dates, maps, sets, and other non-object-like values — delegate + // to fast-equals so we don't have to replicate its type handlers. + return _fastEquals(a, b); +} diff --git a/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx b/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx index 14896b6b..838c2f27 100644 --- a/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx +++ b/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx @@ -16,7 +16,7 @@ import * as React from "react"; import { ReactFlowProvider } from "@xyflow/react"; -import { Diagram, DiagramRef } from "../react-flow/diagram/Diagram"; +import { Diagram } from "../react-flow/diagram/Diagram"; import { DiagramEditorContextProvider } from "../store/DiagramEditorContextProvider"; import { I18nProvider, detectLocale, useI18n } from "@openworkflowspec/i18n"; import { dictionaries } from "../i18n/locales"; @@ -30,26 +30,84 @@ import { DiagramEditorErrorBoundary } from "./error-pages/DiagramEditorErrorBoun import { Toaster } from "@/components/ui/sonner"; /** - * DiagramEditor component API + * Imperative handle exposed by `DiagramEditor` via `ref`. + * + * Mount the editor with a ref and call these members programmatically: + * + * ```tsx + * const editorRef = useRef(null); + * + * ``` + * + * **`undo()` / `redo()`** — Step backward or forward through edit history. + * Both are no-ops when there is nothing to undo or redo respectively. + * + * **`canUndo` / `canRedo`** — Plain boolean values (not reactive state). + * Copy them into local state after each operation and after the `content` + * prop changes to keep toolbar or menu items in sync. + * + * **`getContent()`** — Returns the current workflow serialised back to a + * string. The format (YAML or JSON) matches whatever the host passed as the + * initial `content` prop — it is fixed at mount time and never changes, so + * undo/redo always round-trips in the original format. Returns `""` when no + * valid model has been loaded yet. */ export type DiagramEditorRef = { - doSomething: () => void; // TODO: to be implemented, it is just a placeholder + /** Step back one history entry. No-op if there is nothing to undo. */ + undo: () => void; + /** Step forward one history entry. No-op if there is nothing to redo. */ + redo: () => void; + /** `true` when there is at least one past entry that can be undone. */ + canUndo: boolean; + /** `true` when there is at least one future entry that can be redone. */ + canRedo: boolean; + /** + * Serialise the current model to a string in the same format (YAML or JSON) + * as the initial `content` prop. Returns `""` when no model is loaded. + */ + getContent: () => string; + /** + * Load a new workflow from a YAML or JSON string, exactly as if the + * `content` prop had been updated. The serialisation format is auto-detected + * from the supplied string and preserved for subsequent `getContent()` calls. + * Silently ignored when the string cannot be parsed. + */ + setContent: (content: string) => void; }; export type DiagramEditorProps = { + /** + * The workflow definition to visualise, as a YAML or JSON string. + * Updating this prop (e.g. from an addon panel) re-parses the workflow and, + * in edit mode, pushes a new history entry if the model changed structurally. + * The serialisation format is auto-detected on first load and preserved for + * the lifetime of the component — see `getContent()` on `DiagramEditorRef`. + */ content: string; + /** + * When `true`, the diagram is read-only: no history is recorded, undo/redo + * are no-ops, and `fitView` runs on every content change. When `false` + * (edit mode), history is active and `fitView` runs only on first load. + */ isReadOnly: boolean; + /** + * BCP 47 locale tag (e.g. `"en"`). Controls the language used for + * aria-labels and any localised text inside the editor. + */ locale: string; + /** Attach an imperative ref to access `undo`, `redo`, `canUndo`, `canRedo`, `getContent`, and `setContent`. */ ref?: React.Ref; + /** + * Colour scheme. `"light"` | `"dark"` | `"system"` (default). + * `"system"` follows the OS/browser preference via `prefers-color-scheme`. + */ colorMode?: ColorMode; }; const DiagramEditorContent = ({ - diagramRef, diagramDivRef, colorMode, }: { - diagramRef: React.RefObject; diagramDivRef: React.RefObject; colorMode: ResolvedColorMode; }) => { @@ -57,24 +115,51 @@ const DiagramEditorContent = ({ return model === null ? ( ) : ( - + ); }; -const DiagramEditorInner = ({ - children, +/** + * Inner shell rendered inside I18nProvider so hooks like useI18n() are available. + * Keeps the error boundary title translated without a render-prop indirection. + */ +const DiagramEditorBody = ({ + diagramDivRef, + resolvedColorMode, + props, }: { - children: (t: ReturnType["t"]) => React.ReactNode; + diagramDivRef: React.RefObject; + resolvedColorMode: ResolvedColorMode; + props: DiagramEditorProps; }) => { const { t } = useI18n(); - - return children(t); + const errorBoundaryProps = { + title: t("workflowError.title"), + message: t("workflowError.default"), + }; + return ( + + + + +
+ +
+ +
+
+
+
+ ); }; export const DiagramEditor = (props: DiagramEditorProps) => { - // Refs const diagramDivRef = React.useRef(null); - const diagramRef = React.useRef(null); const locale = React.useMemo(() => { const supportedLocales = Object.keys(dictionaries); return props.locale ?? detectLocale(supportedLocales); @@ -82,17 +167,6 @@ export const DiagramEditor = (props: DiagramEditorProps) => { const colorMode: ColorMode = props.colorMode ?? "system"; const resolvedColorMode = useResolvedColorMode(colorMode); - // Allow imperatively controlling the Editor - React.useImperativeHandle( - props.ref, - () => ({ - doSomething: () => { - // TODO: to be implemented, it is just a placeholder - }, - }), - [], - ); - return (
{ data-testid={"dec-root"} > - - {(t) => { - const errorBoundaryProps = { - title: "workflowError.title", - message: t("workflowError.default"), - }; - return ( - - - - -
- -
- -
-
-
-
- ); - }} -
+
diff --git a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx index aa8534fe..73204271 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx +++ b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx @@ -40,36 +40,43 @@ const applyEdgeZIndex = (edges: T[]): T[] => zIndex: edge.selected ? ZINDEX.EDGE_SELECTED : ZINDEX.EDGE_REGULAR, })); -/** - * Diagram component API - */ -export type DiagramRef = { - doSomething: () => void; // TODO: to be implemented, it is just a placeholder -}; - export type DiagramProps = { divRef?: React.RefObject; - ref?: React.Ref; colorMode?: ResolvedColorMode; }; -export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { +export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => { const { t } = useI18n(); + // useReactFlow must be in deps of effects that use it (not initialised on first render). const reactFlowInstance: RF.ReactFlowInstance = RF.useReactFlow(); - const { model, errors, nodes, edges, isReadOnly, setNodes, setEdges, setSelectedNodeId } = - useDiagramEditorContext(); + const { + model, + errors, + nodes, + edges, + isReadOnly, + setNodes, + setEdges, + setSelectedNodeId, + selectedNodeId, + submitModel, + pendingViewportRestore, + clearPendingViewportRestore, + } = useDiagramEditorContext(); const [minimapVisible, setMinimapVisible] = React.useState(false); - React.useImperativeHandle( - ref, - () => ({ - doSomething: () => { - // TODO: to be implemented, it is just a placeholder - }, - }), - [], - ); + // Track whether fitView has run at least once in this edit session. + const hasRunFitView = React.useRef(false); + + // Ref to the latest selectedNodeId so the layout callback can stamp selection + // without taking it as a dependency (avoids re-running layout on every click). + const selectedNodeIdRef = React.useRef(selectedNodeId); + selectedNodeIdRef.current = selectedNodeId; + + // True once the first layout has been committed to context — gates rendering the canvas + // so React Flow mounts with nodes already positioned and fitView fires on real content. + const [layoutReady, setLayoutReady] = React.useState(false); const onNodesChange = React.useCallback( (changes) => setNodes((nodesSnapshot) => RF.applyNodeChanges(changes, nodesSnapshot)), @@ -91,60 +98,101 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { [setSelectedNodeId], ); - // Rebuild nodes and edges as model changes with debouncing + // Rebuild nodes and edges when model or errors change (with debouncing). React.useEffect(() => { let isActive = true; - let debounceTimeoutId: ReturnType | null = null; - let fitViewTimeoutId: ReturnType | null = null; let abortController: AbortController | null = null; - // Debounce layout calculation to avoid excessive CPU usage on rapid changes - debounceTimeoutId = setTimeout(() => { - // Create abort controller for this layout operation + // Debounce layout calculation to avoid excessive CPU usage on rapid changes. + const debounceTimeoutId = setTimeout(() => { abortController = new AbortController(); const graph = buildDiagramElements(model, errors); applyAutoLayout(graph, abortController.signal) .then(({ nodes, edges }) => { - // Only update if this effect is still active (not cancelled by cleanup) - if (isActive && !abortController?.signal.aborted) { - setNodes(nodes); + if (isActive && !abortController!.signal.aborted) { + // Preserve selection: stamp selected:true on the node that matches + // selectedNodeId so React Flow does not clear it when nodes are replaced. + const selectedId = selectedNodeIdRef.current; + const stampedNodes = selectedId + ? nodes.map((n) => (n.id === selectedId ? { ...n, selected: true } : n)) + : nodes; + setNodes(stampedNodes); setEdges(applyEdgeZIndex(edges)); - - // Queue fitView to run after React updates the DOM - fitViewTimeoutId = setTimeout(() => reactFlowInstance.fitView(), 0); + // On first load: reveal the canvas — React Flow will mount with nodes already + // positioned and the fitView prop will fit them correctly on first render. + setLayoutReady(true); } }) .catch((error) => { - // Ignore abort errors as they are expected when cancelling if (error.name === "AbortError") { return; } - // Handle other auto-layout errors to prevent unhandled promise rejections console.error("Failed to apply auto-layout:", error); }); - }, 100); // 150ms debounce delay + }, 100); - // Cleanup function to cancel stale updates and clear timeouts return () => { isActive = false; + clearTimeout(debounceTimeoutId); + abortController?.abort(); + }; + }, [model, errors, setNodes, setEdges]); - // Cancel debounce timer - if (debounceTimeoutId !== null) { - clearTimeout(debounceTimeoutId); - } + // After each layout cycle: restore viewport (undo/redo) or fit (read-only re-layout), + // then submit the model. The initial fitView on first mount is handled by the + // fitView prop on (fires once, duration:0, nodes already positioned). + React.useEffect(() => { + if (!layoutReady) return; - // Cancel fitView timer - if (fitViewTimeoutId !== null) { - clearTimeout(fitViewTimeoutId); - } + let isActive = true; + const id = setTimeout(() => { + if (!isActive) return; + + if (pendingViewportRestore) { + // Undo/redo — restore saved viewport instead of fitting. + reactFlowInstance.setViewport(pendingViewportRestore); + clearPendingViewportRestore(); + // Submit with the restored viewport directly — setViewport is async so + // getViewport() would still return the old value at this point. + if (model !== null) { + submitModel(model, pendingViewportRestore, selectedNodeId); + } + } else { + if (isReadOnly && hasRunFitView.current) { + // Re-fit on subsequent read-only layout updates (e.g. content prop change). + // duration:0 — no animation; the user expects an instant re-render, not a pan. + reactFlowInstance.fitView({ ...FIT_VIEW_OPTIONS, duration: 0 }); + } - // Abort in-flight layout calculation - if (abortController) { - abortController.abort(); + // Track that first fitView has run (the RF prop fires on mount). + if (!hasRunFitView.current) { + hasRunFitView.current = true; + } + + // Submit model with the real viewport captured after layout settles. + // Diagram.tsx is the sole caller of submitModel. + if (model !== null) { + submitModel(model, reactFlowInstance.getViewport(), selectedNodeId); + } } + }, 0); + + return () => { + isActive = false; + clearTimeout(id); }; - }, [model, errors, reactFlowInstance, setNodes, setEdges]); + }, [ + nodes, + pendingViewportRestore, + isReadOnly, + reactFlowInstance, + model, + selectedNodeId, + submitModel, + clearPendingViewportRestore, + layoutReady, + ]); return (
{ className={isReadOnly ? "dec:h-full dec:relative read-only" : "dec:h-full dec:relative"} data-testid={"diagram-container"} > - - {minimapVisible && ( - - )} - - - - - - - setMinimapVisible(!minimapVisible)} - aria-label={minimapVisible ? t("aria.minimap.hide") : t("aria.minimap.show")} + {minimapVisible && ( + + )} + + + + + + - M - - - - + setMinimapVisible(!minimapVisible)} + aria-label={minimapVisible ? t("aria.minimap.hide") : t("aria.minimap.show")} + > + M + + + + + )}
); }; diff --git a/packages/open-workflow-diagram-editor/src/react-flow/hooks/useHistory.ts b/packages/open-workflow-diagram-editor/src/react-flow/hooks/useHistory.ts new file mode 100644 index 00000000..86f08194 --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/react-flow/hooks/useHistory.ts @@ -0,0 +1,160 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from "react"; + +/** + * Maximum number of past snapshots retained in the history stack. + * Oldest entries are evicted when the cap is reached. + */ +export const HISTORY_STACK_SIZE = 10; + +export type HistoryState = { + /** Past snapshots, oldest first. Length is capped at HISTORY_STACK_SIZE. */ + past: T[]; + /** The current snapshot. Null when the history has not been initialised yet. */ + present: T | null; + /** Future snapshots available for redo. Index 0 is the most recently undone entry. */ + future: T[]; +}; + +type HistoryAction = + | { type: "PUSH"; payload: T } + | { type: "SET_PRESENT"; payload: T } + | { type: "UNDO" } + | { type: "REDO" }; + +/** + * Appends `entry` to `past`, evicting the oldest entry when the stack cap is reached. + * Used by both PUSH and REDO to keep the capping logic in one place. + */ +function appendCapped(past: T[], entry: T): T[] { + return [...(past.length >= HISTORY_STACK_SIZE ? past.slice(1) : past), entry]; +} + +export function historyReducer( + state: HistoryState, + action: HistoryAction, +): HistoryState { + switch (action.type) { + case "PUSH": { + // Discard the future (branch pruning). + // If present is non-null, move it into past (capped). + const newPast = state.present !== null ? appendCapped(state.past, state.present) : state.past; + + return { + past: newPast, + present: action.payload, + future: [], + }; + } + + case "SET_PRESENT": { + // Replace present without touching past or future. + // Used in read-only mode so the diagram renders new content + // without creating any undoable history entry. + return { ...state, present: action.payload }; + } + + case "UNDO": { + // Guard: nothing to undo. + if (state.past.length === 0) return state; + + const previous = state.past[state.past.length - 1]!; + const newPast = state.past.slice(0, -1); + const newFuture = state.present !== null ? [state.present, ...state.future] : state.future; + + return { + past: newPast, + present: previous, + future: newFuture, + }; + } + + case "REDO": { + // Guard: nothing to redo. + if (state.future.length === 0) return state; + + const next = state.future[0]!; + const newFuture = state.future.slice(1); + const newPast = state.present !== null ? appendCapped(state.past, state.present) : state.past; + + return { + past: newPast, + present: next, + future: newFuture, + }; + } + + default: + return state; + } +} + +const initialHistoryState = (): HistoryState => ({ + past: [], + present: null, + future: [], +}); + +export type UseHistoryReturn = { + state: HistoryState; + push: (payload: T) => void; + setPresent: (payload: T) => void; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; +}; + +/** + * Generic past/present/future history hook backed by useReducer. + * Starts uninitialised (present = null). The first push sets the + * initial present without adding a past entry. + */ +export function useHistory(): UseHistoryReturn { + const [state, dispatch] = React.useReducer( + historyReducer as React.Reducer, HistoryAction>, + undefined, + initialHistoryState, + ); + + const push = React.useCallback((payload: T) => { + dispatch({ type: "PUSH", payload }); + }, []); + + const setPresent = React.useCallback((payload: T) => { + dispatch({ type: "SET_PRESENT", payload }); + }, []); + + const undo = React.useCallback(() => { + dispatch({ type: "UNDO" }); + }, []); + + const redo = React.useCallback(() => { + dispatch({ type: "REDO" }); + }, []); + + return { + state, + push, + setPresent, + undo, + redo, + canUndo: state.past.length > 0, + canRedo: state.future.length > 0, + }; +} diff --git a/packages/open-workflow-diagram-editor/src/react-flow/hooks/useWorkflowHistory.ts b/packages/open-workflow-diagram-editor/src/react-flow/hooks/useWorkflowHistory.ts new file mode 100644 index 00000000..76e3ebf6 --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/react-flow/hooks/useWorkflowHistory.ts @@ -0,0 +1,205 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from "react"; +import type * as RF from "@xyflow/react"; +import type { Specification } from "@openworkflowspec/sdk"; +import { structuralEqual } from "../../core/hooks/structuralEqual"; +import { useHistory } from "./useHistory"; + +/** + * One atomic history entry capturing the full editor state at a point in time. + * `model` is never null — null models are never stored in history. + */ +export type HistorySnapshot = { + model: Specification.Workflow; + /** Viewport state ({ x, y, zoom }) at the time of the snapshot. */ + viewport: RF.Viewport; + selectedNodeId: string | null; +}; + +export type UseWorkflowHistoryReturn = { + model: Specification.Workflow | null; + selectedNodeId: string | null; + /** + * Seeds the model from external props.content. + * Respects the isReadOnly guard — no history entry is created in read-only mode, + * but the present snapshot is still set so the diagram can render. + */ + seedModel: ( + newModel: Specification.Workflow, + viewport: RF.Viewport, + selectedNodeId: string | null, + ) => void; + submitModel: ( + newModel: Specification.Workflow, + viewport: RF.Viewport, + selectedNodeId: string | null, + ) => void; + undo: (setSelectedNodeId: React.Dispatch>) => void; + redo: (setSelectedNodeId: React.Dispatch>) => void; + canUndo: boolean; + canRedo: boolean; + pendingViewportRestore: RF.Viewport | null; + clearPendingViewportRestore: () => void; +}; + +/** + * Workflow-specific history hook wrapping the generic useHistory. + * Starts uninitialised (present = null). History is only recorded when + * isReadOnly is false and the incoming model differs from the current present. + */ +export function useWorkflowHistory(isReadOnly: boolean): UseWorkflowHistoryReturn { + const { + state, + push, + setPresent, + undo: dispatchUndo, + redo: dispatchRedo, + canUndo, + canRedo, + } = useHistory(); + + const [pendingViewportRestore, setPendingViewportRestore] = React.useState( + null, + ); + + // Keep a ref to the latest state so callbacks can read it at call time without + // closing over it — this prevents them from being recreated on every state change, + // which would retrigger layout effects and cause infinite re-render loops. + const stateRef = React.useRef(state); + stateRef.current = state; + + // Keep a ref to the latest isReadOnly so callbacks don't go stale when the prop + // changes (e.g. Storybook controls toggling the isReadOnly arg). + const isReadOnlyRef = React.useRef(isReadOnly); + isReadOnlyRef.current = isReadOnly; + + /** + * Seeds the model from external props.content. + * In read-only mode the present snapshot is updated so the diagram renders, + * but no past entry is created (history is edit-mode only). + */ + const seedModel = React.useCallback( + (newModel: Specification.Workflow, viewport: RF.Viewport, selectedNodeId: string | null) => { + // Null model is never stored in history. + if (newModel == null) return; + + const present = stateRef.current.present; + + // In read-only mode, update present so the diagram renders the new + // content, but leave past and future untouched (no undoable history entry). + if (isReadOnlyRef.current) { + setPresent({ model: newModel, viewport, selectedNodeId }); + return; + } + + // First load — no present yet. Set without creating a past entry. + if (present === null) { + push({ model: newModel, viewport, selectedNodeId }); + return; + } + + // No-op if model content is unchanged. + if (structuralEqual(present.model, newModel)) return; + + // Content changed externally (e.g. props.content updated by host). + // Push new snapshot — future is discarded by reducer. + push({ model: newModel, viewport, selectedNodeId }); + }, + [push, setPresent], + ); + + const submitModel = React.useCallback( + (newModel: Specification.Workflow, viewport: RF.Viewport, selectedNodeId: string | null) => { + // No-op in read-only mode. + if (isReadOnlyRef.current) return; + + // Null model is never stored in history. + if (newModel == null) return; + + const present = stateRef.current.present; + + // First load — no present yet. Set it without creating a past entry. + if (present === null) { + push({ model: newModel, viewport, selectedNodeId }); + return; + } + + // Model content unchanged — update viewport/selection on the present snapshot + // without creating a new history entry. This keeps the saved viewport + // current as the user pans/zooms between edits and after undo/redo restores. + if (structuralEqual(present.model, newModel)) { + const vp = present.viewport; + const viewportChanged = + vp.x !== viewport.x || vp.y !== viewport.y || vp.zoom !== viewport.zoom; + const selectionChanged = present.selectedNodeId !== selectedNodeId; + if (viewportChanged || selectionChanged) { + setPresent({ model: newModel, viewport, selectedNodeId }); + } + return; + } + + // New distinct model — push snapshot (future is discarded inside reducer). + push({ model: newModel, viewport, selectedNodeId }); + }, + [push, setPresent], + ); + + const undo = React.useCallback( + (setSelectedNodeId: React.Dispatch>) => { + const { past } = stateRef.current; + if (isReadOnlyRef.current || past.length === 0) return; + // Read target snapshot before dispatching (reducer is synchronous). + const target = past[past.length - 1]!; + // Restore selectedNodeId via direct callback, not via useEffect. + setSelectedNodeId(target.selectedNodeId); + setPendingViewportRestore(target.viewport); + dispatchUndo(); + }, + [dispatchUndo], + ); + + const redo = React.useCallback( + (setSelectedNodeId: React.Dispatch>) => { + const { future } = stateRef.current; + if (isReadOnlyRef.current || future.length === 0) return; + // Read target snapshot before dispatching. + const target = future[0]!; + setSelectedNodeId(target.selectedNodeId); + setPendingViewportRestore(target.viewport); + dispatchRedo(); + }, + [dispatchRedo], + ); + + const clearPendingViewportRestore = React.useCallback(() => { + setPendingViewportRestore(null); + }, []); + + return { + model: state.present?.model ?? null, + selectedNodeId: state.present?.selectedNodeId ?? null, + seedModel, + submitModel, + undo, + redo, + canUndo, + canRedo, + pendingViewportRestore, + clearPendingViewportRestore, + }; +} diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx index e50ebf40..5275c32f 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx @@ -19,9 +19,12 @@ import * as React from "react"; import type * as RF from "@xyflow/react"; import type { SdkError } from "../core"; +export type ContentFormat = "json" | "yaml"; + export type DiagramEditorContextType = { isReadOnly: boolean; locale: string; + contentFormat: ContentFormat; model: Specification.Workflow | null; errors: SdkError[]; nodes: RF.Node[]; @@ -34,6 +37,26 @@ export type DiagramEditorContextType = { setNodes: React.Dispatch>; setEdges: React.Dispatch>; setSelectedNodeId: React.Dispatch>; + + // Undo/redo — history API + submitModel: ( + model: Specification.Workflow, + viewport: RF.Viewport, + selectedNodeId: string | null, + ) => void; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; + pendingViewportRestore: RF.Viewport | null; + clearPendingViewportRestore: () => void; + /** + * Load a new workflow from a YAML or JSON string, exactly as if the + * `content` prop had changed. The serialisation format is re-detected from + * the supplied string and replaces the current format for future + * `getContent()` calls. + */ + setContent: (content: string) => void; }; export const DiagramEditorContext = React.createContext( diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index cae88390..99e5dadc 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -15,72 +15,220 @@ */ import * as React from "react"; +import { dump } from "js-yaml"; import { buildFlatGraph, getTaskReferences, parseWorkflow } from "../core"; -import { DiagramEditorProps } from "../diagram-editor/DiagramEditor"; -import { DiagramEditorContext, DiagramEditorContextType } from "./DiagramEditorContext"; +import type { Specification } from "@openworkflowspec/sdk"; +import { DiagramEditorProps, DiagramEditorRef } from "../diagram-editor/DiagramEditor"; +import { + ContentFormat, + DiagramEditorContext, + DiagramEditorContextType, +} from "./DiagramEditorContext"; import type * as RF from "@xyflow/react"; +import { useWorkflowHistory } from "../react-flow/hooks/useWorkflowHistory"; -export type ContextProviderProps = Omit; +export type ContextProviderProps = Omit & { + ref?: React.Ref | undefined; +}; + +/** + * Resolves the currently selected node/edge ID against a new model. + * Returns the same ID if it still exists in the graph, or null if it was removed. + */ +function resolveSelectedId(model: Specification.Workflow, currentId: string | null): string | null { + if (currentId === null) return null; + const graph = buildFlatGraph(model); + return graph.nodes.some((n) => n.id === currentId) || graph.edges.some((e) => e.id === currentId) + ? currentId + : null; +} + +export const DiagramEditorContextProvider = ({ + ref, + ...props +}: React.PropsWithChildren) => { + // Detect the serialization format once from the initial content prop. + // JSON content starts with `{` (after trimming); everything else is YAML. + // We use a ref so the format is fixed at mount time and never flips mid-session + // (an undo/redo should round-trip back in the same format the host provided). + const contentFormat = React.useRef( + props.content.trimStart().startsWith("{") ? "json" : "yaml", + ); + + // Force a re-render when contentFormat is updated imperatively via setContent + // (refs don't trigger re-renders on their own). + const [contentFormatVersion, setContentFormatVersion] = React.useState(0); -export const DiagramEditorContextProvider = ( - props: React.PropsWithChildren, -) => { - // Initialize states with props values - const [isReadOnly, setIsReadOnly] = React.useState(props.isReadOnly); + // Config state (non-history) const [locale, setLocale] = React.useState(props.locale); const [nodes, setNodes] = React.useState([] as RF.Node[]); const [edges, setEdges] = React.useState([] as RF.Edge[]); const [selectedNodeId, setSelectedNodeId] = React.useState(null); - const { model, errors } = React.useMemo(() => parseWorkflow(props.content), [props.content]); + // Read isReadOnly directly from props — no local state copy. + // This ensures useWorkflowHistory always receives the current value without + // a one-render lag from useState + useEffect synchronisation. + const isReadOnly = props.isReadOnly; + + // History — starts uninitialised (present = null). + // Diagram.tsx is the sole caller of submitModel (after layout, with real viewport). + const { + model, + seedModel, + submitModel, + undo: historyUndo, + redo: historyRedo, + canUndo, + canRedo, + pendingViewportRestore, + clearPendingViewportRestore, + } = useWorkflowHistory(isReadOnly); + + // parseWorkflow drives both errors and the external-content model source. + // errors are never part of a snapshot — always recomputed from current content. + const { model: parsedModel, errors } = React.useMemo( + () => parseWorkflow(props.content), + [props.content], + ); + + // Seed history from the external content prop using seedModel (bypasses isReadOnly guard). + // The real viewport is set by Diagram.tsx once layout completes in edit mode. + // In read-only mode the placeholder viewport is acceptable since fitView always runs. + // Keep a ref to the latest selectedNodeId so the effect below can read it + // synchronously without taking it as a dependency (avoids re-seeding on every click). + const selectedNodeIdRef = React.useRef(selectedNodeId); + selectedNodeIdRef.current = selectedNodeId; + + React.useEffect(() => { + if (parsedModel === null) { + // Null model is never stored in history. + return; + } + // Preserve selection across content reloads (e.g. addon-panel edits): only clear + // selectedNodeId when the previously-selected node/edge no longer exists in the + // new model. We read the ref synchronously so we can pass the same resolved value + // to both setSelectedNodeId and seedModel in one shot. + const resolvedId = resolveSelectedId(parsedModel, selectedNodeIdRef.current); + setSelectedNodeId(resolvedId); + seedModel(parsedModel, { x: 0, y: 0, zoom: 1 }, resolvedId); + // eslint-disable-next-line react-hooks/exhaustive-deps + // Intentionally omitting selectedNodeIdRef and seedModel: + // - selectedNodeIdRef is a ref (stable, mutated inline — not a dep by convention) + // - seedModel is a useCallback with stable identity; including it would re-seed on + // every selection change because its deps would change too + }, [parsedModel]); const taskReferences = React.useMemo( () => (model ? getTaskReferences(buildFlatGraph(model)) : new Set()), [model], ); - // Update states on props changes + // Sync locale state when the prop changes. React.useEffect(() => { - setIsReadOnly(props.isReadOnly); setLocale(props.locale); - }, [props.isReadOnly, props.locale, setIsReadOnly, setLocale]); + }, [props.locale]); - // Clear selectedNodeId when model changes - React.useEffect(() => { - setSelectedNodeId(null); + /** + * Imperative API: load a new workflow from a YAML or JSON string. + * Mirrors exactly what the props.content effect does, plus updates contentFormat. + */ + const setContent = React.useCallback( + (content: string) => { + const { model: newModel } = parseWorkflow(content); + if (newModel === null) return; + + const newFormat: ContentFormat = content.trimStart().startsWith("{") ? "json" : "yaml"; + if (newFormat !== contentFormat.current) { + contentFormat.current = newFormat; + setContentFormatVersion((v) => v + 1); + } + + const resolvedId = resolveSelectedId(newModel, selectedNodeIdRef.current); + setSelectedNodeId(resolvedId); + seedModel(newModel, { x: 0, y: 0, zoom: 1 }, resolvedId); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [seedModel], + ); + + // Bind setSelectedNodeId into undo/redo wrappers via direct callback. + // This keeps selection restore atomic with the history dispatch. + const undo = React.useCallback(() => { + historyUndo(setSelectedNodeId); + }, [historyUndo]); + + const redo = React.useCallback(() => { + historyRedo(setSelectedNodeId); + }, [historyRedo]); + + const getContent = React.useCallback(() => { + if (!model) return ""; + const plain = JSON.parse(JSON.stringify(model)) as Record; + return contentFormat.current === "json" + ? JSON.stringify(plain, null, 2) + : dump(plain, { indent: 2, lineWidth: -1 }); }, [model]); - // Memoize context value to prevent unnecessary re-renders of consumers + React.useImperativeHandle( + ref as React.Ref, + () => ({ undo, redo, canUndo, canRedo, getContent, setContent }), + [undo, redo, canUndo, canRedo, getContent, setContent], + ); + + // Memoize context value to prevent unnecessary re-renders of consumers. const context = React.useMemo( () => ({ isReadOnly, locale, + // contentFormat.current is read here so the memo always captures the current + // format after an imperative setContent call. contentFormatVersion (in the + // dependency array below) busts the memo whenever the format ref is mutated. + contentFormat: contentFormat.current, model, errors, nodes, edges, taskReferences, selectedNodeId, - setIsReadOnly, + // setIsReadOnly is intentionally inoperative: isReadOnly is driven by + // props, not internal state, so there is no local setter to dispatch to. + setIsReadOnly: () => {}, setLocale, setNodes, setEdges, setSelectedNodeId, + submitModel, + undo, + redo, + canUndo, + canRedo, + pendingViewportRestore, + clearPendingViewportRestore, + setContent, }), [ isReadOnly, locale, + contentFormatVersion, + // contentFormat.current is a ref — stable, no need to list as dep. model, errors, nodes, edges, taskReferences, selectedNodeId, - setIsReadOnly, setLocale, setNodes, setEdges, setSelectedNodeId, + submitModel, + undo, + redo, + canUndo, + canRedo, + pendingViewportRestore, + clearPendingViewportRestore, + setContent, ], ); diff --git a/packages/open-workflow-diagram-editor/src/styles.css b/packages/open-workflow-diagram-editor/src/styles.css index 0b29fc47..a0bed771 100644 --- a/packages/open-workflow-diagram-editor/src/styles.css +++ b/packages/open-workflow-diagram-editor/src/styles.css @@ -80,7 +80,7 @@ --dec-minimap-node-stroke: #64748b; --dec-minimap-border: #cbd5e1; --dec-minimap-shadow: rgba(15, 23, 42, 0.12); - + /* toast */ --dec-toast-bg: #ffffff; --dec-toast-border: #e9edf4; @@ -104,7 +104,7 @@ --dec-selected-rgb: 96 165 250; --dec-edge-selected-condition: rgb(var(--dec-selected-rgb)); --dec-node-hover-glow: rgba(255, 255, 255, 0.3); - + /* minimap */ --dec-minimap-bg: var(--dec-surface-dark); --dec-minimap-mask: rgba(2, 6, 23, 0.5); @@ -113,7 +113,7 @@ --dec-minimap-node-stroke: #cbd5e1; --dec-minimap-border: var(--dec-surface-dark-elevated); --dec-minimap-shadow: rgba(0, 0, 0, 0.5); - + /* toast */ --dec-toast-bg: #2d3748; --dec-toast-border: #4a5568; diff --git a/packages/open-workflow-diagram-editor/stories/features/UndoRedo.stories.tsx b/packages/open-workflow-diagram-editor/stories/features/UndoRedo.stories.tsx new file mode 100644 index 00000000..1aea17dd --- /dev/null +++ b/packages/open-workflow-diagram-editor/stories/features/UndoRedo.stories.tsx @@ -0,0 +1,254 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { UndoRedoEditor } from "./UndoRedoEditor"; +import { authenticationReusable } from "../examples"; + +const meta = { + title: "Features/Undo Redo", + component: UndoRedoEditor, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: ` +## Undo / Redo + +The \`DiagramEditor\` component exposes an imperative \`ref\` API that lets host +applications call \`undo()\`, \`redo()\`, \`getContent()\`, and \`setContent()\` +programmatically, and read \`canUndo\` / \`canRedo\` to drive toolbar or menu state. + +### How it works + +History recording is **disabled in read-only mode** and starts automatically +the first time a valid workflow model is loaded in **edit mode** +(\`isReadOnly={false}\`). + +A new history entry is created whenever the workflow model changes +structurally — for example when the external \`content\` prop is updated by +an addon panel, a text editor, or a \`setContent()\` call. Transient state +such as viewport pan/zoom does **not** create a new entry on its own. + +Each snapshot captures three pieces of state atomically: + +| Field | Type | Description | +|---|---|---| +| \`model\` | \`Specification.Workflow\` | The full parsed workflow definition | +| \`viewport\` | \`{ x, y, zoom }\` | Pan, scroll, and zoom position | +| \`selectedNodeId\` | \`string \\| null\` | Currently selected node or edge ID | + +All three are restored together when \`undo()\` or \`redo()\` is called, so the +diagram, viewport, and side-panel selection are always consistent. + +#### Selection persistence across content reloads + +When the external \`content\` prop changes (e.g. an addon panel edits the YAML), +the selected node or edge is **preserved** if its ID still exists in the new +model. It is only cleared when the node or edge has been removed by the change. + +#### Stack behaviour + +The history stack is capped at **10 entries** (\`HISTORY_STACK_SIZE\`). +When that limit is reached the oldest entry is evicted. When a new change +occurs while there are future entries (i.e. the user has previously undone +some steps), all future entries are discarded before the new snapshot is +pushed — producing a strictly linear history with no branches. + +### Ref API + +\`\`\`tsx +import { useRef } from "react"; +import { DiagramEditor, DiagramEditorRef } from "@openworkflowspec/open-workflow-diagram-editor"; + +const editorRef = useRef(null); + + +\`\`\` + +| Member | Type | Description | +|---|---|---| +| \`undo()\` | \`() => void\` | Step back one history entry. No-op if nothing to undo. | +| \`redo()\` | \`() => void\` | Step forward one history entry. No-op if nothing to redo. | +| \`canUndo\` | \`boolean\` | \`true\` when there is at least one past entry to undo. | +| \`canRedo\` | \`boolean\` | \`true\` when there is at least one future entry to redo. | +| \`getContent()\` | \`() => string\` | Returns the current workflow serialised to a string. Returns \`""\` when no model is loaded. | +| \`setContent(content)\` | \`(content: string) => void\` | Loads a new workflow from a YAML or JSON string. The format is auto-detected and preserved for future \`getContent()\` calls. Silently ignored if the string cannot be parsed. | + +--- + +### \`getContent()\` + +Serialises the current model back to the **same format the editor received +on first load**: if the initial \`content\` prop was JSON it returns JSON; if +it was YAML it returns YAML. The format is fixed at mount time and preserved +for the lifetime of the component, so undo/redo always round-trips in the +original format. Returns \`""\` when no valid model has been loaded yet. + +\`\`\`tsx +// Retrieve current content (format matches the original input — YAML or JSON): +const content = editorRef.current?.getContent(); +\`\`\` + +--- + +### \`setContent(content)\` + +Loads a new workflow definition from a YAML or JSON string, exactly as if +the \`content\` prop had been updated externally. The serialisation format is +**auto-detected** from the supplied string and becomes the new format used by +subsequent \`getContent()\` calls. Silently ignored when the string cannot be +parsed as a valid workflow. + +In edit mode, a successful \`setContent()\` call pushes a new entry onto the +history stack, so the change is immediately undoable. + +\`\`\`tsx +// Replace the diagram with a new YAML definition: +editorRef.current?.setContent(\` +document: "1.0.0" +name: my-workflow +do: + - step1: + call: http + with: + method: GET + endpoint: https://example.com +\`); + +// Or load from JSON: +editorRef.current?.setContent(JSON.stringify({ + document: "1.0.0", + name: "my-workflow", + do: [{ step1: { call: "http", with: { method: "GET", endpoint: "https://example.com" } } }], +}, null, 2)); +\`\`\` + +After calling \`setContent()\`, sync \`canUndo\` / \`canRedo\` into local state +(see the deferred-sync pattern below) so toolbar buttons reflect the updated +history stack. + +--- + +### Syncing \`canUndo\` / \`canRedo\` into React state + +\`canUndo\` and \`canRedo\` are **plain values on the ref object**, not reactive +state. To drive toolbar buttons, copy them into local state after each +operation and after external content changes: + +\`\`\`tsx +const [canUndo, setCanUndo] = useState(false); +const [canRedo, setCanRedo] = useState(false); + +const sync = useCallback(() => { + setCanUndo(editorRef.current?.canUndo ?? false); + setCanRedo(editorRef.current?.canRedo ?? false); +}, []); + +// Sync after external content changes (e.g. addon panel updates the YAML): +useEffect(() => { + const id = setTimeout(sync, 0); // defer one tick so the ref has settled + return () => clearTimeout(id); +}, [content, sync]); + +// Sync after a button-triggered undo/redo: +const handleUndo = () => { + editorRef.current?.undo(); + setTimeout(sync, 0); +}; + +// Sync after a setContent call: +const handleSetContent = (newContent: string) => { + editorRef.current?.setContent(newContent); + setTimeout(sync, 0); +}; +\`\`\` + +> The \`setTimeout(..., 0)\` deferral is necessary because \`useImperativeHandle\` +> updates the ref values one render after the internal state changes. + +--- + +### Calling the API from the browser console + +This story publishes the editor ref on \`window.diagramEditor\` so you can +exercise the full API directly from the browser DevTools console **without +writing any code**: + +1. Open the Storybook story **Features → Undo Redo → UndoRedo** in a browser. +2. Open the browser DevTools (**F12** or **⌘ ⌥ I**) and navigate to the **Console** tab. +3. Make sure the correct frame is selected — if Storybook runs the story in an + \`