diff --git a/packages/jdm-editor/src/components/decision-graph/context/serializer.context.tsx b/packages/jdm-editor/src/components/decision-graph/context/serializer.context.tsx new file mode 100644 index 00000000..b77e2d18 --- /dev/null +++ b/packages/jdm-editor/src/components/decision-graph/context/serializer.context.tsx @@ -0,0 +1,166 @@ +import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; + +export type Slice = { + serialize: () => T; + restore: (state: T) => void; +}; + +export type TabSnapshot = Record; + +export type DecisionGraphSnapshot = { + graph?: Record; + tabs?: Record; +}; + +type Registry = { + registerGraph: (key: string, slice: Slice) => () => void; + registerTab: (tabId: string, key: string, slice: Slice) => () => void; + serialize: () => DecisionGraphSnapshot; + restore: (snapshot: DecisionGraphSnapshot) => void; +}; + +const SerializerContext = createContext(null); + +export const SerializerProvider: React.FC = ({ children }) => { + const graphSlices = useRef(new Map()); + const tabSlices = useRef(new Map>()); + const pendingGraph = useRef(new Map()); + const pendingTabs = useRef(new Map>()); + + const registry = useMemo(() => { + const tryRestore = (slice: Slice, value: unknown, label: string) => { + try { + slice.restore(value); + } catch (err) { + console.warn(`Failed to restore "${label}"`, err); + } + }; + + return { + registerGraph: (key, slice) => { + graphSlices.current.set(key, slice); + if (pendingGraph.current.has(key)) { + const pending = pendingGraph.current.get(key); + pendingGraph.current.delete(key); + tryRestore(slice, pending, `graph.${key}`); + } + return () => { + if (graphSlices.current.get(key) === slice) graphSlices.current.delete(key); + }; + }, + registerTab: (tabId, key, slice) => { + if (!tabSlices.current.has(tabId)) tabSlices.current.set(tabId, new Map()); + tabSlices.current.get(tabId)!.set(key, slice); + + const pendingForTab = pendingTabs.current.get(tabId); + if (pendingForTab?.has(key)) { + const pending = pendingForTab.get(key); + pendingForTab.delete(key); + if (pendingForTab.size === 0) pendingTabs.current.delete(tabId); + tryRestore(slice, pending, `tabs.${tabId}.${key}`); + } + return () => { + const bag = tabSlices.current.get(tabId); + if (bag?.get(key) === slice) { + bag.delete(key); + if (bag.size === 0) tabSlices.current.delete(tabId); + } + }; + }, + serialize: () => { + const snapshot: DecisionGraphSnapshot = {}; + const graph: Record = {}; + for (const [key, slice] of graphSlices.current) { + try { + const value = slice.serialize(); + if (value !== undefined) graph[key] = value; + } catch (err) { + console.warn(`Failed to serialize graph.${key}`, err); + } + } + if (Object.keys(graph).length > 0) snapshot.graph = graph; + + const tabs: Record = {}; + for (const [tabId, bag] of tabSlices.current) { + const tabBag: TabSnapshot = {}; + for (const [key, slice] of bag) { + try { + const value = slice.serialize(); + if (value !== undefined) tabBag[key] = value; + } catch (err) { + console.warn(`Failed to serialize tabs.${tabId}.${key}`, err); + } + } + if (Object.keys(tabBag).length > 0) tabs[tabId] = tabBag; + } + if (Object.keys(tabs).length > 0) snapshot.tabs = tabs; + + return snapshot; + }, + restore: (snapshot) => { + pendingGraph.current.clear(); + pendingTabs.current.clear(); + + for (const [key, value] of Object.entries(snapshot?.graph ?? {})) { + const slice = graphSlices.current.get(key); + if (slice) tryRestore(slice, value, `graph.${key}`); + else pendingGraph.current.set(key, value); + } + + for (const [tabId, bag] of Object.entries(snapshot?.tabs ?? {})) { + for (const [key, value] of Object.entries(bag ?? {})) { + const slice = tabSlices.current.get(tabId)?.get(key); + if (slice) { + tryRestore(slice, value, `tabs.${tabId}.${key}`); + } else { + if (!pendingTabs.current.has(tabId)) pendingTabs.current.set(tabId, new Map()); + pendingTabs.current.get(tabId)!.set(key, value); + } + } + } + }, + }; + }, []); + + return {children}; +}; + +export const useSerializerRegistry = (): Registry | null => useContext(SerializerContext); + +function useSlice( + registerFn: ((slice: Slice) => () => void) | null, + slice: Slice, + deps: React.DependencyList, +): void { + const sliceRef = useRef(slice); + sliceRef.current = slice; + + useEffect(() => { + if (!registerFn) return; + return registerFn({ + serialize: () => sliceRef.current.serialize(), + restore: (state) => sliceRef.current.restore(state as T), + }); + }, deps); +} + +export function useGraphSerializer( + key: string | null | undefined, + slice: Slice, + deps: React.DependencyList = [], +): void { + const registry = useContext(SerializerContext); + const registerFn = registry && key ? (s: Slice) => registry.registerGraph(key, s) : null; + useSlice(registerFn, slice, [registry, key, ...deps]); +} + +export function useTabSerializer( + tabId: string | null | undefined, + key: string | null | undefined, + slice: Slice, + deps: React.DependencyList = [], +): void { + const registry = useContext(SerializerContext); + const registerFn = registry && tabId && key ? (s: Slice) => registry.registerTab(tabId, key, s) : null; + useSlice(registerFn, slice, [registry, tabId, key, ...deps]); +} diff --git a/packages/jdm-editor/src/components/decision-graph/dg.stories.tsx b/packages/jdm-editor/src/components/decision-graph/dg.stories.tsx index 18c3d0c0..a89c2e9e 100644 --- a/packages/jdm-editor/src/components/decision-graph/dg.stories.tsx +++ b/packages/jdm-editor/src/components/decision-graph/dg.stories.tsx @@ -1,12 +1,13 @@ import { ApartmentOutlined, ApiOutlined, LeftOutlined, PlayCircleOutlined, RightOutlined } from '@ant-design/icons'; import type { Meta, StoryObj } from '@storybook/react'; -import { Button, Select, Space } from 'antd'; +import { Button, Select, Space, Typography } from 'antd'; import json5 from 'json5'; -import React, { useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { P, match } from 'ts-pattern'; import type { DictionaryMap } from '../../theme'; import type { JdmUiMode } from '../decision-table/context/dt-store.context'; +import type { DecisionGraphSnapshot } from './context/serializer.context'; import type { DecisionGraphRef } from './dg'; import { DecisionGraph } from './dg'; import type { DecisionGraphType } from './dg-types'; @@ -509,6 +510,186 @@ const businessModeGraph: DecisionGraphType = { ], }; +const buildLargeSerializeGraph = (): DecisionGraphType => { + const inputId = 'serialize-input'; + const outputId = 'serialize-output'; + const tableId = 'serialize-table'; + const expressionId = 'serialize-expression'; + const functionId = 'serialize-function'; + + const tableInputs = [ + { id: 'in_weight', field: 'cart.weight', name: 'Cart Weight (Kg)' }, + { id: 'in_country', field: 'customer.country', name: 'Customer Country' }, + { id: 'in_tier', field: 'customer.tier', name: 'Customer Tier' }, + ]; + const tableOutputs = [{ id: 'out_fee', field: 'shippingFee', name: 'Shipping Fee' }]; + + const rules = Array.from({ length: 100 }, (_, i) => ({ + _id: `rule-${i + 1}`, + _description: `Rule ${i + 1}: shipping fee for tier ${i % 5}`, + in_weight: `[${i * 2}..${i * 2 + 10}]`, + in_country: i % 2 === 0 ? '"US"' : '"CA"', + in_tier: `"tier${i % 5}"`, + out_fee: `${10 + i}`, + })); + + const expressions = Array.from({ length: 50 }, (_, i) => ({ + id: `expr-${i + 1}`, + key: `field_${i + 1}`, + value: `customer.score + ${i} * order.total / 100`, + })); + + const functionSource = [ + "import zen from 'zen';", + '', + '/** @type {Handler} **/', + 'export const handler = async (input) => {', + ' const lines = [];', + ...Array.from({ length: 80 }, (_, i) => ` lines.push('line ${i + 1}: ' + JSON.stringify(input));`), + '', + ' const result = {', + ' received: input,', + ' processedAt: new Date().toISOString(),', + ' lineCount: lines.length,', + " summary: lines.join('\\n'),", + ' };', + '', + ' return result;', + '};', + ].join('\n'); + + return { + nodes: [ + { id: inputId, type: 'inputNode', position: { x: 80, y: 240 }, name: 'Request' }, + { + id: tableId, + type: 'decisionTableNode', + position: { x: 360, y: 100 }, + name: 'Big Table', + content: { hitPolicy: 'first', inputs: tableInputs, outputs: tableOutputs, rules }, + }, + { + id: expressionId, + type: 'expressionNode', + position: { x: 360, y: 360 }, + name: 'Big Expression', + content: { expressions, passThrough: true, executionMode: 'single' }, + }, + { + id: functionId, + type: 'functionNode', + position: { x: 360, y: 600 }, + name: 'Big Function', + content: { source: functionSource }, + }, + { id: outputId, type: 'outputNode', position: { x: 720, y: 360 }, name: 'Response' }, + ], + edges: [ + { id: 'e-in-table', type: 'edge', sourceId: inputId, targetId: tableId }, + { id: 'e-in-expr', type: 'edge', sourceId: inputId, targetId: expressionId }, + { id: 'e-in-func', type: 'edge', sourceId: inputId, targetId: functionId }, + { id: 'e-table-out', type: 'edge', sourceId: tableId, targetId: outputId }, + { id: 'e-expr-out', type: 'edge', sourceId: expressionId, targetId: outputId }, + { id: 'e-func-out', type: 'edge', sourceId: functionId, targetId: outputId }, + ], + }; +}; + +export const Serialize: Story = { + render: () => , +}; + +const DecisionGraphSerializeTest: React.FC = () => { + const ref = useRef(null); + const [value, setValue] = useState(() => buildLargeSerializeGraph()); + const [snapshot, setSnapshot] = useState(null); + const [mountKey, setMountKey] = useState(0); + const [showJson, setShowJson] = useState(true); + const pendingRestoreRef = useRef(null); + + useEffect(() => { + if (!pendingRestoreRef.current) return; + const snap = pendingRestoreRef.current; + pendingRestoreRef.current = null; + requestAnimationFrame(() => ref.current?.restore(snap)); + }, [mountKey]); + + const handleSave = () => { + const snap = ref.current?.serialize() ?? {}; + setSnapshot(snap); + }; + + const handleRestore = () => { + if (snapshot) ref.current?.restore(snapshot); + }; + + const handleRemountAndRestore = () => { + pendingRestoreRef.current = snapshot; + setMountKey((k) => k + 1); + }; + + const handleClear = () => setSnapshot(null); + + return ( +
+
+ + + + + + + + {snapshot ? 'Snapshot saved' : 'No snapshot'} + + +
+
+
+ setValue(val)} + components={components} + customNodes={customNodes} + /> +
+ {showJson && ( +
+ + Snapshot + +
+              {snapshot ? JSON.stringify(snapshot, null, 2) : '// click "Serialize" to capture'}
+            
+
+ )} +
+
+ ); +}; + export const BusinessMode: Story = { render: () => { const [value, setValue] = useState(businessModeGraph); diff --git a/packages/jdm-editor/src/components/decision-graph/dg.tsx b/packages/jdm-editor/src/components/decision-graph/dg.tsx index ae003b67..3dbfc166 100644 --- a/packages/jdm-editor/src/components/decision-graph/dg.tsx +++ b/packages/jdm-editor/src/components/decision-graph/dg.tsx @@ -1,10 +1,11 @@ import clsx from 'clsx'; import type { DragDropManager } from 'dnd-core'; -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import { ReactFlowProvider } from 'reactflow'; import type { DecisionGraphContextProps } from './context/dg-store.context'; import { DecisionGraphProvider } from './context/dg-store.context'; +import { SerializerProvider } from './context/serializer.context'; import type { DecisionGraphEmptyType } from './dg-empty'; import { DecisionGraphEmpty } from './dg-empty'; import { DecisionGraphInferTypes } from './dg-infer'; @@ -27,13 +28,15 @@ export const DecisionGraph = forwardRef(
- - - + + + + +
diff --git a/packages/jdm-editor/src/components/decision-graph/graph/graph.tsx b/packages/jdm-editor/src/components/decision-graph/graph/graph.tsx index 9195e041..9e5c4b8e 100644 --- a/packages/jdm-editor/src/components/decision-graph/graph/graph.tsx +++ b/packages/jdm-editor/src/components/decision-graph/graph/graph.tsx @@ -3,7 +3,7 @@ import { App, Button, Typography, message, notification } from 'antd'; import clsx from 'clsx'; import equal from 'fast-deep-equal'; import React, { type MutableRefObject, forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import type { Connection, Node, ProOptions, ReactFlowInstance, XYPosition } from 'reactflow'; +import type { Connection, Node, ProOptions, ReactFlowInstance, Viewport, XYPosition } from 'reactflow'; import ReactFlow, { Background, ControlButton, @@ -26,6 +26,7 @@ import { useDecisionGraphReferences, useDecisionGraphState, } from '../context/dg-store.context'; +import { type DecisionGraphSnapshot, useGraphSerializer, useSerializerRegistry } from '../context/serializer.context'; import { edgeFunction } from '../custom-edge'; import { type DecisionNode } from '../dg-types'; import { mapToDecisionEdge } from '../dg-util'; @@ -38,6 +39,8 @@ import { NodeKind } from '../nodes/specifications/specification-types'; import { nodeSpecification } from '../nodes/specifications/specifications'; import { GraphComponents } from './graph-components'; +type TabsSlice = { openTabs: string[]; activeTab: string }; + export type GraphProps = { className?: string; onDisableTabs?: (val: boolean) => void; @@ -46,6 +49,8 @@ export type GraphProps = { export type GraphRef = DecisionGraphStoreType['actions'] & { stateStore: ExposedStore; + serialize: () => DecisionGraphSnapshot; + restore: (snapshot: DecisionGraphSnapshot) => void; }; const defaultNodeTypes = Object.entries(nodeSpecification).reduce( @@ -87,7 +92,10 @@ export const Graph = forwardRef(function GraphInner({ reac return localStorage.getItem(componentsOpenedKey) === 'true'; }); + const initialViewport = useRef(undefined); + const raw = useDecisionGraphRaw(); + const registry = useSerializerRegistry(); const graphActions = useDecisionGraphActions(); const graphReferences = useDecisionGraphReferences((s) => s); const { onReactFlowInit } = useDecisionGraphListeners(({ onReactFlowInit }) => ({ onReactFlowInit })); @@ -348,9 +356,39 @@ export const Graph = forwardRef(function GraphInner({ reac graphActions.addEdges([mapToDecisionEdge(edge)]); }; + useGraphSerializer('viewport', { + serialize: () => reactFlowInstance.current?.getViewport() ?? { x: 0, y: 0, zoom: 1 }, + restore: (viewport) => { + if (!viewport) return; + initialViewport.current = viewport; + reactFlowInstance.current?.setViewport(viewport); + }, + }); + + useGraphSerializer('tabs', { + serialize: () => { + const { openTabs, activeTab } = raw.stateStore.getState(); + return { openTabs, activeTab }; + }, + restore: ({ openTabs, activeTab } = { openTabs: [], activeTab: 'graph' }) => { + raw.stateStore.setState({ openTabs: openTabs ?? [], activeTab: activeTab ?? 'graph' }); + }, + }); + + useGraphSerializer('componentsOpened', { + serialize: () => componentsOpened, + restore: (value) => { + if (typeof value !== 'boolean') return; + setComponentsOpened(value); + localStorage.setItem(componentsOpenedKey, `${value}`); + }, + }); + useImperativeHandle(ref, () => ({ ...graphActions, stateStore: raw.stateStore, + serialize: () => registry?.serialize() ?? {}, + restore: (snapshot) => registry?.restore(snapshot ?? {}), })); return ( @@ -438,8 +476,12 @@ export const Graph = forwardRef(function GraphInner({ reac connectionRadius={35} nodes={nodesState[0]} edges={edgesState[0]} + defaultViewport={initialViewport.current} onInit={(instance) => { (reactFlowInstance as MutableRefObject).current = instance; + if (initialViewport.current) { + instance.setViewport(initialViewport.current); + } onReactFlowInit?.(instance); }} snapToGrid={true} diff --git a/packages/jdm-editor/src/components/decision-graph/graph/tab-decision-table.tsx b/packages/jdm-editor/src/components/decision-graph/graph/tab-decision-table.tsx index bba7649e..1f3b4b17 100644 --- a/packages/jdm-editor/src/components/decision-graph/graph/tab-decision-table.tsx +++ b/packages/jdm-editor/src/components/decision-graph/graph/tab-decision-table.tsx @@ -1,17 +1,20 @@ import type { DragDropManager } from 'dnd-core'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import { P, match } from 'ts-pattern'; import { getNodeData } from '../../../helpers/node-data'; import { get } from '../../../helpers/utility'; import { isWasmAvailable } from '../../../helpers/wasm'; -import type { DecisionTableType } from '../../decision-table'; +import type { DecisionTableType, TableScrollApi } from '../../decision-table'; import { DecisionTable } from '../../decision-table'; import type { DecisionTablePermission } from '../../decision-table/context/dt-store.context'; import { useDecisionGraphActions, useDecisionGraphState } from '../context/dg-store.context'; +import { useTabSerializer } from '../context/serializer.context'; import type { NodeDecisionTableData } from '../nodes/specifications/decision-table.specification'; import type { SimulationTrace, SimulationTraceDataTable } from '../simulator/simulation.types'; +type TableScrollSnapshot = { rowIndex: number; scrollLeft: number }; + export type TabDecisionTableProps = { id: string; manager?: DragDropManager; @@ -19,6 +22,30 @@ export type TabDecisionTableProps = { export const TabDecisionTable: React.FC = ({ id, manager }) => { const graphActions = useDecisionGraphActions(); + const scrollContainerRef = useRef(null); + const scrollApiRef = useRef(null); + + useTabSerializer(id, 'scroll', { + serialize: () => ({ + rowIndex: scrollApiRef.current?.getTopRowIndex() ?? 0, + scrollLeft: scrollContainerRef.current?.scrollLeft ?? 0, + }), + restore: (snapshot) => { + if (!snapshot) return; + let attempts = 0; + const apply = () => { + const api = scrollApiRef.current; + const el = scrollContainerRef.current; + if (!api || !el || el.scrollHeight <= el.clientHeight) { + if (attempts++ < 60) requestAnimationFrame(apply); + return; + } + api.scrollToRowIndex(snapshot.rowIndex); + el.scrollLeft = snapshot.scrollLeft; + }; + requestAnimationFrame(apply); + }, + }); const { nodeName, nodeTrace, inputData, nodeSnapshot, viewConfig, dictionaries, mode } = useDecisionGraphState( ({ simulate, decisionGraph, viewConfig, dictionaries, mode }) => ({ nodeName: decisionGraph.nodes.find((n) => n.id === id)?.name, @@ -69,6 +96,8 @@ export const TabDecisionTable: React.FC = ({ id, manager tableHeight={'100%'} value={content as any} manager={manager} + scrollContainerRef={scrollContainerRef} + scrollApiRef={scrollApiRef} disabled={disabled} permission={viewConfig?.enabled ? (viewConfig?.permissions?.[id] as DecisionTablePermission) : 'edit:full'} dictionaries={dictionaries} diff --git a/packages/jdm-editor/src/components/decision-graph/graph/tab-expression.tsx b/packages/jdm-editor/src/components/decision-graph/graph/tab-expression.tsx index 61abab9a..186940c0 100644 --- a/packages/jdm-editor/src/components/decision-graph/graph/tab-expression.tsx +++ b/packages/jdm-editor/src/components/decision-graph/graph/tab-expression.tsx @@ -1,5 +1,5 @@ import type { DragDropManager } from 'dnd-core'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import { P, match } from 'ts-pattern'; import type { z } from 'zod'; @@ -11,9 +11,12 @@ import { isWasmAvailable } from '../../../helpers/wasm'; import { Expression } from '../../expression'; import type { ExpressionPermission } from '../../expression/context/expression-store.context'; import { useDecisionGraphActions, useDecisionGraphState } from '../context/dg-store.context'; +import { useTabSerializer } from '../context/serializer.context'; import type { NodeExpressionData } from '../nodes/specifications/expression.specification'; import type { SimulationTrace, SimulationTraceDataExpression } from '../simulator/simulation.types'; +type ScrollPosition = { top: number; left: number }; + export type TabExpressionProps = { id: string; manager?: DragDropManager; @@ -21,6 +24,30 @@ export type TabExpressionProps = { export const TabExpression: React.FC = ({ id, manager }) => { const graphActions = useDecisionGraphActions(); + const scrollContainerRef = useRef(null); + + useTabSerializer(id, 'scroll', { + serialize: () => { + const el = scrollContainerRef.current; + if (!el) return { top: 0, left: 0 }; + return { top: el.scrollTop, left: el.scrollLeft }; + }, + restore: (position) => { + if (!position) return; + let attempts = 0; + const apply = () => { + const el = scrollContainerRef.current; + if (!el || el.scrollHeight <= el.clientHeight) { + if (attempts++ < 60) requestAnimationFrame(apply); + return; + } + el.scrollTop = position.top; + el.scrollLeft = position.left; + }; + requestAnimationFrame(apply); + }, + }); + const { disabled, content } = useDecisionGraphState(({ disabled, decisionGraph }) => ({ disabled, content: (decisionGraph?.nodes ?? []).find((node) => node.id === id)?.content as NodeExpressionData, @@ -75,7 +102,7 @@ export const TabExpression: React.FC = ({ id, manager }) => }, [nodeTrace, nodeSnapshot, inputData]); return ( -
+
= ({ id }) => { const graphActions = useDecisionGraphActions(); const onFunctionReady = useDecisionGraphListeners((s) => s.onFunctionReady); const [monaco, setMonaco] = useState(); + const editorRef = useRef(null); + const pendingViewStateRef = useRef(null); + + useTabSerializer(id, 'monaco', { + serialize: () => editorRef.current?.saveViewState() ?? pendingViewStateRef.current ?? null, + restore: (state) => { + if (!state) return; + if (editorRef.current) { + editorRef.current.restoreViewState(state); + } else { + pendingViewStateRef.current = state; + } + }, + }); const nodeType = useNodeType(id); const { nodeTrace, disabled, content, nodeError, viewConfig } = useDecisionGraphState( ({ simulate, disabled, decisionGraph, viewConfig }) => ({ @@ -70,6 +86,13 @@ export const TabFunction: React.FC = ({ id }) => { }> setMonaco(monaco)} + onEditorMount={(editor) => { + editorRef.current = editor; + if (pendingViewStateRef.current) { + editor.restoreViewState(pendingViewStateRef.current); + pendingViewStateRef.current = null; + } + }} value={kind === FunctionKind.Stable ? content.source : content} previousValue={typeof previousValue === 'string' ? previousValue : undefined} error={nodeError ?? undefined} diff --git a/packages/jdm-editor/src/components/decision-graph/graph/tab-json-schema.tsx b/packages/jdm-editor/src/components/decision-graph/graph/tab-json-schema.tsx index d6d35126..8b961900 100644 --- a/packages/jdm-editor/src/components/decision-graph/graph/tab-json-schema.tsx +++ b/packages/jdm-editor/src/components/decision-graph/graph/tab-json-schema.tsx @@ -3,12 +3,13 @@ import { DiffEditor, Editor } from '@monaco-editor/react'; import { Button, Space, Spin, Tabs, Tooltip, theme } from 'antd'; import type { DragDropManager } from 'dnd-core'; import { type editor } from 'monaco-editor'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { PanelGroup } from 'react-resizable-panels'; import { match } from 'ts-pattern'; import { useThrottledCallback } from 'use-debounce'; import { useDecisionGraphActions, useDecisionGraphState, useNodeDiff } from '../context/dg-store.context'; +import { useTabSerializer } from '../context/serializer.context'; import { JsonToJsonSchemaDialog } from './json-to-json-schema-dialog'; const schemaTooltip = 'Provide JSON Schema format. If no JSON Schema is provided, validation will be skipped.'; @@ -52,9 +53,29 @@ export const TabJsonSchema: React.FC = ({ id, type = 'input' const [editor, setEditor] = useState(); const [diffEditor, setDiffEditor] = useState(); + const pendingViewStateRef = useRef(null); const resizeEditor = useThrottledCallback(() => editor?.layout(), 100, { trailing: true }); const resizeDiffEditor = useThrottledCallback(() => diffEditor?.layout(), 100, { trailing: true }); + useTabSerializer(id, 'monaco', { + serialize: () => editor?.saveViewState() ?? pendingViewStateRef.current ?? null, + restore: (state) => { + if (!state) return; + if (editor) { + editor.restoreViewState(state); + } else { + pendingViewStateRef.current = state; + } + }, + }); + + useEffect(() => { + if (editor && pendingViewStateRef.current) { + editor.restoreViewState(pendingViewStateRef.current); + pendingViewStateRef.current = null; + } + }, [editor]); + const { disabled, content } = useDecisionGraphState(({ simulate, disabled, decisionGraph }) => ({ nodeError: match(simulate) .with({ error: { data: { nodeId: id } } }, ({ error }) => error) diff --git a/packages/jdm-editor/src/components/decision-graph/index.ts b/packages/jdm-editor/src/components/decision-graph/index.ts index d4009c87..4f13051d 100644 --- a/packages/jdm-editor/src/components/decision-graph/index.ts +++ b/packages/jdm-editor/src/components/decision-graph/index.ts @@ -24,6 +24,13 @@ export { useEdgeDiff, NodeTypeKind, } from './context/dg-store.context'; +export { + useGraphSerializer, + useTabSerializer, + type DecisionGraphSnapshot, + type TabSnapshot, + type Slice, +} from './context/serializer.context'; export { NodeColor } from './nodes/specifications/colors'; export type { diff --git a/packages/jdm-editor/src/components/decision-table/dt.tsx b/packages/jdm-editor/src/components/decision-table/dt.tsx index 8e0229fd..438cf50c 100644 --- a/packages/jdm-editor/src/components/decision-table/dt.tsx +++ b/packages/jdm-editor/src/components/decision-table/dt.tsx @@ -13,13 +13,18 @@ import { DecisionTableCommandBar } from './dt-command-bar'; import type { DecisionTableEmptyType } from './dt-empty'; import { DecisionTableEmpty } from './dt-empty'; import './dt.scss'; +import type { TableScrollApi } from './table/table'; import { Table } from './table/table'; +export type { TableScrollApi } from './table/table'; + export type DecisionTableProps = { id?: string; tableHeight: string | number; mountDialogsOnBody?: boolean; manager?: DragDropManager; + scrollContainerRef?: React.MutableRefObject; + scrollApiRef?: React.MutableRefObject; } & DecisionTableContextProps & DecisionTableEmptyType; @@ -28,6 +33,8 @@ export const DecisionTable: React.FC = ({ tableHeight, mountDialogsOnBody = false, manager, + scrollContainerRef, + scrollApiRef, ...props }) => { const { token } = theme.useToken(); @@ -64,7 +71,12 @@ export const DecisionTable: React.FC = ({ - +
diff --git a/packages/jdm-editor/src/components/decision-table/table/table.tsx b/packages/jdm-editor/src/components/decision-table/table/table.tsx index 89212e97..6782533d 100644 --- a/packages/jdm-editor/src/components/decision-table/table/table.tsx +++ b/packages/jdm-editor/src/components/decision-table/table/table.tsx @@ -20,9 +20,16 @@ import { import { TableHeadRow } from './table-head-row'; import { TableRow } from './table-row'; +export type TableScrollApi = { + getTopRowIndex: () => number; + scrollToRowIndex: (index: number) => void; +}; + export type TableProps = { id?: string; maxHeight: string | number; + scrollContainerRef?: React.MutableRefObject; + scrollApiRef?: React.MutableRefObject; }; type ColumnSizing = Record; @@ -43,9 +50,17 @@ const loadColumnSizing = (id?: string) => { } }; -export const Table: React.FC = ({ id, maxHeight }) => { +export const Table: React.FC = ({ id, maxHeight, scrollContainerRef, scrollApiRef }) => { const { token } = theme.useToken(); + const setContainerRef = useCallback( + (el: HTMLDivElement | null) => { + (tableContainerRef as React.MutableRefObject).current = el; + if (scrollContainerRef) scrollContainerRef.current = el; + }, + [scrollContainerRef], + ); + const tableActions = useDecisionTableActions(); const { cellRenderer } = useDecisionTableListeners(({ cellRenderer }) => ({ cellRenderer })); const [columnSizing, setColumnSizing] = useState(() => loadColumnSizing(id)); @@ -195,7 +210,7 @@ export const Table: React.FC = ({ id, maxHeight }) => { return (
= ({ id, maxHeight }) => { ))} - +
@@ -245,12 +260,13 @@ export const Table: React.FC = ({ id, maxHeight }) => { }; type TableBodyProps = { - tableContainerRef: React.RefObject; + tableContainerRef: React.RefObject; table: ReactTable; + scrollApiRef?: React.MutableRefObject; } & Omit, 'children'>; const TableBody = React.forwardRef( - ({ table, tableContainerRef, ...props }, ref) => { + ({ table, tableContainerRef, scrollApiRef, ...props }, ref) => { const tableActions = useDecisionTableActions(); const { disabled, cursor } = useDecisionTableState(({ disabled, cursor }) => ({ disabled, @@ -266,6 +282,20 @@ const TableBody = React.forwardRef( overscan: 5, }); + useEffect(() => { + if (!scrollApiRef) return; + scrollApiRef.current = { + getTopRowIndex: () => { + const offset = tableContainerRef.current?.scrollTop ?? 0; + return virtualizer.getVirtualItemForOffset(offset)?.index ?? 0; + }, + scrollToRowIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' }), + }; + return () => { + if (scrollApiRef.current) scrollApiRef.current = null; + }; + }, [virtualizer, scrollApiRef]); + const virtualItems = virtualizer.getVirtualItems(); const totalSize = virtualizer.getTotalSize(); diff --git a/packages/jdm-editor/src/components/function/function.tsx b/packages/jdm-editor/src/components/function/function.tsx index a97f62ec..cf5cbc3a 100644 --- a/packages/jdm-editor/src/components/function/function.tsx +++ b/packages/jdm-editor/src/components/function/function.tsx @@ -26,6 +26,7 @@ export type FunctionProps = { onChange?: (value: string) => void; trace?: SimulationTrace; onMonacoReady?: (monaco: Monaco) => void; + onEditorMount?: (editor: editor.IStandaloneCodeEditor) => void; libraries?: FunctionLibrary[]; inputData?: unknown; permission?: FunctionPermission; @@ -43,6 +44,7 @@ export const Function: React.FC = ({ onChange, trace, onMonacoReady, + onEditorMount, error, inputData, previousValue, @@ -261,7 +263,10 @@ export const Function: React.FC = ({ loading={} language={language} value={innerValue} - onMount={(editor) => setEditor(editor)} + onMount={(editor) => { + setEditor(editor); + onEditorMount?.(editor); + }} onChange={(value) => { setInnerValue(value ?? ''); innerChange(value ?? '');