diff --git a/.changeset/wb-217-actions-hook.md b/.changeset/wb-217-actions-hook.md
new file mode 100644
index 000000000..342a57603
--- /dev/null
+++ b/.changeset/wb-217-actions-hook.md
@@ -0,0 +1,5 @@
+---
+'@workflowbuilder/sdk': minor
+---
+
+feat: add `useWorkflowBuilderActions()` hook exposing save / import / export / settings / read-only / theme / layout-direction actions. Lets custom layouts that omit `` trigger every action the built-in app bar offers. `toggleLayoutDirection` takes an optional `LayoutChangeOptions` (`{ flipPositions?, fitView? }`): `flipPositions` mirrors node `x`/`y` so the diagram re-lays-out along the new axis (a naive mirror, not auto-layout), and `fitView` re-fits the view afterwards. `setLayoutDirection` stays idempotent and takes no options. The `LayoutChangeOptions` and `WorkflowBuilderActions` types are exported from the package barrel. See the [Layout without the app bar guide](https://www.workflowbuilder.io/docs/guides/no-app-bar-layout/).
diff --git a/.changeset/wb-217-flip-layout-stale-handles.md b/.changeset/wb-217-flip-layout-stale-handles.md
new file mode 100644
index 000000000..b52957b0c
--- /dev/null
+++ b/.changeset/wb-217-flip-layout-stale-handles.md
@@ -0,0 +1,5 @@
+---
+'@workflowbuilder/sdk': patch
+---
+
+fix: re-measure node internals when `layoutDirection` changes. React Flow caches each handle's measured bounds; when the direction flipped, the cache stayed stale and edges kept routing to the old port positions on every node that had already been mounted. The `DiagramContainer` now calls `updateNodeInternals` for all mounted nodes whenever `layoutDirection` changes, so handle positions, edge routing, and the new `toggleLayoutDirection` action all stay in sync.
diff --git a/.changeset/wb-217-theme-shared-store.md b/.changeset/wb-217-theme-shared-store.md
new file mode 100644
index 000000000..d7d4ed892
--- /dev/null
+++ b/.changeset/wb-217-theme-shared-store.md
@@ -0,0 +1,5 @@
+---
+'@workflowbuilder/sdk': patch
+---
+
+fix: theme is now a shared store, applied to the DOM on load. Previously `useTheme` held per-component `useState`, so multiple consumers could desync and the persisted theme was only applied to the DOM by the app-bar toggle's mount effect. Theme now lives in a single module-level store (`useSyncExternalStore`), and the persisted value is applied to `document` on import, so a saved non-default theme paints correctly on first load even when `` is omitted.
diff --git a/apps/docs/src/content/docs/guides/no-app-bar-layout.md b/apps/docs/src/content/docs/guides/no-app-bar-layout.md
new file mode 100644
index 000000000..66c85995b
--- /dev/null
+++ b/apps/docs/src/content/docs/guides/no-app-bar-layout.md
@@ -0,0 +1,91 @@
+---
+title: Layout without the app bar
+description: Use useWorkflowBuilderActions() to trigger save, import, export, settings, read-only, theme, and layout-direction commands from your own UI when omitting WorkflowBuilder.TopBar.
+sidebar:
+ order: 6
+---
+
+`` ships save, import / export, settings, read-only, theme, and layout-direction controls. When you build a custom layout and omit the top bar, those actions are still reachable via the `useWorkflowBuilderActions()` hook — call it from any descendant of `` and wire the returned callbacks to your own buttons.
+
+## Quick example
+
+```tsx
+import { WorkflowBuilder, useWorkflowBuilderActions } from '@workflowbuilder/sdk';
+
+function MyToolbar() {
+ const actions = useWorkflowBuilderActions();
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function App() {
+ return (
+
+
+
+
+
+
+ );
+}
+```
+
+## Action reference
+
+| Action | Signature | What it does |
+| ----------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `save` | `() => Promise` | Triggers a manual save through the active [`integration` strategy](/guides/configuring-the-editor/#integration-strategies). |
+| `openSettings` | `() => void` | Opens the built-in workflow settings modal (general settings, global variables). |
+| `openImport` | `() => void` | Opens the import-diagram modal. |
+| `openExport` | `() => void` | Opens the export-diagram modal. |
+| `toggleReadOnly` | `() => void` | Flips read-only mode. |
+| `setReadOnly` | `(value: boolean) => void` | Sets read-only mode explicitly. |
+| `toggleDarkMode` | `() => void` | Flips the editor theme between `'light'` and `'dark'`. |
+| `setTheme` | `(theme: 'light' \| 'dark') => void` | Sets the editor theme explicitly. |
+| `setLayoutDirection` | `(direction: 'RIGHT' \| 'DOWN') => void` | Sets the diagram layout direction. Idempotent. |
+| `toggleLayoutDirection` | `(options?: LayoutChangeOptions) => void` | Flips `'RIGHT'` ↔ `'DOWN'`. |
+
+`toggleLayoutDirection` accepts an optional `LayoutChangeOptions`:
+
+| Field | Type | Default | What it does |
+| --------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `flipPositions` | `boolean` | `false` | Mirror node positions (swaps each node's `x`/`y`) so the diagram re-lays-out along the new axis. Naive mirror, not auto-layout: it ignores node sizes, so pair it with `fitView`. |
+| `fitView` | `boolean` | `false` | Animate the view to fit all nodes after the change. |
+
+Without `flipPositions`, a direction change only re-orients handles and re-routes edges, node coordinates stay put. Position flipping is relative, so it lives only on the toggle, not on the idempotent `setLayoutDirection`.
+
+## Renaming the workflow
+
+Document name lives in the editor store. Read and write it with the existing `useStore` hook:
+
+```tsx
+import { useStore } from '@workflowbuilder/sdk';
+
+function NameField() {
+ const documentName = useStore((s) => s.documentName ?? '');
+ const setDocumentName = useStore((s) => s.setDocumentName);
+ return setDocumentName(event.target.value)} />;
+}
+```
+
+## Constraints
+
+- The hook must be called from a descendant of ``. `save` reads the active integration via React context; calling the hook outside Root resolves `save()` to `'error'` and logs a warning.
+- The returned object is a stable reference across re-renders, so you can pass any callback straight to an event handler without `useCallback`.
+- Modal openers (`openSettings` / `openImport` / `openExport`) render into the modal overlay mounted by `` — no extra setup needed.
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index 75a4a55f2..43fadae04 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -85,6 +85,8 @@ To add custom overlays alongside the default layout, mount it explicitly:
Each subcomponent is also exported under a named alias (`WorkflowBuilderTopBar`, `WorkflowBuilderPalette`, `WorkflowBuilderCanvas`, `WorkflowBuilderPropertiesPanel`, `WorkflowBuilderDefaultLayout`) for consumers who prefer the classic style.
+If you omit ``, use [`useWorkflowBuilderActions()`](https://www.workflowbuilder.io/docs/guides/no-app-bar-layout/) to trigger save / import / export / settings / read-only / theme / layout-direction from your own controls.
+
## `` props
| Prop | Type | Description |
diff --git a/packages/sdk/src/features/diagram/diagram.tsx b/packages/sdk/src/features/diagram/diagram.tsx
index a90c47fb2..338055151 100644
--- a/packages/sdk/src/features/diagram/diagram.tsx
+++ b/packages/sdk/src/features/diagram/diagram.tsx
@@ -9,8 +9,9 @@ import {
type OnSelectionChangeParams,
ReactFlow,
SelectionMode,
+ useUpdateNodeInternals,
} from '@xyflow/react';
-import { type DragEventHandler, useCallback, useMemo } from 'react';
+import { type DragEventHandler, useCallback, useEffect, useMemo } from 'react';
import type { DragEvent } from 'react';
import styles from './diagram.module.css';
@@ -70,6 +71,18 @@ function DiagramContainerComponent({ edgeTypes = {} }: DiagramContainerProps) {
const setConnectionBeingDragged = useStore((store) => store.setConnectionBeingDragged);
const nodeTypes = useNodeTypes();
+ // React Flow caches each handle's measured bounds in `nodeInternals`. When
+ // `layoutDirection` flips, existing nodes re-render their `` with a
+ // new `position` prop, but the cache stays stale and edges keep routing to
+ // the old port spots. Ask React Flow to remeasure every mounted node when
+ // the direction changes.
+ const layoutDirection = useStore((store) => store.layoutDirection);
+ const updateNodeInternals = useUpdateNodeInternals();
+ useEffect(() => {
+ const ids = useStore.getState().nodes.map((node) => node.id);
+ if (ids.length > 0) updateNodeInternals(ids);
+ }, [layoutDirection, updateNodeInternals]);
+
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
diff --git a/packages/sdk/src/hooks/theme.spec.ts b/packages/sdk/src/hooks/theme.spec.ts
new file mode 100644
index 000000000..643889801
--- /dev/null
+++ b/packages/sdk/src/hooks/theme.spec.ts
@@ -0,0 +1,72 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { getTheme, initTheme, setTheme, subscribeTheme } from './theme';
+
+describe('theme module', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ delete document.documentElement.dataset.theme;
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ delete document.documentElement.dataset.theme;
+ });
+
+ it('defaults to "light" when localStorage has no value', () => {
+ expect(getTheme()).toBe('light');
+ });
+
+ it('reads the persisted value from localStorage', () => {
+ localStorage.setItem('wb-theme', 'dark');
+ expect(getTheme()).toBe('dark');
+ });
+
+ it('setTheme updates localStorage and the document attribute', () => {
+ setTheme('dark');
+
+ expect(localStorage.getItem('wb-theme')).toBe('dark');
+ expect(document.documentElement.dataset.theme).toBe('dark');
+ expect(getTheme()).toBe('dark');
+ });
+
+ it('setTheme notifies subscribers', () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribeTheme(listener);
+
+ setTheme('dark');
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ unsubscribe();
+ });
+
+ it('setTheme does NOT notify subscribers when the value is unchanged', () => {
+ setTheme('light');
+ const listener = vi.fn();
+ const unsubscribe = subscribeTheme(listener);
+
+ setTheme('light');
+
+ expect(listener).not.toHaveBeenCalled();
+ unsubscribe();
+ });
+
+ it('initTheme applies the persisted theme to the DOM without a toggle', () => {
+ localStorage.setItem('wb-theme', 'dark');
+ delete document.documentElement.dataset.theme;
+
+ initTheme();
+
+ expect(document.documentElement.dataset.theme).toBe('dark');
+ });
+
+ it('unsubscribe removes the listener', () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribeTheme(listener);
+ unsubscribe();
+
+ setTheme('dark');
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/sdk/src/hooks/theme.ts b/packages/sdk/src/hooks/theme.ts
new file mode 100644
index 000000000..8d64c8423
--- /dev/null
+++ b/packages/sdk/src/hooks/theme.ts
@@ -0,0 +1,46 @@
+const THEME_KEY = 'wb-theme';
+
+export type Theme = 'dark' | 'light';
+
+type Listener = () => void;
+
+const listeners = new Set();
+
+function applyToDom(theme: Theme): void {
+ document.documentElement.dataset.theme = theme;
+}
+
+export function getTheme(): Theme {
+ return (localStorage.getItem(THEME_KEY) as Theme | null) ?? 'light';
+}
+
+export function setTheme(theme: Theme): void {
+ const current = getTheme();
+ if (current === theme) return;
+
+ localStorage.setItem(THEME_KEY, theme);
+ applyToDom(theme);
+
+ for (const listener of listeners) listener();
+}
+
+export function subscribeTheme(listener: Listener): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+/**
+ * Reflect the persisted theme on the DOM. Idempotent. Run once on import so a
+ * saved non-default theme paints correctly on first load, without waiting for
+ * a `setTheme` toggle. Previously this lived in `useTheme`'s mount effect, so
+ * it only ran when the app-bar's theme toggle was mounted; centralizing it
+ * here keeps it correct for custom layouts that omit the bar.
+ */
+export function initTheme(): void {
+ applyToDom(getTheme());
+}
+
+// Client-only SDK: apply the persisted theme as soon as this module loads.
+initTheme();
diff --git a/packages/sdk/src/hooks/use-theme.ts b/packages/sdk/src/hooks/use-theme.ts
index 69e4c0269..9b3a82f4d 100644
--- a/packages/sdk/src/hooks/use-theme.ts
+++ b/packages/sdk/src/hooks/use-theme.ts
@@ -1,21 +1,15 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useSyncExternalStore } from 'react';
-const THEME_KEY = 'wb-theme';
-type Theme = 'dark' | 'light';
+import { getTheme, setTheme, subscribeTheme } from './theme';
export function useTheme() {
- const [theme, setTheme] = useState(() => {
- return (localStorage.getItem(THEME_KEY) || 'light') as Theme;
- });
-
- useEffect(() => {
- document.documentElement.dataset.theme = theme;
- localStorage.setItem(THEME_KEY, theme);
- }, [theme]);
+ const theme = useSyncExternalStore(subscribeTheme, getTheme, getTheme);
const toggleTheme = useCallback(() => {
- setTheme((previous) => (previous === 'light' ? 'dark' : 'light'));
+ setTheme(getTheme() === 'light' ? 'dark' : 'light');
}, []);
return { theme, toggleTheme };
}
+
+export { type Theme } from './theme';
diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx
new file mode 100644
index 000000000..aebe968c4
--- /dev/null
+++ b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx
@@ -0,0 +1,268 @@
+import { render } from '@testing-library/react';
+import { useRef } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { openExportModal } from '../features/integration/components/import-export/export-modal/open-export-modal';
+import { openImportModal } from '../features/integration/components/import-export/import-modal/open-import-modal';
+import { IntegrationContext } from '../features/integration/components/integration-variants/context/integration-context-wrapper';
+import { openModalWorkflowSettings } from '../features/variables/modals/modal-settings';
+import { useStore } from '../store/store';
+import { getTheme } from './theme';
+import { type WorkflowBuilderActions, useWorkflowBuilderActions } from './use-workflow-builder-actions';
+
+vi.mock('../features/integration/components/import-export/export-modal/open-export-modal', () => ({
+ openExportModal: vi.fn(),
+}));
+
+vi.mock('../features/integration/components/import-export/import-modal/open-import-modal', () => ({
+ openImportModal: vi.fn(),
+}));
+
+vi.mock('../features/variables/modals/modal-settings', () => ({
+ openModalWorkflowSettings: vi.fn(),
+}));
+
+// Short-circuit the use-integration-store chain that drags in
+// @synergycodes/overflow-ui (CSS side-effect that vitest's jsdom env can't load).
+vi.mock('@/features/integration/stores/use-integration-store', () => ({
+ getStoreSavingStatus: vi.fn(),
+ setStoreSavingStatus: vi.fn(),
+}));
+
+vi.mock('@/features/changes-tracker/stores/use-changes-tracker-store', () => ({
+ trackFutureChange: vi.fn(),
+}));
+
+function renderHook(useHookFn: () => T, onSave = vi.fn().mockResolvedValue('success' as const)) {
+ const captured: { current: T | null } = { current: null };
+
+ function Consumer() {
+ captured.current = useHookFn();
+ return null;
+ }
+
+ render(
+
+
+ ,
+ );
+
+ return { actions: captured, onSave };
+}
+
+describe('useWorkflowBuilderActions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ delete document.documentElement.dataset.theme;
+ useStore.setState({ isReadOnlyMode: false, layoutDirection: 'RIGHT', nodes: [], reactFlowInstance: undefined });
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ delete document.documentElement.dataset.theme;
+ });
+
+ describe('save', () => {
+ it('calls IntegrationContext.onSave with isAutoSave: false', async () => {
+ const { actions, onSave } = renderHook(useWorkflowBuilderActions);
+
+ await actions.current!.save();
+
+ expect(onSave).toHaveBeenCalledWith({ isAutoSave: false });
+ });
+
+ it('returns the resolution from onSave', async () => {
+ const { actions } = renderHook(useWorkflowBuilderActions, vi.fn().mockResolvedValue('error'));
+
+ await expect(actions.current!.save()).resolves.toBe('error');
+ });
+ });
+
+ describe('modal openers', () => {
+ it('openSettings delegates to openModalWorkflowSettings', () => {
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.openSettings();
+
+ expect(openModalWorkflowSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('openImport delegates to openImportModal', () => {
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.openImport();
+
+ expect(openImportModal).toHaveBeenCalledTimes(1);
+ });
+
+ it('openExport delegates to openExportModal', () => {
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.openExport();
+
+ expect(openExportModal).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('read-only', () => {
+ it('toggleReadOnly flips isReadOnlyMode', () => {
+ const { actions } = renderHook(useWorkflowBuilderActions);
+ expect(useStore.getState().isReadOnlyMode).toBe(false);
+
+ actions.current!.toggleReadOnly();
+ expect(useStore.getState().isReadOnlyMode).toBe(true);
+
+ actions.current!.toggleReadOnly();
+ expect(useStore.getState().isReadOnlyMode).toBe(false);
+ });
+
+ it('setReadOnly sets the value explicitly regardless of current state', () => {
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.setReadOnly(true);
+ expect(useStore.getState().isReadOnlyMode).toBe(true);
+
+ actions.current!.setReadOnly(true);
+ expect(useStore.getState().isReadOnlyMode).toBe(true);
+
+ actions.current!.setReadOnly(false);
+ expect(useStore.getState().isReadOnlyMode).toBe(false);
+ });
+ });
+
+ describe('theme', () => {
+ it('setTheme writes through to the theme module', () => {
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.setTheme('dark');
+
+ expect(getTheme()).toBe('dark');
+ expect(document.documentElement.dataset.theme).toBe('dark');
+ });
+
+ it('toggleDarkMode flips theme', () => {
+ const { actions } = renderHook(useWorkflowBuilderActions);
+ expect(getTheme()).toBe('light');
+
+ actions.current!.toggleDarkMode();
+ expect(getTheme()).toBe('dark');
+
+ actions.current!.toggleDarkMode();
+ expect(getTheme()).toBe('light');
+ });
+ });
+
+ describe('layout direction', () => {
+ it('setLayoutDirection sets the value explicitly', () => {
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.setLayoutDirection('DOWN');
+
+ expect(useStore.getState().layoutDirection).toBe('DOWN');
+ });
+
+ it('toggleLayoutDirection flips RIGHT to DOWN and back', () => {
+ const { actions } = renderHook(useWorkflowBuilderActions);
+ expect(useStore.getState().layoutDirection).toBe('RIGHT');
+
+ actions.current!.toggleLayoutDirection();
+ expect(useStore.getState().layoutDirection).toBe('DOWN');
+
+ actions.current!.toggleLayoutDirection();
+ expect(useStore.getState().layoutDirection).toBe('RIGHT');
+ });
+
+ it('leaves node positions untouched by default', () => {
+ useStore.setState({ nodes: [{ id: 'n1', position: { x: 10, y: 20 } }] as never });
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.toggleLayoutDirection();
+
+ expect(useStore.getState().nodes[0].position).toEqual({ x: 10, y: 20 });
+ });
+
+ it('swaps every node x/y when flipPositions is set', () => {
+ useStore.setState({
+ nodes: [
+ { id: 'n1', position: { x: 10, y: 20 } },
+ { id: 'n2', position: { x: 30, y: 40 } },
+ ] as never,
+ });
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.toggleLayoutDirection({ flipPositions: true });
+
+ expect(useStore.getState().nodes.map((n) => n.position)).toEqual([
+ { x: 20, y: 10 },
+ { x: 40, y: 30 },
+ ]);
+ });
+
+ it('setLayoutDirection is idempotent and never moves nodes', () => {
+ useStore.setState({ nodes: [{ id: 'n1', position: { x: 1, y: 2 } }] as never });
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.setLayoutDirection('DOWN');
+ actions.current!.setLayoutDirection('DOWN');
+
+ expect(useStore.getState().layoutDirection).toBe('DOWN');
+ expect(useStore.getState().nodes[0].position).toEqual({ x: 1, y: 2 });
+ });
+
+ it('flipPositions is relative: toggling twice restores original positions', () => {
+ useStore.setState({ nodes: [{ id: 'n1', position: { x: 10, y: 20 } }] as never });
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.toggleLayoutDirection({ flipPositions: true });
+ actions.current!.toggleLayoutDirection({ flipPositions: true });
+
+ expect(useStore.getState().nodes[0].position).toEqual({ x: 10, y: 20 });
+ });
+
+ it('fits the view when fitView is set', () => {
+ const fitView = vi.fn();
+ useStore.setState({ reactFlowInstance: { getZoom: () => 1, fitView } as never });
+ const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
+ callback(0);
+ return 0;
+ });
+ const { actions } = renderHook(useWorkflowBuilderActions);
+
+ actions.current!.toggleLayoutDirection({ fitView: true });
+
+ expect(fitView).toHaveBeenCalledTimes(1);
+ rafSpy.mockRestore();
+ });
+ });
+
+ describe('identity', () => {
+ it('returns a stable object reference across re-renders when dependencies are unchanged', () => {
+ const seen: WorkflowBuilderActions[] = [];
+ const onSave = vi.fn().mockResolvedValue('success' as const);
+
+ function Consumer() {
+ const actions = useWorkflowBuilderActions();
+ const ref = useRef(0);
+ ref.current += 1;
+ seen.push(actions);
+ return null;
+ }
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(seen.length).toBe(2);
+ expect(seen[0]).toBe(seen[1]);
+ });
+ });
+});
diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.ts b/packages/sdk/src/hooks/use-workflow-builder-actions.ts
new file mode 100644
index 000000000..e30490ead
--- /dev/null
+++ b/packages/sdk/src/hooks/use-workflow-builder-actions.ts
@@ -0,0 +1,138 @@
+import { useContext, useMemo } from 'react';
+
+import { openExportModal } from '../features/integration/components/import-export/export-modal/open-export-modal';
+import { openImportModal } from '../features/integration/components/import-export/import-modal/open-import-modal';
+import { IntegrationContext } from '../features/integration/components/integration-variants/context/integration-context-wrapper';
+import { openModalWorkflowSettings } from '../features/variables/modals/modal-settings';
+import type { LayoutDirection } from '../node/common';
+import { getStoreNodes, setStoreNodes } from '../store/slices/diagram-slice/actions';
+import { useStore } from '../store/store';
+import type { DidSaveStatus } from '../types/integration';
+import { type Theme, getTheme, setTheme } from './theme';
+import { useFitView } from './use-fit-view';
+
+/**
+ * Optional side effects for a layout-direction *toggle*.
+ *
+ * @category Hooks
+ */
+export type LayoutChangeOptions = {
+ /**
+ * Also reflow node positions by swapping each node's `x`/`y`, so the diagram
+ * visually re-lays-out along the new axis. Defaults to `false` (handles and
+ * edges re-orient, coordinates stay put). This is a naive mirror, not a
+ * layout algorithm: it ignores node dimensions, so non-square nodes shift
+ * relative to their neighbours. Pair it with `fitView` and treat it as a
+ * quick approximation, not production auto-layout.
+ */
+ flipPositions?: boolean;
+ /** Animate the view to fit all nodes after the change. Defaults to `false`. */
+ fitView?: boolean;
+};
+
+/**
+ * Imperative action surface for every command the built-in app bar
+ * exposes — `save`, modal openers, read-only / theme / layout-direction
+ * toggles. Use this when omitting `` from a
+ * custom layout so your own UI can trigger the same actions.
+ *
+ * Stable across renders while the active integration and the mounted
+ * React Flow instance are stable (layout actions close over the fit-view
+ * callback, which is keyed on that instance).
+ *
+ * @category Hooks
+ */
+export type WorkflowBuilderActions = {
+ /** Persist the current diagram through the active integration strategy. */
+ save: () => Promise;
+ /** Open the built-in workflow settings modal. */
+ openSettings: () => void;
+ /** Open the import-diagram modal. */
+ openImport: () => void;
+ /** Open the export-diagram modal. */
+ openExport: () => void;
+ /** Flip read-only mode. */
+ toggleReadOnly: () => void;
+ /** Set read-only mode explicitly. */
+ setReadOnly: (value: boolean) => void;
+ /** Flip the editor theme between `'light'` and `'dark'`. */
+ toggleDarkMode: () => void;
+ /** Set the editor theme explicitly. */
+ setTheme: (theme: Theme) => void;
+ /**
+ * Set the diagram layout direction (`'RIGHT'` ↔ `'DOWN'`). Idempotent:
+ * setting the same direction twice is a no-op. Position reflow is only
+ * offered on {@link toggleLayoutDirection}, where it is unambiguous.
+ */
+ setLayoutDirection: (direction: LayoutDirection) => void;
+ /**
+ * Flip the diagram layout direction. Pass `options.flipPositions` to also
+ * reflow node coordinates and/or `options.fitView` to re-fit the view
+ * afterwards.
+ */
+ toggleLayoutDirection: (options?: LayoutChangeOptions) => void;
+};
+
+/**
+ * Returns a stable object of action callbacks mirroring every command
+ * the built-in `` offers. Use it from a custom
+ * header / toolbar when omitting the bar.
+ *
+ * Must be called from a descendant of ``; `save`
+ * reads the active integration via React context.
+ *
+ * @example
+ * ```tsx
+ * function MyToolbar() {
+ * const actions = useWorkflowBuilderActions();
+ * return ;
+ * }
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * @category Hooks
+ */
+export function useWorkflowBuilderActions(): WorkflowBuilderActions {
+ const { onSave } = useContext(IntegrationContext);
+ const setToggleReadOnlyMode = useStore((s) => s.setToggleReadOnlyMode);
+ const setStoreLayoutDirection = useStore((s) => s.setLayoutDirection);
+ const fitView = useFitView();
+
+ return useMemo(
+ () => ({
+ save: () => onSave({ isAutoSave: false }),
+
+ openSettings: openModalWorkflowSettings,
+ openImport: openImportModal,
+ openExport: openExportModal,
+
+ toggleReadOnly: () => setToggleReadOnlyMode(),
+ setReadOnly: (value) => setToggleReadOnlyMode(value),
+
+ toggleDarkMode: () => setTheme(getTheme() === 'light' ? 'dark' : 'light'),
+ setTheme: (theme) => setTheme(theme),
+
+ setLayoutDirection: (direction) => setStoreLayoutDirection(direction),
+ toggleLayoutDirection: ({ flipPositions, fitView: doFitView } = {}) => {
+ setStoreLayoutDirection(useStore.getState().layoutDirection === 'RIGHT' ? 'DOWN' : 'RIGHT');
+
+ // Naive x/y mirror. Goes through setStoreNodes so the nodes stay on
+ // the same mutation path as the rest of the editor (schema re-validation
+ // included); positions can't change validation, but consistency matters
+ // more than the redundant pass.
+ if (flipPositions) {
+ setStoreNodes(
+ getStoreNodes().map((node) => ({ ...node, position: { x: node.position.y, y: node.position.x } })),
+ );
+ }
+
+ if (doFitView) fitView();
+ },
+ }),
+ [onSave, setToggleReadOnlyMode, setStoreLayoutDirection, fitView],
+ );
+}
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 8aa54f376..7c10d337f 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -140,6 +140,8 @@ export { defineNodeTemplate } from './utils/define-node-template';
export { useEffectChange } from './hooks/use-effect-change';
export { useFitView } from './hooks/use-fit-view';
export { useKeyPress } from './hooks/use-key-press';
+export { useWorkflowBuilderActions } from './hooks/use-workflow-builder-actions';
+export type { LayoutChangeOptions, WorkflowBuilderActions } from './hooks/use-workflow-builder-actions';
export { useLabelEdgeHover } from './features/diagram/edges/label-edge/use-label-edge-hover';
export { useSingleSelectedElement } from './features/properties-bar/use-single-selected-element';
export { useChangesTrackerStore, trackFutureChange } from './features/changes-tracker/stores/use-changes-tracker-store';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6e42921cf..33ae35c68 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -501,7 +501,7 @@ importers:
version: 0.4.0
i18next:
specifier: ^24.2.3
- version: 24.2.3(typescript@5.9.3)
+ version: 24.2.3(typescript@5.6.3)
i18next-browser-languagedetector:
specifier: ^8.0.5
version: 8.0.5
@@ -510,16 +510,16 @@ importers:
version: 10.1.1
react-i18next:
specifier: ^15.4.1
- version: 15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ version: 15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
vite:
specifier: ^6.0.7
version: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)
vite-plugin-dts:
specifier: ^4.5.0
- version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4))
+ version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4))
vite-plugin-svgr:
specifier: ^4.3.0
- version: 4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4))
+ version: 4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4))
vitest:
specifier: ^3.0.4
version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)
@@ -10011,7 +10011,7 @@ snapshots:
de-indent: 1.0.2
he: 1.2.0
- '@vue/language-core@2.2.0(typescript@5.9.3)':
+ '@vue/language-core@2.2.0(typescript@5.6.3)':
dependencies:
'@volar/language-core': 2.4.28
'@vue/compiler-dom': 3.5.33
@@ -10022,7 +10022,7 @@ snapshots:
muggle-string: 0.4.1
path-browserify: 1.0.1
optionalDependencies:
- typescript: 5.9.3
+ typescript: 5.6.3
'@vue/shared@3.5.33': {}
@@ -12192,6 +12192,12 @@ snapshots:
dependencies:
'@babel/runtime': 7.27.0
+ i18next@24.2.3(typescript@5.6.3):
+ dependencies:
+ '@babel/runtime': 7.27.0
+ optionalDependencies:
+ typescript: 5.6.3
+
i18next@24.2.3(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.27.0
@@ -13744,6 +13750,15 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
+ react-i18next@15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ '@babel/runtime': 7.27.0
+ html-parse-stringify: 3.0.1
+ i18next: 24.2.3(typescript@5.6.3)
+ react: 19.1.0
+ optionalDependencies:
+ react-dom: 19.1.0(react@19.1.0)
+
react-i18next@15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.0
@@ -14957,18 +14972,18 @@ snapshots:
- tsx
- yaml
- vite-plugin-dts@4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)):
+ vite-plugin-dts@4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)):
dependencies:
'@microsoft/api-extractor': 7.58.7(@types/node@22.12.0)
'@rollup/pluginutils': 5.3.0(rollup@4.57.1)
'@volar/typescript': 2.4.28
- '@vue/language-core': 2.2.0(typescript@5.9.3)
+ '@vue/language-core': 2.2.0(typescript@5.6.3)
compare-versions: 6.1.1
debug: 4.4.3
kolorist: 1.8.0
local-pkg: 1.1.2
magic-string: 0.30.21
- typescript: 5.9.3
+ typescript: 5.6.3
optionalDependencies:
vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)
transitivePeerDependencies: