93 lines
5.8 KiB
TypeScript
93 lines
5.8 KiB
TypeScript
/* eslint-disable react-refresh/only-export-components -- Local rendering adapter and its state hooks share private contexts. */
|
|
import { createContext, useContext, useLayoutEffect, useState, type ReactNode } from "react";
|
|
import { createStateStore, JSONUIProvider, useStateStore, type ComponentRegistry } from "@json-render/react";
|
|
import type { Spec } from "@json-render/core";
|
|
import {
|
|
answerStatePointer, answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerNodeState, normalizeAnswerViewState,
|
|
type AnswerUiNodeState, type AnswerViewState, type AnswerViewStateChange
|
|
} from "./answer-view-state";
|
|
|
|
const NodeContext = createContext<string | undefined>(undefined);
|
|
const ChangeContext = createContext<AnswerViewStateChange | undefined>(undefined);
|
|
|
|
/** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */
|
|
export function withAnswerNodeIds(spec: Spec): Spec {
|
|
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) };
|
|
}
|
|
|
|
export function withAnswerNodeIdentity(registry: ComponentRegistry): ComponentRegistry {
|
|
return Object.fromEntries(Object.entries(registry).map(([type, Component]) => [type, function AnswerNodeRenderer(props) {
|
|
const id = (props.element as typeof props.element & { __answerNodeId?: string }).__answerNodeId;
|
|
return <NodeContext.Provider value={id}><Component {...props} /></NodeContext.Provider>;
|
|
}]));
|
|
}
|
|
|
|
export function AnswerViewStateProvider({ spec, registry, viewState, onViewStateChange, children }: {
|
|
spec: Spec; registry: ComponentRegistry; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange; children: ReactNode;
|
|
}) {
|
|
const [store] = useState(() => createStateStore(normalizeAnswerViewState(spec, viewState)));
|
|
// Capture the full prior schema, including in-place updates from callers.
|
|
const [previous, setPrevious] = useState(() => JSON.stringify(spec));
|
|
const serialized = JSON.stringify(spec);
|
|
useLayoutEffect(() => {
|
|
const current = store.getSnapshot() as AnswerViewState;
|
|
const source = viewState ?? current;
|
|
const next = previous === serialized ? normalizeAnswerViewState(spec, source) : migrateAnswerViewState(JSON.parse(previous) as Spec, spec, source);
|
|
const dirty = answerViewStateDirtyPaths(source, next);
|
|
const previousSpec = JSON.parse(previous) as Spec;
|
|
const resetCharts = Object.entries(next.nodes).filter(([id, node]) => {
|
|
const old = source.nodes[id];
|
|
if (node.type !== "Chart" || old?.type !== "Chart" || previous === serialized) return false;
|
|
const before = previousSpec.elements[id], after = spec.elements[id];
|
|
if (before?.type !== "Chart" || JSON.stringify([before.props.x_data, before.props.series]) === JSON.stringify([after.props.x_data, after.props.series])) return false;
|
|
const previousLabels = before.props.x_data as string[], labels = after.props.x_data as string[];
|
|
const relocate = (position: number) => {
|
|
const label = previousLabels[position];
|
|
return label !== undefined && previousLabels.filter((value) => value === label).length === 1 && labels.filter((value) => value === label).length === 1 ? labels.indexOf(label) : -1;
|
|
};
|
|
return relocate(old.point) < 0 || relocate(old.range[0]) < 0 || relocate(old.range[1]) < relocate(old.range[0]);
|
|
}).map(([id]) => id);
|
|
store.update({ "/nodes": next.nodes, "/block": next.block });
|
|
if (previous !== serialized) {
|
|
store.set("/notices", Object.fromEntries(resetCharts.map((id) => [id, "数据标签已变化,原选点或区间无法唯一对应,已清除选择。仍可查看当前数据。"])));
|
|
setPrevious(serialized);
|
|
}
|
|
if (dirty.length) onViewStateChange?.(next, dirty);
|
|
}, [store, previous, serialized, spec, viewState, onViewStateChange]);
|
|
const change: AnswerViewStateChange = (next, dirty) => {
|
|
store.update({ "/nodes": next.nodes, "/block": next.block });
|
|
onViewStateChange?.(next, dirty);
|
|
};
|
|
return <ChangeContext.Provider value={change}>
|
|
<JSONUIProvider registry={registry} store={store}>{children}</JSONUIProvider>
|
|
</ChangeContext.Provider>;
|
|
}
|
|
|
|
export function useAnswerNodeState<T extends AnswerUiNodeState>(type: T["type"], props: Record<string, unknown>) {
|
|
const id = useContext(NodeContext);
|
|
const store = useStateStore();
|
|
const onChange = useContext(ChangeContext);
|
|
// Derived controls (such as a chart's data table) have no schema node of their own.
|
|
const [local, setLocal] = useState(() => normalizeAnswerNodeState({ type, props, children: [] }) as T);
|
|
const owned = id && (store.get(`/nodes/${answerStatePointer(id)}`) as AnswerUiNodeState | undefined)?.type === type;
|
|
const state = (owned ? normalizeAnswerNodeState({ type, props, children: [] }, store.get(`/nodes/${answerStatePointer(id!)}`)) : local) as T;
|
|
const patch = (updates: Partial<Omit<T, "type">>, fields?: string[]) => {
|
|
const live = owned ? store.get(`/nodes/${answerStatePointer(id!)}`) as T : state;
|
|
const next = { ...live, ...updates } as T;
|
|
if (!owned || !onChange) { setLocal(next); return; }
|
|
const snapshot = store.getSnapshot() as AnswerViewState;
|
|
onChange({ nodes: { ...snapshot.nodes, [id!]: next }, block: snapshot.block },
|
|
(fields ?? Object.keys(updates)).map((field) => `/nodes/${answerStatePointer(id!)}/${field}`));
|
|
};
|
|
return [state, patch, id ? store.get(`/notices/${answerStatePointer(id)}`) as string | undefined : undefined] as const;
|
|
}
|
|
|
|
export function useAnswerBlockState() {
|
|
const store = useStateStore(), onChange = useContext(ChangeContext);
|
|
const block = store.get("/block") as AnswerViewState["block"];
|
|
return [block, (evidenceFilter: string) => {
|
|
const snapshot = store.getSnapshot() as AnswerViewState;
|
|
onChange?.({ nodes: snapshot.nodes, block: { evidenceFilter } }, ["/block/evidenceFilter"]);
|
|
}] as const;
|
|
}
|