|
@@ -0,0 +1,386 @@
|
|
|
+import * as React from "react";
|
|
|
+import * as Mantine from "@mantine/core";
|
|
|
+import * as Icons from "@tabler/icons";
|
|
|
+
|
|
|
+import {D3OnionGraphData, D3GraphUpdater} from "../d3graph/reducers/onion_graph";
|
|
|
+import {D3GraphEditable, UserEditCallback} from "../d3graph/d3graph_editable";
|
|
|
+
|
|
|
+import {
|
|
|
+ DeltaGraphState,
|
|
|
+ fullDeltaId,
|
|
|
+ deltaGraphReducer,
|
|
|
+} from "../d3graph/reducers/delta_graph";
|
|
|
+
|
|
|
+import {
|
|
|
+ HistoryGraphState,
|
|
|
+ initialHistoryGraph,
|
|
|
+ fullVersionId,
|
|
|
+ historyGraphReducer,
|
|
|
+} from "../d3graph/reducers/history_graph";
|
|
|
+
|
|
|
+import * as helpText from "./help_text";
|
|
|
+import {MergeView} from "./merge_view";
|
|
|
+import {GraphView} from "./graph_view";
|
|
|
+
|
|
|
+import {D3Graph, emptyGraph, defaultGraphForces} from "../d3graph/d3graph";
|
|
|
+import {RountangleEditor} from "../rountangleEditor/RountangleEditor";
|
|
|
+import {InfoHoverCardOverlay} from "../info_hover_card";
|
|
|
+import {OnionContext, OnionContextType} from "../onion_context";
|
|
|
+
|
|
|
+import {Version, VersionRegistry} from "onion/version";
|
|
|
+import {PrimitiveDelta, PrimitiveRegistry} from "onion/primitive_delta";
|
|
|
+import {PrimitiveValue, UUID} from "onion/types";
|
|
|
+import {CompositeDelta, CompositeLevel} from "onion/composite_delta";
|
|
|
+import {GraphState} from "onion/graph_state";
|
|
|
+import {Delta} from "onion/delta";
|
|
|
+import {DeltaParser} from "onion/delta_parser";
|
|
|
+
|
|
|
+export const undoButtonHelpText = "Use the Undo/Redo buttons or the History panel to navigate to any version.";
|
|
|
+
|
|
|
+export interface VersionedModelState {
|
|
|
+ version: Version; // the 'current version'
|
|
|
+ graph: D3OnionGraphData; // the state what is displayed in the leftmost panel
|
|
|
+ historyGraph: HistoryGraphState; // the state of what is displayed in the middle panel
|
|
|
+ deltaGraphL1: DeltaGraphState; // the state of what is displayed in the rightmost panel
|
|
|
+ deltaGraphL0: DeltaGraphState; // the state of what is displayed in the rightmost panel
|
|
|
+}
|
|
|
+
|
|
|
+interface VersionedModelCallbacks {
|
|
|
+ onUserEdit?: UserEditCallback;
|
|
|
+ onUndoClicked?: (parentVersion: Version, deltaToUndo: Delta) => void;
|
|
|
+ onRedoClicked?: (childVersion: Version, deltaToRedo: Delta) => void;
|
|
|
+ onVersionClicked?: (Version) => void;
|
|
|
+ onMerge?: (outputs: Version[]) => void;
|
|
|
+ onImport?: (json: Array<any>) => void;
|
|
|
+}
|
|
|
+
|
|
|
+// Basically everything we need to construct the React components for:
|
|
|
+// - Graph state (+ optionally, a Rountangle Editor)
|
|
|
+// - History graph (+undo/redo buttons)
|
|
|
+// - Delta graph
|
|
|
+// , their state, and callbacks for updating their state.
|
|
|
+export function newOnion({readonly, primitiveRegistry}) {
|
|
|
+ const versionRegistry = new VersionRegistry();
|
|
|
+ const graphState = new GraphState();
|
|
|
+ const compositeLevel = new CompositeLevel();
|
|
|
+
|
|
|
+ // SVG coordinates to be used when adding a new node
|
|
|
+ let x = 0;
|
|
|
+ let y = 0;
|
|
|
+
|
|
|
+ const initialState: VersionedModelState = {
|
|
|
+ version: versionRegistry.initialVersion,
|
|
|
+ graph: emptyGraph,
|
|
|
+ historyGraph: initialHistoryGraph(versionRegistry.initialVersion),
|
|
|
+ deltaGraphL1: emptyGraph,
|
|
|
+ deltaGraphL0: emptyGraph,
|
|
|
+ }
|
|
|
+
|
|
|
+ // The "current version" is both part of the React state (for rendering undo/redo buttons) and a local variable here, such that we can get the current version (synchronously), even outside of a setState-callback.
|
|
|
+ let currentVersion = versionRegistry.initialVersion;
|
|
|
+ function getCurrentVersion() {
|
|
|
+ return currentVersion;
|
|
|
+ }
|
|
|
+
|
|
|
+ function useOnion(overridenCallbacks: (any) => VersionedModelCallbacks) {
|
|
|
+ const [state, setState] = React.useState<VersionedModelState>(initialState);
|
|
|
+
|
|
|
+ // Reducer
|
|
|
+
|
|
|
+ // Create and add a new version, and its deltas, without changing the current version
|
|
|
+ const addDeltasAndVersion = (deltas: PrimitiveDelta[], description: string, parentHash: Buffer) => {
|
|
|
+ const composite = compositeLevel.createComposite(deltas, description);
|
|
|
+
|
|
|
+ const parentVersion = versionRegistry.lookupOptional(parentHash);
|
|
|
+ if (parentVersion !== undefined) {
|
|
|
+ const newVersion = versionRegistry.createVersion(parentVersion, composite);
|
|
|
+
|
|
|
+ setState(({historyGraph, deltaGraphL1, deltaGraphL0, ...rest}) => {
|
|
|
+ return {
|
|
|
+ // add new version to history graph:
|
|
|
+ historyGraph: historyGraphReducer(historyGraph, {type: 'addVersion', version: newVersion}),
|
|
|
+ // add the composite delta to the L1-graph + highlight it as 'active':
|
|
|
+ deltaGraphL1: composite.deltas.length > 0 ? deltaGraphReducer(deltaGraphL1, {type: 'addDelta', delta: composite, active: false}) : deltaGraphL1, // never add an empty composite
|
|
|
+ // add the primitive L0-deltas to the L0-graph + highlight them as 'active':
|
|
|
+ deltaGraphL0: composite.deltas.reduce(
|
|
|
+ (graph, delta) => deltaGraphReducer(graph, {type: 'addDelta', delta, active: false}),
|
|
|
+ deltaGraphL0),
|
|
|
+ ...rest,
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ return newVersion;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const createAndGotoNewVersion = (deltas: PrimitiveDelta[], description: string, parentVersion: Version = currentVersion): Version => {
|
|
|
+ const newVersion = addDeltasAndVersion(deltas, description, parentVersion.hash) as Version;
|
|
|
+ gotoVersion(newVersion);
|
|
|
+ return newVersion;
|
|
|
+ };
|
|
|
+ const appendVersions = (versions: Version[]) => {
|
|
|
+ setState(({historyGraph, ...rest}) => {
|
|
|
+ const ordered: Version[] = [];
|
|
|
+ const makeSureParentVersionsAreThere = version => {
|
|
|
+ for (const [parent] of version.parents) {
|
|
|
+ if (! (historyGraph.nodes.some(n => n.obj === parent) || ordered.includes(parent))) {
|
|
|
+ makeSureParentVersionsAreThere(parent);
|
|
|
+ ordered.push(parent);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (const v of versions) {
|
|
|
+ makeSureParentVersionsAreThere(v);
|
|
|
+ ordered.push(v);
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ historyGraph: ordered.reduce((historyGraph, version) => historyGraphReducer(historyGraph, {type: 'addVersion', version}), historyGraph),
|
|
|
+ ...rest,
|
|
|
+ };
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // helper
|
|
|
+ const setGraph = callback =>
|
|
|
+ setState(({graph, ...rest}) => ({graph: callback(graph), ...rest}));
|
|
|
+
|
|
|
+ const undoWithoutUpdatingHistoryGraph = (deltaToUndo) => {
|
|
|
+ const d3Updater = new D3GraphUpdater(setGraph, x, y);
|
|
|
+ graphState.unexec(deltaToUndo, d3Updater);
|
|
|
+ setState(({deltaGraphL0: prevDeltaGraphL0, deltaGraphL1: prevDeltaGraphL1, ...rest}) => ({
|
|
|
+ deltaGraphL1: deltaGraphReducer(prevDeltaGraphL1, {type: 'setDeltaInactive', delta: deltaToUndo}),
|
|
|
+ deltaGraphL0: deltaToUndo.deltas.reduce((prevDeltaGraphL0, delta) => deltaGraphReducer(prevDeltaGraphL0, {type: 'setDeltaInactive', delta}),prevDeltaGraphL0),
|
|
|
+ ...rest,
|
|
|
+ }));
|
|
|
+ };
|
|
|
+ const redoWithoutUpdatingHistoryGraph = (deltaToRedo) => {
|
|
|
+ const d3Updater = new D3GraphUpdater(setGraph, x, y);
|
|
|
+ graphState.exec(deltaToRedo, d3Updater);
|
|
|
+ setState(({deltaGraphL0: prevDeltaGraphL0, deltaGraphL1: prevDeltaGraphL1, ...rest}) => ({
|
|
|
+ deltaGraphL1: deltaGraphReducer(prevDeltaGraphL1, {type: 'setDeltaActive', delta: deltaToRedo}),
|
|
|
+ deltaGraphL0: deltaToRedo.deltas.reduce((prevDeltaGraphL0, delta) => deltaGraphReducer(prevDeltaGraphL0, {type: 'setDeltaActive', delta}),
|
|
|
+ prevDeltaGraphL0),
|
|
|
+ ...rest,
|
|
|
+ }));
|
|
|
+ };
|
|
|
+ const undo = (parentVersion, deltaToUndo) => {
|
|
|
+ undoWithoutUpdatingHistoryGraph(deltaToUndo);
|
|
|
+ currentVersion = parentVersion;
|
|
|
+ setState(({historyGraph: prevHistoryGraph, version: prevVersion, ...rest}) => ({
|
|
|
+ version: parentVersion,
|
|
|
+ historyGraph: historyGraphReducer(historyGraphReducer(prevHistoryGraph,
|
|
|
+ {type: 'highlightVersion', version: prevVersion, bold: false}),
|
|
|
+ {type: 'highlightVersion', version: parentVersion, bold: true}),
|
|
|
+ ...rest,
|
|
|
+ }));
|
|
|
+ };
|
|
|
+ const redo = (childVersion, deltaToRedo) => {
|
|
|
+ redoWithoutUpdatingHistoryGraph(deltaToRedo);
|
|
|
+ currentVersion = childVersion;
|
|
|
+ setState(({historyGraph: prevHistoryGraph, version: prevVersion, ...rest}) => ({
|
|
|
+ version: childVersion,
|
|
|
+ historyGraph: historyGraphReducer(historyGraphReducer(prevHistoryGraph,
|
|
|
+ {type: 'highlightVersion', version: prevVersion, bold: false}),
|
|
|
+ {type: 'highlightVersion', version: childVersion, bold: true}),
|
|
|
+ ...rest,
|
|
|
+ }));
|
|
|
+ };
|
|
|
+ const gotoVersion = (chosenVersion: Version) => {
|
|
|
+ const path = currentVersion.findPathTo(chosenVersion);
|
|
|
+ if (path === undefined) {
|
|
|
+ throw new Error("Could not find path to version!");
|
|
|
+ }
|
|
|
+ for (const [linkType, delta] of path) {
|
|
|
+ if (linkType === 'p') {
|
|
|
+ undoWithoutUpdatingHistoryGraph(delta);
|
|
|
+ }
|
|
|
+ else if (linkType === 'c') {
|
|
|
+ redoWithoutUpdatingHistoryGraph(delta);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ currentVersion = chosenVersion;
|
|
|
+ setState(({historyGraph, version: oldVersion, ...rest}) => ({
|
|
|
+ version: chosenVersion,
|
|
|
+ historyGraph: historyGraphReducer(historyGraphReducer(historyGraph,
|
|
|
+ {type: 'highlightVersion', version: oldVersion, bold: false}),
|
|
|
+ {type: 'highlightVersion', version: chosenVersion, bold: true}),
|
|
|
+ ...rest,
|
|
|
+ }));
|
|
|
+ };
|
|
|
+
|
|
|
+ const reducer = {
|
|
|
+ addDeltasAndVersion,
|
|
|
+ gotoVersion,
|
|
|
+ createAndGotoNewVersion,
|
|
|
+ appendVersions,
|
|
|
+ undo,
|
|
|
+ redo,
|
|
|
+ };
|
|
|
+
|
|
|
+ // Components
|
|
|
+
|
|
|
+ const defaultCallbacks = {
|
|
|
+ onUserEdit: createAndGotoNewVersion,
|
|
|
+ onUndoClicked: undo,
|
|
|
+ onRedoClicked: redo,
|
|
|
+ onVersionClicked: gotoVersion,
|
|
|
+ onMerge: appendVersions,
|
|
|
+ };
|
|
|
+
|
|
|
+ const callbacks = Object.assign({}, defaultCallbacks, overridenCallbacks(reducer));
|
|
|
+
|
|
|
+ const graphStateComponent = readonly ?
|
|
|
+ <GraphView graphData={state.graph} help={helpText.graphEditorReadonly} mouseUpHandler={()=>{}} />
|
|
|
+ : <InfoHoverCardOverlay contents={helpText.graphEditor}>
|
|
|
+ <D3GraphEditable
|
|
|
+ graph={state.graph}
|
|
|
+ graphState={graphState}
|
|
|
+ forces={defaultGraphForces}
|
|
|
+ setNextNodePosition={(newX,newY) => {x = newX; y = newY;}}
|
|
|
+ onUserEdit={callbacks.onUserEdit}
|
|
|
+ />
|
|
|
+ </InfoHoverCardOverlay>;
|
|
|
+
|
|
|
+ // Serialize delta
|
|
|
+ const onDeltaClick = (e, {x,y}, node) => {
|
|
|
+ if (node) {
|
|
|
+ alert(JSON.stringify(node.obj.serialize(), null, 2));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const deltaGraphL0Component = <GraphView graphData={state.deltaGraphL0} help={helpText.deltaGraph} mouseUpHandler={onDeltaClick} />;
|
|
|
+ const deltaGraphL1Component = <GraphView graphData={state.deltaGraphL1} help={helpText.deltaGraph} mouseUpHandler={onDeltaClick} />;
|
|
|
+ const historyComponent = <GraphView graphData={state.historyGraph} help={helpText.historyGraph}
|
|
|
+ mouseUpHandler={(e, {x, y}, node) => node ? callbacks.onVersionClicked?.(node.obj) : undefined} />;
|
|
|
+
|
|
|
+ const historyComponentWithMerge = <MergeView
|
|
|
+ history={state.historyGraph}
|
|
|
+ setHistory={callback => setState(({historyGraph, ...rest})=>({historyGraph: callback(historyGraph), ...rest}))}
|
|
|
+ forces={defaultGraphForces}
|
|
|
+ versionRegistry={versionRegistry}
|
|
|
+ onMerge={outputs => callbacks.onMerge?.(outputs)}
|
|
|
+ onGoto={version => callbacks.onVersionClicked?.(version)}
|
|
|
+ {...{primitiveRegistry, compositeLevel, addDeltasAndVersion}}
|
|
|
+ />
|
|
|
+
|
|
|
+ const rountangleEditor = <InfoHoverCardOverlay contents={helpText.rountangleEditor}>
|
|
|
+ <RountangleEditor
|
|
|
+ graph={state.graph}
|
|
|
+ graphState={graphState}
|
|
|
+ onUserEdit={callbacks.onUserEdit}
|
|
|
+ />,
|
|
|
+ </InfoHoverCardOverlay>;
|
|
|
+
|
|
|
+ const makeUndoOrRedoButton = (parentsOrChildren, text, leftIcon?, rightIcon?, callback?) => {
|
|
|
+ if (parentsOrChildren.length === 0) {
|
|
|
+ return <Mantine.Button compact leftIcon={leftIcon} rightIcon={rightIcon} disabled>{text}</Mantine.Button>;
|
|
|
+ }
|
|
|
+ if (parentsOrChildren.length === 1) {
|
|
|
+ return <Mantine.Button compact leftIcon={leftIcon} rightIcon={rightIcon} onClick={callback?.bind(null, parentsOrChildren[0][0], parentsOrChildren[0][1])}>{text}</Mantine.Button>;
|
|
|
+ }
|
|
|
+ return <Mantine.Menu shadow="md" position="bottom-start" trigger="hover" offset={0} transitionDuration={0}>
|
|
|
+ <Mantine.Menu.Target>
|
|
|
+ <Mantine.Button compact leftIcon={leftIcon} rightIcon={rightIcon}>{text} ({parentsOrChildren.length.toString()})</Mantine.Button>
|
|
|
+ </Mantine.Menu.Target>
|
|
|
+ <Mantine.Menu.Dropdown>
|
|
|
+ {/*<Mantine.Menu.Label>{text}</Mantine.Menu.Label>*/}
|
|
|
+ {parentsOrChildren.map(([parentOrChildVersion,deltaToUndoOrRedo]) =>
|
|
|
+ <Mantine.Menu.Item key={fullDeltaId(deltaToUndoOrRedo)} onClick={callback?.bind(null, parentOrChildVersion, deltaToUndoOrRedo)}>{deltaToUndoOrRedo.getDescription()}</Mantine.Menu.Item>)}
|
|
|
+ </Mantine.Menu.Dropdown>
|
|
|
+ </Mantine.Menu>;
|
|
|
+
|
|
|
+ }
|
|
|
+ const undoButton = makeUndoOrRedoButton(state.version.parents, "Undo", <Icons.IconChevronLeft/>, null, callbacks.onUndoClicked);
|
|
|
+ const redoButton = makeUndoOrRedoButton(state.version.children, "Redo", null, <Icons.IconChevronRight/>, callbacks.onRedoClicked);
|
|
|
+
|
|
|
+ const undoRedoButtons = <>
|
|
|
+ {undoButton}
|
|
|
+ <Mantine.Space w="sm"/>
|
|
|
+ {redoButton}
|
|
|
+ </>;
|
|
|
+
|
|
|
+ const stackedUndoButtons = state.version.parents.map(([parentVersion,deltaToUndo]) => {
|
|
|
+ return (
|
|
|
+ <div key={fullVersionId(parentVersion)}>
|
|
|
+ <Mantine.Button fullWidth={true} compact={true} leftIcon={<Icons.IconChevronLeft size={18}/>} onClick={callbacks.onUndoClicked?.bind(null, parentVersion, deltaToUndo)}>
|
|
|
+ UNDO {deltaToUndo.getDescription()}
|
|
|
+ </Mantine.Button>
|
|
|
+ <Mantine.Space h="xs"/>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ });
|
|
|
+ const stackedRedoButtons = state.version.children.map(([childVersion,deltaToRedo]) => {
|
|
|
+ return (
|
|
|
+ <div key={fullVersionId(childVersion)}>
|
|
|
+ <Mantine.Button style={{width: "100%"}} compact={true} rightIcon={<Icons.IconChevronRight size={18}/>} onClick={callbacks.onRedoClicked?.bind(null, childVersion, deltaToRedo)}>
|
|
|
+ REDO {deltaToRedo.getDescription()}
|
|
|
+ </Mantine.Button>
|
|
|
+ <Mantine.Space h="xs"/>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ });
|
|
|
+ const stackedUndoRedoButtons = (
|
|
|
+ <Mantine.SimpleGrid cols={2}>
|
|
|
+ <div>{stackedUndoButtons}</div>
|
|
|
+ <div>{stackedRedoButtons}</div>
|
|
|
+ </Mantine.SimpleGrid>
|
|
|
+ );
|
|
|
+ const makeTabs = (defaultTab: string, tabs: string[]) => {
|
|
|
+ return <Mantine.Tabs defaultValue={defaultTab} keepMounted={false}>
|
|
|
+ <Mantine.Tabs.List grow>
|
|
|
+ {tabs.map(tab => ({
|
|
|
+ editor: <Mantine.Tabs.Tab key={tab} value={tab}>Editor</Mantine.Tabs.Tab>,
|
|
|
+ state: <Mantine.Tabs.Tab key={tab} value={tab}>State</Mantine.Tabs.Tab>,
|
|
|
+ history: <Mantine.Tabs.Tab key={tab} value={tab}>History</Mantine.Tabs.Tab>,
|
|
|
+ merge: <Mantine.Tabs.Tab key={tab} value={tab}>History</Mantine.Tabs.Tab>,
|
|
|
+ deltaL1: <Mantine.Tabs.Tab key={tab} value={tab}>Deltas (L1)</Mantine.Tabs.Tab>,
|
|
|
+ deltaL0: <Mantine.Tabs.Tab key={tab} value={tab}>Deltas (L0)</Mantine.Tabs.Tab>,
|
|
|
+ }[tab]))}
|
|
|
+ </Mantine.Tabs.List>
|
|
|
+ <Mantine.Tabs.Panel value="state">
|
|
|
+ {graphStateComponent}
|
|
|
+ </Mantine.Tabs.Panel>
|
|
|
+ <Mantine.Tabs.Panel value="editor">
|
|
|
+ {rountangleEditor}
|
|
|
+ </Mantine.Tabs.Panel>
|
|
|
+ <Mantine.Tabs.Panel value="deltaL1">
|
|
|
+ {deltaGraphL1Component}
|
|
|
+ </Mantine.Tabs.Panel>
|
|
|
+ <Mantine.Tabs.Panel value="deltaL0">
|
|
|
+ {deltaGraphL0Component}
|
|
|
+ </Mantine.Tabs.Panel>
|
|
|
+ <Mantine.Tabs.Panel value="history">
|
|
|
+ {historyComponent}
|
|
|
+ </Mantine.Tabs.Panel>
|
|
|
+ <Mantine.Tabs.Panel value="merge">
|
|
|
+ {historyComponentWithMerge}
|
|
|
+ </Mantine.Tabs.Panel>
|
|
|
+ </Mantine.Tabs>;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ return {
|
|
|
+ state,
|
|
|
+ reducer,
|
|
|
+ components: {
|
|
|
+ graphStateComponent,
|
|
|
+ rountangleEditor,
|
|
|
+ deltaGraphL1Component,
|
|
|
+ deltaGraphL0Component,
|
|
|
+ historyComponent,
|
|
|
+ historyComponentWithMerge,
|
|
|
+ undoButton,
|
|
|
+ redoButton,
|
|
|
+ undoRedoButtons,
|
|
|
+ stackedUndoRedoButtons,
|
|
|
+ makeTabs,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ initialState,
|
|
|
+ graphState,
|
|
|
+ versionRegistry,
|
|
|
+ getCurrentVersion,
|
|
|
+ useOnion,
|
|
|
+ }
|
|
|
+}
|