diff --git a/.changeset/state-management.md b/.changeset/state-management.md new file mode 100644 index 00000000..c75c852e --- /dev/null +++ b/.changeset/state-management.md @@ -0,0 +1,5 @@ +--- +"@openworkflowspec/diagram-editor": minor +--- + +Add state management, undo / redo capabilities and component API. diff --git a/packages/open-workflow-diagram-editor/.storybook/preview.tsx b/packages/open-workflow-diagram-editor/.storybook/preview.tsx index c1d56a04..66aeff6a 100644 --- a/packages/open-workflow-diagram-editor/.storybook/preview.tsx +++ b/packages/open-workflow-diagram-editor/.storybook/preview.tsx @@ -28,6 +28,7 @@ const preview: Preview = { color: /(background|color)$/i, date: /Date$/i, }, + disableSaveFromUI: true, // Disable modifiy story popup. Stories mustn't be editable from Storybook UI. }, backgrounds: { 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/structuralEqual.ts b/packages/open-workflow-diagram-editor/src/core/structuralEqual.ts new file mode 100644 index 00000000..91d9a4b8 --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/core/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/core/workflowSdk.ts b/packages/open-workflow-diagram-editor/src/core/workflowSdk.ts index 4765f032..dbfb4ddf 100644 --- a/packages/open-workflow-diagram-editor/src/core/workflowSdk.ts +++ b/packages/open-workflow-diagram-editor/src/core/workflowSdk.ts @@ -14,11 +14,13 @@ * limitations under the License. */ -import { load } from "js-yaml"; +import { load, dump } from "js-yaml"; import * as sdk from "@openworkflowspec/sdk"; import { fixNodesConnections } from "./graph"; import { stripSpecAheadOfSdkErrors } from "./specWorkarounds"; +export type ContentFormat = "json" | "yaml"; + /** * Sanitizes an object by removing dangerous prototype pollution keys * and creating a new object with null prototype to prevent pollution attacks. @@ -288,3 +290,15 @@ export function parseWorkflow(text: string): WorkflowParseResult { export function buildFlatGraph(model: sdk.Specification.Workflow): sdk.FlatGraph { return fixNodesConnections(sdk.buildFlatGraph(model)); } + +export function serializeWorkflow( + model: sdk.Specification.Workflow, + format: ContentFormat, +): string { + // The SDK validates the model before serializing it and it may cause validation exceptions + // Even if we have a model with validation errors we want it to be serialized + const json = JSON.stringify(model); + if (format === "json") return json; + // dump only works with plain objects. + return dump(JSON.parse(json)); +} 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..dafad7fe 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) tracks the most recently successfully + * loaded content: it starts as the format of the initial `content` prop and + * updates whenever `setContent()` loads a new string, so undo/redo always + * round-trips in the format of the last successfully loaded content. + * 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 format (YAML or JSON) of + * the most recently successfully loaded content. 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; - 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,81 +115,77 @@ 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, + editorRef, }: { - children: (t: ReturnType["t"]) => React.ReactNode; + diagramDivRef: React.RefObject; + resolvedColorMode: ResolvedColorMode; + props: DiagramEditorProps; + editorRef: React.ForwardedRef; }) => { const { t } = useI18n(); - - return children(t); -}; - -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); - }, [props.locale]); - 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 - }, - }), - [], - ); - + const errorBoundaryProps = { + title: t("workflowError.title"), + message: t("workflowError.default"), + }; return ( -
- - - {(t) => { - const errorBoundaryProps = { - title: "workflowError.title", - message: t("workflowError.default"), - }; - return ( - - - - -
- -
- -
-
-
-
- ); - }} -
-
- -
+ + + + +
+ +
+ +
+
+
+
); }; + +export const DiagramEditor = React.forwardRef( + (props, ref) => { + const diagramDivRef = React.useRef(null); + const locale = React.useMemo(() => { + const supportedLocales = Object.keys(dictionaries); + return props.locale ?? detectLocale(supportedLocales); + }, [props.locale]); + const colorMode: ColorMode = props.colorMode ?? "system"; + const resolvedColorMode = useResolvedColorMode(colorMode); + + return ( +
+ + + + +
+ ); + }, +); diff --git a/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts b/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts index f8a46bc0..55298bd5 100644 --- a/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts +++ b/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts @@ -50,6 +50,8 @@ export const en = { "aria.panel.workflowInfo": "Workflow information panel", "aria.panel.content": "Panel content", "aria.panel.exportActions": "Export actions", + "workflowError.autoLayout.title": "Layout Error", + "workflowError.autoLayout.message": "Failed to apply auto-layout to the diagram.", "toast.clipboard.error": "Failed to copy", "toast.download.success": "Download started", "toast.download.error": "Download failed", 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..4d5bcbca 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 @@ -27,6 +27,7 @@ import { buildDiagramElements } from "./diagramBuilder"; import { applyAutoLayout } from "./autoLayout"; import { SidePanelTrigger } from "@/side-panel/SidePanelTrigger"; import { ZINDEX } from "../zIndexConstants"; +import { ErrorPage } from "../../diagram-editor/error-pages/ErrorPage"; const FIT_VIEW_OPTIONS: RF.FitViewOptions = { maxZoom: 1, @@ -40,36 +41,58 @@ 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); + const [layoutError, setLayoutError] = React.useState(null); - React.useImperativeHandle( - ref, - () => ({ - doSomething: () => { - // TODO: to be implemented, it is just a placeholder - }, - }), - [], - ); + // Refs to the latest values that the post-layout callback reads after the async + // layout completes. Keeping them as refs (not deps) prevents those values from + // re-triggering the layout effect when they change independently (e.g. selection, + // viewport, undo/redo). + const selectedNodeIdRef = React.useRef(selectedNodeId); + selectedNodeIdRef.current = selectedNodeId; + const pendingViewportRestoreRef = React.useRef(pendingViewportRestore); + pendingViewportRestoreRef.current = pendingViewportRestore; + const isReadOnlyRef = React.useRef(isReadOnly); + isReadOnlyRef.current = isReadOnly; + const modelRef = React.useRef(model); + modelRef.current = model; + // Function refs — callbacks change identity across renders but the post-layout + // setTimeout must always invoke the latest version without re-running layout. + const submitModelRef = React.useRef(submitModel); + submitModelRef.current = submitModel; + const clearPendingViewportRestoreRef = React.useRef(clearPendingViewportRestore); + clearPendingViewportRestoreRef.current = clearPendingViewportRestore; + + // 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); + // Whether the initial fitView (fired by the fitView prop on ) has run. + // Used by the post-layout callback to decide whether to re-fit on subsequent layouts. + const hasRunInitialFitView = React.useRef(false); const onNodesChange = React.useCallback( (changes) => setNodes((nodesSnapshot) => RF.applyNodeChanges(changes, nodesSnapshot)), @@ -91,60 +114,99 @@ 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). + // Post-layout work (viewport restore, re-fit, submitModel) runs directly inside the + // async callback via refs, so this effect never depends on selectedNodeId, viewport, + // or any other value that changes independently of layout. 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(() => { + // Clear any previous layout error when starting a new layout cycle + // so the editor can recover if the new layout succeeds. + setLayoutError(null); 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)); + // 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); + + // Post-layout viewport work runs in a zero-delay timeout so React Flow has + // processed the new nodes before we read or set the viewport. + setTimeout(() => { + if (!isActive) return; + + const pendingRestore = pendingViewportRestoreRef.current; + if (pendingRestore) { + // Undo/redo — restore saved viewport instead of fitting. + reactFlowInstance.setViewport(pendingRestore); + clearPendingViewportRestoreRef.current(); + // Submit with the restored viewport directly — setViewport is async so + // getViewport() would still return the old value at this point. + const currentModel = modelRef.current; + if (currentModel !== null) { + submitModelRef.current(currentModel, pendingRestore, selectedNodeIdRef.current); + } + } else { + if (isReadOnlyRef.current && hasRunInitialFitView.current) { + // Re-fit on subsequent read-only layout cycles (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 }); + } + hasRunInitialFitView.current = true; - // Queue fitView to run after React updates the DOM - fitViewTimeoutId = setTimeout(() => reactFlowInstance.fitView(), 0); + // Submit model with the real viewport captured after layout settles. + // Diagram.tsx is the sole caller of submitModel. + const currentModel = modelRef.current; + if (currentModel !== null) { + submitModelRef.current( + currentModel, + reactFlowInstance.getViewport(), + selectedNodeIdRef.current, + ); + } + } + }, 0); } }) .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); + setLayoutError(error instanceof Error ? error : new Error(String(error))); }); - }, 100); // 150ms debounce delay + }, 300); - // Cleanup function to cancel stale updates and clear timeouts return () => { isActive = false; - - // Cancel debounce timer - if (debounceTimeoutId !== null) { - clearTimeout(debounceTimeoutId); - } - - // Cancel fitView timer - if (fitViewTimeoutId !== null) { - clearTimeout(fitViewTimeoutId); - } - - // Abort in-flight layout calculation - if (abortController) { - abortController.abort(); - } + clearTimeout(debounceTimeoutId); + abortController?.abort(); }; - }, [model, errors, reactFlowInstance, setNodes, setEdges]); + }, [model, errors, setNodes, setEdges, reactFlowInstance]); + + if (layoutError) { + return ( + + ); + } 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..1a3ad013 --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/react-flow/hooks/useHistory.ts @@ -0,0 +1,173 @@ +/* + * 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 = { + /** + * Flat array of all snapshots. + * Entries before `presentIndex` are past; entries after are future. + * The total number of past entries is capped at HISTORY_STACK_SIZE + * (i.e. presentIndex <= HISTORY_STACK_SIZE at all times). + */ + history: T[]; + /** + * Index of the current snapshot within `history`. + * -1 when the history has not been initialised yet (present is null). + */ + presentIndex: number; +}; + +/** Convenience accessor — null before the first push. */ +export function getPresent(state: HistoryState): T | null { + return state.presentIndex === -1 ? null : (state.history[state.presentIndex] ?? null); +} + +/** Convenience accessor — entries before the present, oldest first. */ +export function getPast(state: HistoryState): T[] { + return state.presentIndex <= 0 ? [] : state.history.slice(0, state.presentIndex); +} + +/** Convenience accessor — entries after the present, most-recently-undone first. */ +export function getFuture(state: HistoryState): T[] { + return state.presentIndex === -1 ? [] : state.history.slice(state.presentIndex + 1); +} + +type HistoryAction = + | { type: "PUSH"; payload: T } + | { type: "SET_PRESENT"; payload: T } + | { type: "UNDO" } + | { type: "REDO" } + | { type: "RESET" }; + +export function historyReducer( + state: HistoryState, + action: HistoryAction, +): HistoryState { + switch (action.type) { + case "PUSH": { + // Truncate any future entries (branch pruning). + const withoutFuture = state.history.slice(0, state.presentIndex + 1); + + // Enforce the past cap: past length = presentIndex, so if it is already + // at the cap we evict the oldest entry before appending. + const capped = + withoutFuture.length > HISTORY_STACK_SIZE ? withoutFuture.slice(1) : withoutFuture; + + return { + history: [...capped, action.payload], + presentIndex: capped.length, // new entry is always at the end + }; + } + + case "SET_PRESENT": { + // Replace present in-place without touching past or future. + if (state.presentIndex === -1) { + // Uninitialised — treat identically to the first PUSH. + return { history: [action.payload], presentIndex: 0 }; + } + const next = [...state.history]; + next[state.presentIndex] = action.payload; + return { history: next, presentIndex: state.presentIndex }; + } + + case "UNDO": { + // Guard: nothing to undo. + if (state.presentIndex <= 0) return state; + return { ...state, presentIndex: state.presentIndex - 1 }; + } + + case "REDO": { + // Guard: nothing to redo. + if (state.presentIndex >= state.history.length - 1) return state; + return { ...state, presentIndex: state.presentIndex + 1 }; + } + + case "RESET": + // Wipe the entire history stack and return to the uninitialised state. + return initialHistoryState(); + + default: + return state; + } +} + +const initialHistoryState = (): HistoryState => ({ + history: [], + presentIndex: -1, +}); + +export type UseHistoryReturn = { + state: HistoryState; + push: (payload: T) => void; + setPresent: (payload: T) => void; + reset: () => void; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; +}; + +/** + * Generic history hook backed by useReducer. + * Uses a single flat array + a cursor index instead of three separate arrays. + * Starts uninitialised (presentIndex = -1). 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 reset = React.useCallback(() => { + dispatch({ type: "RESET" }); + }, []); + + const undo = React.useCallback(() => { + dispatch({ type: "UNDO" }); + }, []); + + const redo = React.useCallback(() => { + dispatch({ type: "REDO" }); + }, []); + + return { + state, + push, + setPresent, + reset, + undo, + redo, + canUndo: state.presentIndex > 0, + canRedo: state.presentIndex < state.history.length - 1, + }; +} 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..7101cfdb --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/react-flow/hooks/useWorkflowHistory.ts @@ -0,0 +1,219 @@ +/* + * 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/structuralEqual"; +import { useHistory, getPresent, getPast, getFuture } 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; + /** + * Resets history to the uninitialised state (present = null). + * Use when external content becomes unparseable so the diagram can correctly + * render the parsing-error page instead of keeping stale model content. + */ + resetHistory: () => 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, + reset, + 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 = getPresent(stateRef.current); + + // 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 or addon panel). + // Preserve the current viewport so undo restores to where the user was looking, + // rather than the placeholder {x:0,y:0,zoom:1} passed by the caller. + // The real viewport will be updated by submitModel after layout settles. + push({ model: newModel, viewport: present.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 = getPresent(stateRef.current); + + // 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 = getPast(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 = getFuture(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); + }, []); + + const resetHistory = React.useCallback(() => { + reset(); + }, [reset]); + + return { + model: getPresent(state)?.model ?? null, + selectedNodeId: getPresent(state)?.selectedNodeId ?? null, + seedModel, + submitModel, + resetHistory, + 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..dcf93340 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx @@ -17,11 +17,12 @@ import type { Specification } from "@openworkflowspec/sdk"; import * as React from "react"; import type * as RF from "@xyflow/react"; -import type { SdkError } from "../core"; +import type { ContentFormat, SdkError } from "../core"; export type DiagramEditorContextType = { isReadOnly: boolean; locale: string; + contentFormat: ContentFormat; model: Specification.Workflow | null; errors: SdkError[]; nodes: RF.Node[]; @@ -29,11 +30,30 @@ export type DiagramEditorContextType = { taskReferences: Set; selectedNodeId: string | null; - setIsReadOnly: React.Dispatch>; setLocale: React.Dispatch>; 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..845ca9b5 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -15,76 +15,209 @@ */ import * as React from "react"; -import { buildFlatGraph, getTaskReferences, parseWorkflow } from "../core"; -import { DiagramEditorProps } from "../diagram-editor/DiagramEditor"; +import { + buildFlatGraph, + ContentFormat, + getTaskReferences, + parseWorkflow, + serializeWorkflow, +} from "../core"; +import type { Specification } from "@openworkflowspec/sdk"; +import { DiagramEditorProps, DiagramEditorRef } from "../diagram-editor/DiagramEditor"; import { 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 = DiagramEditorProps; -export const DiagramEditorContextProvider = ( - props: React.PropsWithChildren, -) => { - // Initialize states with props values - const [isReadOnly, setIsReadOnly] = React.useState(props.isReadOnly); +/** + * 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 = React.forwardRef< + DiagramEditorRef, + React.PropsWithChildren +>((props, ref) => { + // Detect the serialization format once from the initial content prop. + // JSON content starts with `{` (after trimming); everything else is YAML. + // useState keeps the format in sync with React's render cycle, so consumers + // always see the current format without needing a separate cache-buster counter. + const [contentFormat, setContentFormat] = React.useState( + props.content.trimStart().startsWith("{") ? "json" : "yaml", + ); + + // 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, + resetHistory, + undo: historyUndo, + redo: historyRedo, + canUndo, + canRedo, + pendingViewportRestore, + clearPendingViewportRestore, + } = useWorkflowHistory(isReadOnly); + + // errors are shared state written by both the props.content path and setContent. + // They are never part of a history snapshot — always reflect the last parse result. + const [errors, setErrors] = React.useState["errors"]>( + () => parseWorkflow(props.content).errors, + ); + + // 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; + + // 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. + React.useEffect(() => { + const { model: parsedModel, errors: parsedErrors } = parseWorkflow(props.content); + setErrors(parsedErrors); + if (parsedModel === null) { + // Content is unparseable — reset history to null so downstream consumers + // (e.g. DiagramEditorContent) see model === null and render the error page + // instead of displaying the last successfully-parsed (now stale) model. + resetHistory(); + setSelectedNodeId(null); + 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); + // selectedNodeIdRef is a ref (stable, mutated inline — not a dep by convention). + }, [props.content, resetHistory, seedModel]); 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); - }, [model]); + /** + * 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, errors: newErrors } = parseWorkflow(content); + if (newModel === null) return; + + setErrors(newErrors); + + const newFormat: ContentFormat = content.trimStart().startsWith("{") ? "json" : "yaml"; + setContentFormat(newFormat); + + const resolvedId = resolveSelectedId(newModel, selectedNodeIdRef.current); + setSelectedNodeId(resolvedId); + seedModel(newModel, { x: 0, y: 0, zoom: 1 }, resolvedId); + }, + [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 ""; + return serializeWorkflow(model, contentFormat); + }, [model, contentFormat]); + + 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 + // Memoize context value to prevent unnecessary re-renders of consumers. const context = React.useMemo( () => ({ isReadOnly, locale, + contentFormat, model, errors, nodes, edges, taskReferences, selectedNodeId, - setIsReadOnly, setLocale, setNodes, setEdges, setSelectedNodeId, + submitModel, + undo, + redo, + canUndo, + canRedo, + pendingViewportRestore, + clearPendingViewportRestore, + setContent, }), [ isReadOnly, locale, + contentFormat, model, errors, nodes, edges, taskReferences, selectedNodeId, - setIsReadOnly, setLocale, setNodes, setEdges, setSelectedNodeId, + submitModel, + undo, + redo, + canUndo, + canRedo, + pendingViewportRestore, + clearPendingViewportRestore, + setContent, ], ); return ( {props.children} ); -}; +}); 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..b6ced1f1 --- /dev/null +++ b/packages/open-workflow-diagram-editor/stories/features/UndoRedo.stories.tsx @@ -0,0 +1,754 @@ +/* + * 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 React, { useCallback, useEffect, useRef, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { DiagramEditor, DiagramEditorRef } from "../../src/diagram-editor/DiagramEditor"; +import { useResolvedColorMode } from "../../src/hooks/useResolvedColorMode"; +import { authenticationReusable } from "../examples"; + +// --------------------------------------------------------------------------- +// Toolbar theme tokens +// --------------------------------------------------------------------------- + +type Theme = { + toolbar: React.CSSProperties; + button: React.CSSProperties; + buttonPressed: { background: string; boxShadow: string }; +}; + +const light: Theme = { + toolbar: { borderBottom: "1px solid #e5e7eb", background: "#f7f8fa" }, + button: { + border: "1px solid #d1d5db", + background: "linear-gradient(to bottom, #ffffff, #f3f4f6)", + color: "#374151", + boxShadow: "0 1px 2px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.9)", + }, + buttonPressed: { + background: "linear-gradient(to bottom, #e5e7eb, #f3f4f6)", + boxShadow: "0 0 0 rgba(0,0,0,0), inset 0 1px 3px rgba(0,0,0,0.15)", + }, +}; + +const dark: Theme = { + toolbar: { borderBottom: "1px solid #374151", background: "#1f2937" }, + button: { + border: "1px solid #4b5563", + background: "linear-gradient(to bottom, #374151, #2d3748)", + color: "#e5e7eb", + boxShadow: "0 1px 2px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)", + }, + buttonPressed: { + background: "linear-gradient(to bottom, #1f2937, #2d3748)", + boxShadow: "0 0 0 rgba(0,0,0,0), inset 0 2px 4px rgba(0,0,0,0.4)", + }, +}; + +const baseButtonStyle: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: "6px", + padding: "4px 12px", + height: "32px", + fontSize: "13px", + fontFamily: "inherit", + fontWeight: 500, + lineHeight: 1, + cursor: "pointer", + borderRadius: "6px", + userSelect: "none", + transition: "background 60ms, box-shadow 60ms, transform 60ms", +}; + +// --------------------------------------------------------------------------- +// Story render function +// --------------------------------------------------------------------------- + +function UndoRedoStory({ + content, + colorMode: colorModeProp, + isReadOnly, + locale, + onContentChange, +}: { + content: string; + colorMode?: string; + isReadOnly?: boolean; + locale?: string; + onContentChange?: (content: string) => void; +}) { + const editorRef = useRef(null); + const resolvedColorMode = useResolvedColorMode( + (colorModeProp as "light" | "dark" | "system") ?? "system", + ); + const theme = resolvedColorMode === "dark" ? dark : light; + + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [modalText, setModalText] = useState(""); + const [getContentOpen, setGetContentOpen] = useState(false); + const [getContentText, setGetContentText] = useState(""); + const [copied, setCopied] = useState(false); + + // True only while a button-triggered undo/redo is pending its deferred sync. + // Prevents onContentChange from firing when content changes externally + // (e.g. via the Controls panel), which would create a feedback loop. + const undoRedoInFlight = useRef(false); + + const syncHistory = useCallback(() => { + setCanUndo(editorRef.current?.canUndo ?? false); + setCanRedo(editorRef.current?.canRedo ?? false); + if (undoRedoInFlight.current && onContentChange && editorRef.current) { + onContentChange(editorRef.current.getContent()); + } + undoRedoInFlight.current = false; + }, [onContentChange]); + + // Sync button state after external content changes — never notify the host. + useEffect(() => { + const id = setTimeout(syncHistory, 0); + return () => clearTimeout(id); + }, [content, syncHistory]); + + // Expose the ref on the window for browser-console testing. + useEffect(() => { + (window as unknown as Record).diagramEditorRef = editorRef; + return () => { + delete (window as unknown as Record).diagramEditorRef; + }; + }, []); + + // --------------------------------------------------------------------------- + // Button helpers + // --------------------------------------------------------------------------- + + const buttonStyle = (disabled: boolean): React.CSSProperties => ({ + ...baseButtonStyle, + ...theme.button, + ...(disabled ? { opacity: 0.4, cursor: "not-allowed", pointerEvents: "none" } : {}), + }); + + const handlePointerDown = (e: React.PointerEvent) => { + const btn = e.currentTarget; + btn.style.background = theme.buttonPressed.background; + btn.style.boxShadow = theme.buttonPressed.boxShadow; + btn.style.transform = "translateY(1px)"; + }; + + const handlePointerUp = (e: React.PointerEvent) => { + const btn = e.currentTarget; + btn.style.background = theme.button.background as string; + btn.style.boxShadow = theme.button.boxShadow as string; + btn.style.transform = ""; + }; + + // --------------------------------------------------------------------------- + // Set Content modal + // --------------------------------------------------------------------------- + + const openSetContentModal = () => { + setModalText(editorRef.current?.getContent() ?? ""); + setModalOpen(true); + }; + + const applyModal = () => { + editorRef.current?.setContent(modalText); + setModalOpen(false); + undoRedoInFlight.current = true; + setTimeout(syncHistory, 0); + }; + + // --------------------------------------------------------------------------- + // Get Content modal + // --------------------------------------------------------------------------- + + const openGetContentModal = () => { + setGetContentText(editorRef.current?.getContent() ?? ""); + setCopied(false); + setGetContentOpen(true); + }; + + const copyToClipboard = () => { + navigator.clipboard.writeText(getContentText).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + // --------------------------------------------------------------------------- + // Shared modal styles + // --------------------------------------------------------------------------- + + const overlayStyle: React.CSSProperties = { + position: "fixed", + inset: 0, + background: "rgba(0,0,0,0.45)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 1000, + }; + + const dialogStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + width: "min(660px, 92vw)", + maxHeight: "80vh", + borderRadius: "10px", + overflow: "hidden", + boxShadow: "0 20px 60px rgba(0,0,0,0.3)", + background: resolvedColorMode === "dark" ? "#1f2937" : "#ffffff", + border: resolvedColorMode === "dark" ? "1px solid #374151" : "1px solid #d1d5db", + }; + + const dialogHeaderStyle: React.CSSProperties = { + padding: "14px 20px", + fontWeight: 600, + fontSize: "15px", + borderBottom: resolvedColorMode === "dark" ? "1px solid #374151" : "1px solid #e5e7eb", + color: resolvedColorMode === "dark" ? "#f3f4f6" : "#111827", + flexShrink: 0, + }; + + const textareaStyle: React.CSSProperties = { + flex: 1, + resize: "none", + border: "none", + outline: "none", + padding: "16px 20px", + fontFamily: '"Menlo", "Consolas", "Monaco", monospace', + fontSize: "12.5px", + lineHeight: 1.6, + background: resolvedColorMode === "dark" ? "#111827" : "#f7f8fa", + color: resolvedColorMode === "dark" ? "#e5e7eb" : "#1f2328", + overflowY: "auto", + minHeight: "320px", + }; + + const dialogFooterStyle: React.CSSProperties = { + display: "flex", + justifyContent: "flex-end", + gap: "8px", + padding: "12px 16px", + borderTop: resolvedColorMode === "dark" ? "1px solid #374151" : "1px solid #e5e7eb", + flexShrink: 0, + }; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + return ( + <> + {/* Get Content modal */} + {getContentOpen && ( +
setGetContentOpen(false)} role="presentation"> +
e.stopPropagation()} role="none"> +
Get Content
+