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, Renderer} 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, Embeddings} 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; } // 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, 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; // 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 [version, setVersion] = React.useState(versionRegistry.initialVersion); const [graph, setGraph] = React.useState(emptyGraph); const [historyGraph, setHistoryGraph] = React.useState(initialHistoryGraph(versionRegistry.initialVersion)); const [deltaGraphL1, setDeltaGraphL1] = React.useState(emptyGraph); const [deltaGraphL0, setDeltaGraphL0] = React.useState(emptyGraph); // Reducer // Create and add a new version, and its deltas, without changing the current version const createVersion = (deltas: PrimitiveDelta[], description: string, parentHash: Buffer, embeddings: (Version) => Embeddings = () => new Map()) => { const parentVersion = versionRegistry.lookupOptional(parentHash); if (parentVersion !== undefined) { // The following is not very efficient, and it looks weird and hacky, but it works. // Cleaner looking solution would be to implement a function version.getGraphState() ... let prevVersion = currentVersion; gotoVersion(parentVersion); const dependencies = compositeLevel.findCompositeDependencies(deltas, graphState.composites) const composite = compositeLevel.createComposite(deltas, description, dependencies); gotoVersion(prevVersion); // go back const newVersion = versionRegistry.createVersion(parentVersion, composite, embeddings); setHistoryGraph(historyGraph => historyGraphReducer(historyGraph, {type: 'addVersion', version: newVersion})); setDeltaGraphL1(deltaGraphL1 => composite.deltas.length > 0 ? deltaGraphReducer(deltaGraphL1, {type: 'addDelta', delta: composite, active: false}) : deltaGraphL1); setDeltaGraphL0(deltaGraphL0 => composite.deltas.reduce((graph, delta) => deltaGraphReducer(graph, {type: 'addDelta', delta, active: false}), deltaGraphL0)); return newVersion; } }; const createAndGotoNewVersion = (deltas: PrimitiveDelta[], description: string, parentVersion: Version = currentVersion, embeddings: (Version) => Embeddings = () => new Map()): Version => { const newVersion = createVersion(deltas, description, parentVersion.hash, embeddings) as Version; gotoVersion(newVersion); return newVersion; }; // Idempotent const appendVersions = (versions: Version[]) => { setHistoryGraph(historyGraph => { const versionsToAdd: Version[] = []; const addIfDontHaveYet = version => { if (!(historyGraph.nodes.some(n => n.obj === version) || versionsToAdd.includes(version))) { collectVersions(version); } } const collectVersions = version => { // first add child, then parent // this prevents infinite recursion when a version explicitly embeds itself. versionsToAdd.push(version); for (const [parent] of version.parents) { addIfDontHaveYet(parent); } for (const {version: guest} of version.embeddings.values()) { addIfDontHaveYet(guest); } } for (const v of versions) { collectVersions(v); } return versionsToAdd.reduceRight((historyGraph, version) => historyGraphReducer(historyGraph, {type: 'addVersion', version}), historyGraph); }) } // // Idempotent // const appendDelta = (delta: CompositeDelta) => { // setState(({deltaGraphL0, deltaGraphL1, ...rest}) => { // return { // deltaGraphL1: deltaGraphReducer(deltaGraphL1, {type: 'addDelta', delta, active: false}), // deltaGraphL0: delta.reduce( // (graph, delta) => deltaGraphReducer(deltaGraphL0, {type: 'addDelta', delta, active: false}), // deltaGraphL0), // ...rest, // }; // }); // } const undoWithoutUpdatingHistoryGraph = (deltaToUndo) => { const d3Updater = new D3GraphUpdater(setGraph, x, y); graphState.unexec(deltaToUndo, d3Updater); setDeltaGraphL0(deltaGraphL0 => deltaToUndo.deltas.reduce((deltaGraphL0, delta) => deltaGraphReducer(deltaGraphL0, {type: 'setDeltaInactive', delta}), deltaGraphL0)); setDeltaGraphL1(deltaGraphL1 => deltaGraphReducer(deltaGraphL1, {type: 'setDeltaInactive', delta: deltaToUndo})); }; const redoWithoutUpdatingHistoryGraph = (deltaToRedo) => { const d3Updater = new D3GraphUpdater(setGraph, x, y); graphState.exec(deltaToRedo, d3Updater); setDeltaGraphL0(deltaGraphL0 => deltaToRedo.deltas.reduce((deltaGraphL0, delta) => deltaGraphReducer(deltaGraphL0, {type: 'setDeltaActive', delta}), deltaGraphL0)); setDeltaGraphL1(deltaGraphL1 => deltaGraphReducer(deltaGraphL1, {type: 'setDeltaActive', delta: deltaToRedo})); }; const undo = (parentVersion, deltaToUndo) => { undoWithoutUpdatingHistoryGraph(deltaToUndo); currentVersion = parentVersion; setVersion(prevVersion => { setHistoryGraph(historyGraph => historyGraphReducer(historyGraphReducer(historyGraph, {type: 'highlightVersion', version: prevVersion, bold: false}), {type: 'highlightVersion', version: parentVersion, bold: true})); return parentVersion; }); }; const redo = (childVersion, deltaToRedo) => { redoWithoutUpdatingHistoryGraph(deltaToRedo); currentVersion = childVersion; setVersion(prevVersion => { setHistoryGraph(historyGraph => historyGraphReducer(historyGraphReducer(historyGraph, {type: 'highlightVersion', version: prevVersion, bold: false}), {type: 'highlightVersion', version: childVersion, bold: true})); return childVersion; }); }; 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; setVersion(prevVersion => { setHistoryGraph(historyGraph => historyGraphReducer(historyGraphReducer(historyGraph, {type: 'highlightVersion', version: prevVersion, bold: false}), {type: 'highlightVersion', version: chosenVersion, bold: true})); return chosenVersion; }); }; const reducer = { createVersion, 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 ? {}} /> : {x = newX; y = newY;}} onUserEdit={callbacks.onUserEdit} /> ; const deltaComponentProps = { help: helpText.deltaGraph, defaultRenderer: ('graphviz' as Renderer), mouseUpHandler: (e, {x,y}, node) => { if (node) { alert(JSON.stringify(node.obj.serialize(), null, 2)); } }, }; const deltaGraphL0Component = graphData={deltaGraphL0} {...deltaComponentProps} />; const deltaGraphL1Component = graphData={deltaGraphL1} {...deltaComponentProps} />; const historyComponentWithMerge = callbacks.onMerge?.(outputs)} onGoto={version => callbacks.onVersionClicked?.(version)} appendVersions={reducer.appendVersions} {...{primitiveRegistry, compositeLevel, createVersion}} />; const rountangleEditor = ; const makeUndoOrRedoButton = (parentsOrChildren, text, leftIcon?, rightIcon?, callback?) => { if (parentsOrChildren.length === 0) { return {text}; } if (parentsOrChildren.length === 1) { return {text}; } return {text} ({parentsOrChildren.length.toString()}) {parentsOrChildren.map(([parentOrChildVersion,deltaToUndoOrRedo]) => {deltaToUndoOrRedo.getDescription()})} ; } const undoButton = makeUndoOrRedoButton(version.parents, "Undo", , null, callbacks.onUndoClicked); const redoButton = makeUndoOrRedoButton(version.children, "Redo", null, , callbacks.onRedoClicked); const undoRedoButtons = <> {undoButton} {redoButton} ; const stackedUndoButtons = version.parents.map(([parentVersion,deltaToUndo]) => { return (
} onClick={callbacks.onUndoClicked?.bind(null, parentVersion, deltaToUndo)}> UNDO {deltaToUndo.getDescription()}
); }); const stackedRedoButtons = version.children.map(([childVersion,deltaToRedo]) => { return (
} onClick={callbacks.onRedoClicked?.bind(null, childVersion, deltaToRedo)}> REDO {deltaToRedo.getDescription()}
); }); const stackedUndoRedoButtons = (
{stackedUndoButtons}
{stackedRedoButtons}
); const makeTabs = (defaultTab: string, tabs: string[]) => { return {tabs.map(tab => ({ editor: Editor, state: State, merge: History, deltaL1: Deltas (L1), deltaL0: Deltas (L0), }[tab]))} {graphStateComponent} {rountangleEditor} {deltaGraphL1Component} {deltaGraphL0Component} {historyComponentWithMerge} ; } return { state: { version, }, reducer, components: { graphStateComponent, rountangleEditor, deltaGraphL1Component, deltaGraphL0Component, historyComponentWithMerge, undoButton, redoButton, undoRedoButtons, stackedUndoRedoButtons, makeTabs, }, }; } return { graphState, useOnion, } }